feat(cmcc): 中国移动签到有礼自动签到(Python3 重放素材链路)
协议链路 2026-11-07 全实验裁决后投产: - 重放 autoLogin 素材换会话(命中即停 + 15 败长静默防风暴冷却) - appTokenLogin 换 QWHD_SESSION_TOKEN(token=原始 Set-Cookie 全值) - mark31/domark 签到 + 幂等(markstatus 先行) - 出口 IP 无关/素材无小时级寿命 已实测证伪,云青龙可直接部署 - 通知走 webhook+pushplus 双通道(与 common.js 契约同名的 Python 实现) - 素材获取:保持登录态等自动续期抓一次即可,无每日仪式
This commit is contained in:
60
cmcc/README.md
Normal file
60
cmcc/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 中国移动自动签到(scrip_cmcc_mark.py)
|
||||||
|
|
||||||
|
中国移动 APP「签到有礼」自动签到,青龙 Python3 零第三方依赖(只用 requests,面板自带)。
|
||||||
|
|
||||||
|
协议底座(全部实测,2026-11-07):重放抓包素材 `autoLogin` 换 APP 会话 →
|
||||||
|
`appTokenLogin` 换 `QWHD_SESSION_TOKEN` → `mark31/domark` 签到。
|
||||||
|
三大前提已实验裁决:出口 IP 无关、素材寿命是「冷却」机制而非小时数、
|
||||||
|
抓素材需登录态但**一次登录长期收**(已登录冷启动不发 autoLogin,无每日仪式)。
|
||||||
|
|
||||||
|
> **文件名必须带 `scrip_` 前缀**:订阅白名单规则(根 README)。
|
||||||
|
> 元数据头两行 `// cron:` / `// name:`(Python 用 `#`)已按仓库约定写好,
|
||||||
|
> 新增后跑 `node _probe_meta.js` 需把 `.py` 加入扫描范围(当前探针只扫 js,
|
||||||
|
> Python 侧由本 README 说明兜底)。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CMCC_AUTOLOGIN_CURL` | ✅(与 CMCC_TOKEN 二选一) | 整段 cURL(手机抓包导出 autoLogin 请求)。多号用 `CMCC_AUTOLOGIN_CURL_2` / `_3` … 编号变量,**每号一份素材**(素材与账号一对一) |
|
||||||
|
| `CMCC_TOKEN` | 备选 | 直通 token(仅当天调试;TTL≈30min,隔日必死) |
|
||||||
|
| `CMCC_NOTIFY_ON_SUCCESS` | 可选 | `1` = 签到成功也推送(默认只推失败) |
|
||||||
|
| `WEBHOOK_URL` / `PUSH_PLUS_TOKEN` 等 | 可选 | 通知通道,与仓库 `common.js` 契约同名同义(Python 侧同语义实现) |
|
||||||
|
|
||||||
|
## 素材怎么抓(一次登录,长期收)
|
||||||
|
|
||||||
|
1. 手机保持 APP **登录态**(不用清数据——已实测清数据后冷启动不发 autoLogin)
|
||||||
|
2. 开抓包(Reqable/HttpCanary/mitmproxy),在 APP 里触发一次登录或等它自动续期
|
||||||
|
3. 找 `client.app.coc.10086.cn` 的 `uamrandcodelogin/autoLogin` 请求
|
||||||
|
(**响应必须含 `Set-Cookie: JSESSIONID`**,没有的是失败记录不是素材)
|
||||||
|
4. 导出为 cURL(含 header + body),整段贴进 `CMCC_AUTOLOGIN_CURL`
|
||||||
|
5. **抓完立刻关手机代理**(`settings put global http_proxy :0`,否则 APP 断网)
|
||||||
|
|
||||||
|
素材失效的信号是**连续多次全 64B**(会话层死)——这时才需要重抓。
|
||||||
|
88B ≠ 失效(素材活着只是没命中,稍后再跑);236B ≠ 封号(慢节拍通道,快节拍重试)。
|
||||||
|
|
||||||
|
## 定时
|
||||||
|
|
||||||
|
```
|
||||||
|
40 8 * * * # 主任务(脚本头注已写,订阅自动建任务用)
|
||||||
|
0 12 * * * # 建议手动补一条错峰补签——命中即停模式下 30 发全败概率 ≈36%,双 cron 把日失败率压到 <13%
|
||||||
|
```
|
||||||
|
|
||||||
|
同一日重复运行幂等(`HAVE_MARKED`/markstatus 先行判断),多补一条 cron 无副作用。
|
||||||
|
|
||||||
|
## 退出码
|
||||||
|
|
||||||
|
| 码 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | 成功 / 今日已签 |
|
||||||
|
| 1 | 签到动作失败(复核未通过) |
|
||||||
|
| 2 | 素材问题(重放未命中 / cURL 解析损坏) |
|
||||||
|
| 3 | 换票失败([C]/[D] 层) |
|
||||||
|
|
||||||
|
## 实现关键坑(改动前必读)
|
||||||
|
|
||||||
|
1. **token 必须用原始合并 Set-Cookie 全值**(`split("; Secure")` 前段,含 Comment/Path 属性碎片)——结构化重建三段式实测报「APP登录状态已过期」
|
||||||
|
2. **[A] 重放必须无状态 `requests.post`**——Session 的 `prepare_cookies` 会用 jar 覆盖素材 Cookie 头 → 64B
|
||||||
|
3. **Set-Cookie 名字判定必须 `re.findall`**——服务端把 JSESSIONID+UID+ticketID 合并在同一条头里,`re.match` 取首个恒漏 UID
|
||||||
|
4. **命中即停**——命中后立刻冷却(连打全 88B),循环里多打是纯浪费还喂风暴
|
||||||
|
5. sid 正则必须 `[A-Za-z0-9]+`——大写版截断尾部随机串 → 换票 404(但 code 仍 SUCCESS,判据是 url 含 404)
|
||||||
486
cmcc/scrip_cmcc_mark.py
Normal file
486
cmcc/scrip_cmcc_mark.py
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# cron: 40 8 * * *
|
||||||
|
# name: 中国移动签到
|
||||||
|
"""
|
||||||
|
中国移动 APP「签到有礼」自动签到(青龙 Python3,纯 requests)
|
||||||
|
|
||||||
|
上面两行是青龙订阅自动建任务用的元数据注解(约定见仓库根 README):
|
||||||
|
- cron 独占一行、后面不跟任何注释
|
||||||
|
- 全文不得再出现任务名/cron 的行内连写(青龙按全文第一个命中提取)
|
||||||
|
|
||||||
|
协议链路(全部实测,2026-11-07):
|
||||||
|
[A] 重放抓包素材 autoLogin(原样,一字符不改)→ 492B 命中 → Set-Cookie 全值 = 会话
|
||||||
|
★ 实测:素材寿命上限是「命中/风暴后冷却」机制,不是小时数——
|
||||||
|
34h 老素材仍首发命中;命中后立刻冷却(连打全 88B)→ 命中即停
|
||||||
|
★ 实测:出口 IP 无关(AWS 出口同样命中);慢节拍(60s+)会被推向 236B
|
||||||
|
[B] GET 活动页 → 302 → 正则 sid=([A-Za-z0-9]+)(大写版会截断尾部随机串)
|
||||||
|
[C] POST appTokenLogin?sid=… body={"token": sess}
|
||||||
|
★ token 必须是原始合并 Set-Cookie 全值(含 Comment/Path 属性碎片)——
|
||||||
|
结构化重建三段式实测报「APP登录状态已过期」
|
||||||
|
[D] GET data.url → QWHD_SESSION_TOKEN(实测 ≥108min)
|
||||||
|
[E] /user/info 验活(content-type 含 json 判据)→ mark31/markstatus → domark → 复核
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
CMCC_AUTOLOGIN_CURL 整段 cURL(手机抓包 → Reqable/HttpCanary 导出 autoLogin 为 cURL)
|
||||||
|
CMCC_AUTOLOGIN_CURL_2/3 多号:编号变量主方案(每号一份素材,素材与账号一对一)
|
||||||
|
CMCC_TOKEN 直通 token(仅当天调试;TTL≈30min,隔日必死)
|
||||||
|
CMCC_NOTIFY_ON_SUCCESS "1" = 成功也推送(默认只推失败)
|
||||||
|
|
||||||
|
素材怎么抓(保持 APP 登录态,无需清数据/无需每日短信):
|
||||||
|
1. 手机开抓包代理(Reqable/HttpCanary 或 mitmproxy)
|
||||||
|
2. APP 里触发一次登录或等它自动续期 → 抓 client.app.coc.10086.cn 的
|
||||||
|
uamrandcodelogin/autoLogin 请求(响应须含 Set-Cookie: JSESSIONID)
|
||||||
|
3. 导出为 cURL,整段贴进 CMCC_AUTOLOGIN_CURL
|
||||||
|
4. 抓完关代理!(settings put global http_proxy :0,否则 APP 断网)
|
||||||
|
|
||||||
|
退出码:0 成功/已签;1 签到失败;2 素材/cURL 问题;3 换票失败
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import shlex
|
||||||
|
try:
|
||||||
|
import fcntl # Linux(青龙)生效;Windows 降级跳过
|
||||||
|
_HAVE_FCNTL = True
|
||||||
|
except ImportError:
|
||||||
|
_HAVE_FCNTL = False
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 常量
|
||||||
|
|
||||||
|
UA_WEB = ("Mozilla/5.0 (Linux; Android 11; MI 9 Build/RKQ1.200826.002; wv) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/109.0.5414.86 MQQBrowser/6.2 TBS/047115 "
|
||||||
|
"Mobile Safari/537.36 leadeon/12.0.9/CMCCIT")
|
||||||
|
ACT_URL = "https://wx.10086.cn/qwhdhub/qwhdmark/1021122301?channelId=P00000132551&yx=1391563104"
|
||||||
|
BASE = "https://wx.10086.cn/qwhdhub/api/mark"
|
||||||
|
SSO = "https://wx.10086.cn/qwhdsso"
|
||||||
|
|
||||||
|
UTC8 = timezone(timedelta(hours=8)) # ★ 显式 UTC+8:青龙容器时区可能不是 +8
|
||||||
|
STRIP_HEADERS = ("content-length", "accept-encoding", "host", "connection")
|
||||||
|
|
||||||
|
REPLAY_MAX = 30 # 命中即停:实测中位 1~9 发;30 发封顶,由错峰补签 cron 兜底
|
||||||
|
REPLAY_GAP = 5.0 # 3~5s 快节拍(慢节拍推向 236B 通道)
|
||||||
|
WALL_LIMIT = 6 * 60 # [A] 墙钟上限(含长静默),防止极端情况拖死任务
|
||||||
|
BACKOFF_AFTER = 15 # 连续 15 败 → 一次 10min 静默(防风暴冷却,只插一次)
|
||||||
|
BACKOFF_SECS = 600
|
||||||
|
THROTTLE = 1.2 # 业务请求间隔
|
||||||
|
|
||||||
|
MASK = lambda s: (s[:3] + "****" + s[-4:]) if s and len(s) >= 7 else (s or "?")
|
||||||
|
|
||||||
|
|
||||||
|
def now8():
|
||||||
|
return datetime.now(UTC8)
|
||||||
|
|
||||||
|
|
||||||
|
def today_str():
|
||||||
|
return now8().strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print("[%s] %s" % (now8().strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 通知
|
||||||
|
# 复用仓库通知约定:webhook + pushplus 双通道,任一成功即 ok,永不抛错。
|
||||||
|
# 不用青龙 sendNotify.js —— deps 复制顺序决定它每轮被面板自带版覆盖(见仓库 common.js 头注)。
|
||||||
|
# Python 侧无法 require JS 的 common.js → 在这里实现同一契约的轻量版。
|
||||||
|
|
||||||
|
def send_notify(title, content):
|
||||||
|
hook = os.environ.get("WEBHOOK_URL", "").strip()
|
||||||
|
token = os.environ.get("PUSH_PLUS_TOKEN", "").strip()
|
||||||
|
topic = os.environ.get("PUSH_PLUS_USER", "").strip()
|
||||||
|
sent = False
|
||||||
|
if hook:
|
||||||
|
try:
|
||||||
|
body = os.environ.get("WEBHOOK_BODY", "").strip()
|
||||||
|
if body:
|
||||||
|
body = body.replace("$title", title).replace("$content", content)
|
||||||
|
else:
|
||||||
|
body = json.dumps({"title": title, "content": content}, ensure_ascii=False)
|
||||||
|
hdrs = {"Content-Type": os.environ.get("WEBHOOK_CONTENT_TYPE", "application/json")}
|
||||||
|
for ln in os.environ.get("WEBHOOK_HEADERS", "").splitlines():
|
||||||
|
if ":" in ln:
|
||||||
|
k, v = ln.split(":", 1)
|
||||||
|
hdrs[k.strip()] = v.strip()
|
||||||
|
r = requests.post(hook, data=body.encode("utf-8"), headers=hdrs, timeout=15)
|
||||||
|
ok = 200 <= r.status_code < 300
|
||||||
|
sent = sent or ok
|
||||||
|
log("[通知] webhook HTTP %d %s" % (r.status_code, "成功" if ok else "失败"))
|
||||||
|
except Exception as e:
|
||||||
|
log("[通知] webhook 异常 %r" % e)
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
payload = {"token": token, "title": title, "content": content,
|
||||||
|
"template": os.environ.get("PUSH_PLUS_TEMPLATE", "txt")}
|
||||||
|
if topic:
|
||||||
|
payload["topic"] = topic
|
||||||
|
r = requests.post("https://www.pushplus.plus/send", json=payload, timeout=15)
|
||||||
|
j = r.json() if "json" in r.headers.get("content-type", "") else {}
|
||||||
|
ok = j.get("code") == 200
|
||||||
|
sent = sent or ok
|
||||||
|
log("[通知] pushplus code=%s %s" % (j.get("code"), "成功" if ok else (j.get("msg") or "")))
|
||||||
|
except Exception as e:
|
||||||
|
log("[通知] pushplus 异常 %r" % e)
|
||||||
|
if not hook and not token:
|
||||||
|
log("[通知] 未配置 WEBHOOK_URL / PUSH_PLUS_TOKEN,本轮不发推送")
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- cURL 解析
|
||||||
|
|
||||||
|
def parse_curl(raw):
|
||||||
|
"""整段 cURL → {url, headers, body}。续行/CRLF/shlex/剥头/解析后自检。"""
|
||||||
|
text = raw.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
merged, buf = [], ""
|
||||||
|
for ln in text.split("\n"):
|
||||||
|
s = ln.rstrip()
|
||||||
|
if s.endswith("\\"):
|
||||||
|
buf += s[:-1] + " "
|
||||||
|
else:
|
||||||
|
merged.append(buf + s)
|
||||||
|
buf = ""
|
||||||
|
if buf.strip():
|
||||||
|
merged.append(buf)
|
||||||
|
cmdline = " ".join(m.strip() for m in merged if m.strip()).replace("^", " ")
|
||||||
|
|
||||||
|
tokens = shlex.split(cmdline, posix=True)
|
||||||
|
url, headers, body = None, {}, None
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
t = tokens[i]
|
||||||
|
tl = t.lower()
|
||||||
|
if tl == "curl":
|
||||||
|
i += 1
|
||||||
|
elif t in ("-H", "--header") and i + 1 < len(tokens):
|
||||||
|
hv = tokens[i + 1]
|
||||||
|
if ":" in hv:
|
||||||
|
k, v = hv.split(":", 1)
|
||||||
|
headers[k.strip()] = v.strip() # 同名头取最后
|
||||||
|
i += 2
|
||||||
|
elif t in ("-d", "--data", "--data-raw", "--data-binary") and i + 1 < len(tokens):
|
||||||
|
body = tokens[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif t.startswith("http://") or t.startswith("https://"):
|
||||||
|
url = t
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not url or not body:
|
||||||
|
raise ValueError("cURL 里没解析到 URL 或 body")
|
||||||
|
headers = {k: v for k, v in headers.items() if k.lower() not in STRIP_HEADERS}
|
||||||
|
problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效
|
||||||
|
if "autoLogin" not in url:
|
||||||
|
problems.append("URL 不含 autoLogin")
|
||||||
|
if not headers.get("xs"):
|
||||||
|
problems.append("缺 xs 头")
|
||||||
|
if not headers.get("cookie"):
|
||||||
|
problems.append("缺 cookie 头")
|
||||||
|
if not (1300 <= len(body) <= 1700):
|
||||||
|
problems.append("body 长度 %d 不在 1472/1516±10%% 内" % len(body))
|
||||||
|
if problems:
|
||||||
|
raise ValueError("cURL 自检失败: " + "; ".join(problems))
|
||||||
|
return {"url": url, "headers": headers, "body": body}
|
||||||
|
|
||||||
|
|
||||||
|
def load_seeds():
|
||||||
|
"""多号:编号变量主方案;单变量按空行分片兜底(逐片校验)。"""
|
||||||
|
seeds = []
|
||||||
|
for suffix in [""] + ["_%d" % i for i in range(2, 21)]:
|
||||||
|
v = os.environ.get("CMCC_AUTOLOGIN_CURL" + suffix, "").strip()
|
||||||
|
if v:
|
||||||
|
try:
|
||||||
|
seeds.append(("CMCC_AUTOLOGIN_CURL" + suffix, parse_curl(v)))
|
||||||
|
except ValueError as e:
|
||||||
|
log("!! %s 解析失败: %s" % (suffix or "(主)", e))
|
||||||
|
if not seeds:
|
||||||
|
blob = os.environ.get("CMCC_AUTOLOGIN_CURL", "")
|
||||||
|
for idx, part in enumerate(p.strip() for p in re.split(r"\n\s*\n", blob) if p.strip()):
|
||||||
|
try:
|
||||||
|
seeds.append(("CMCC_AUTOLOGIN_CURL#%d" % (idx + 1), parse_curl(part)))
|
||||||
|
except ValueError as e:
|
||||||
|
log("!! 分片 %d 解析失败: %s" % (idx + 1, e))
|
||||||
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- [A] 重放
|
||||||
|
|
||||||
|
def set_cookie_names(resp):
|
||||||
|
"""Set-Cookie 全量名字。服务端把 JSESSIONID+UID+ticketID 合并在同一条头里,
|
||||||
|
必须 findall 全量提(re.match 取首个恒漏 UID → 命中判定永远失败)。"""
|
||||||
|
try:
|
||||||
|
raw = ",".join(resp.raw.headers.getlist("Set-Cookie"))
|
||||||
|
except Exception:
|
||||||
|
raw = resp.headers.get("set-cookie", "") or ""
|
||||||
|
return re.findall(r"([A-Za-z0-9_\-]+)\s*=", raw)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(resp):
|
||||||
|
"""五形态:492命中 / 64会话不存在 / 88业务未中 / 236账号层(慢节拍通道) / 403/150网关拒。"""
|
||||||
|
ct = (resp.headers.get("content-type", "") or "").lower()
|
||||||
|
n = len(resp.content)
|
||||||
|
names = set_cookie_names(resp)
|
||||||
|
if resp.status_code == 403 or "text/html" in ct:
|
||||||
|
return "403_gateway"
|
||||||
|
if "JSESSIONID" in names and "UID" in names and "text/json" in ct and 400 <= n <= 550:
|
||||||
|
return "492_hit"
|
||||||
|
if n == 64:
|
||||||
|
return "64_no_session"
|
||||||
|
if n == 88:
|
||||||
|
return "88_business_miss"
|
||||||
|
if n == 236:
|
||||||
|
return "236_account_layer"
|
||||||
|
if n == 150:
|
||||||
|
return "150_gateway_reject"
|
||||||
|
return "other_%dB" % n
|
||||||
|
|
||||||
|
|
||||||
|
def replay_auto_login(seed, stats):
|
||||||
|
"""重放至 492 命中,命中即停。无状态 requests.post ——
|
||||||
|
★ Session 的 prepare_cookies 会用 jar 无条件覆盖手工 Cookie 头,素材 cookie 一被污染就 64B。"""
|
||||||
|
start = time.time()
|
||||||
|
tried = 0
|
||||||
|
backed = False
|
||||||
|
while tried < REPLAY_MAX and time.time() - start < WALL_LIMIT:
|
||||||
|
tried += 1
|
||||||
|
try:
|
||||||
|
r = requests.post(seed["url"], data=seed["body"].encode("utf-8"),
|
||||||
|
headers=seed["headers"], timeout=30)
|
||||||
|
except Exception as e:
|
||||||
|
stats["net_error"] = stats.get("net_error", 0) + 1
|
||||||
|
log("[A] #%d 网络错误 %r" % (tried, e))
|
||||||
|
time.sleep(REPLAY_GAP)
|
||||||
|
continue
|
||||||
|
form = classify(r)
|
||||||
|
stats[form] = stats.get(form, 0) + 1
|
||||||
|
if form == "492_hit":
|
||||||
|
try:
|
||||||
|
raw_list = r.raw.headers.getlist("Set-Cookie")
|
||||||
|
except Exception:
|
||||||
|
raw_list = [r.headers.get("set-cookie", "")]
|
||||||
|
blob = "; ".join(x.strip() for x in raw_list if x.strip())
|
||||||
|
sess = blob.split("; Secure")[0].strip() # ★ 原始全值(含 Comment/Path 碎片)
|
||||||
|
log("[A] 第 %d 发命中(统计=%s)" % (tried, stats))
|
||||||
|
return sess, tried
|
||||||
|
if tried % 5 == 0 or form.startswith(("403", "150")):
|
||||||
|
log("[A] 第 %d 发 %s" % (tried, form))
|
||||||
|
if tried >= BACKOFF_AFTER and not backed:
|
||||||
|
log("[A] 连续 %d 败 → 静默 %ds 防风暴冷却" % (tried, BACKOFF_SECS))
|
||||||
|
time.sleep(BACKOFF_SECS)
|
||||||
|
backed = True
|
||||||
|
else:
|
||||||
|
time.sleep(REPLAY_GAP)
|
||||||
|
return None, tried
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- [B]~[D] 换票
|
||||||
|
|
||||||
|
def mint_token(sess, stats):
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"user-agent": UA_WEB,
|
||||||
|
"x-requested-with": "com.greenpoint.android.mc10086.activity"})
|
||||||
|
for _ in range(3): # [C] 失败先重建 sid 快重试,不盲目整轮重放
|
||||||
|
try:
|
||||||
|
r = s.get(ACT_URL, timeout=30, allow_redirects=True)
|
||||||
|
m = re.search(r"sid=([A-Za-z0-9]+)", r.text)
|
||||||
|
except Exception as e:
|
||||||
|
log("[B] 活动页请求异常 %r" % e)
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
if not m:
|
||||||
|
log("[B] 没取到 sid")
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
sid = m.group(1)
|
||||||
|
try:
|
||||||
|
r2 = s.post("%s/appTokenLogin?sid=%s" % (SSO, sid), json={"token": sess},
|
||||||
|
timeout=30,
|
||||||
|
headers={"content-type": "application/json;charset=UTF-8",
|
||||||
|
"referer": r.url, "origin": "https://wx.10086.cn"})
|
||||||
|
j = r2.json()
|
||||||
|
except Exception as e:
|
||||||
|
log("[C] 异常 %r" % e)
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
url = (j.get("data") or {}).get("url", "")
|
||||||
|
log("[C] %s %s" % (j.get("code"), j.get("msg", "")))
|
||||||
|
if "404" in url or not url: # ★ 判据是 url 含 404,code 仍是 SUCCESS
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
s.get(url, timeout=30, allow_redirects=True)
|
||||||
|
except Exception as e:
|
||||||
|
log("[D] 异常 %r" % e)
|
||||||
|
continue
|
||||||
|
tok = next((c.value for c in s.cookies if c.name == "QWHD_SESSION_TOKEN"), None)
|
||||||
|
if tok:
|
||||||
|
log("[D] token=%s…%s" % (tok[:6], tok[-4:]))
|
||||||
|
return tok
|
||||||
|
stats["mint_fail"] = stats.get("mint_fail", 0) + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- [E] 签到
|
||||||
|
|
||||||
|
class Mark:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers.update({
|
||||||
|
"user-agent": UA_WEB,
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
"content-type": "application/json;charset=UTF-8",
|
||||||
|
"origin": "https://wx.10086.cn",
|
||||||
|
"referer": ACT_URL,
|
||||||
|
})
|
||||||
|
self.s.cookies.set("QWHD_SESSION_TOKEN", token, domain="wx.10086.cn")
|
||||||
|
|
||||||
|
def post(self, path, body=None):
|
||||||
|
time.sleep(THROTTLE)
|
||||||
|
try:
|
||||||
|
r = self.s.post(BASE + path, data=json.dumps(body or {}), timeout=30)
|
||||||
|
except Exception as e:
|
||||||
|
return {"_error": repr(e)}
|
||||||
|
if "json" not in r.headers.get("content-type", "").lower():
|
||||||
|
# ★ token 失效判据:返回 13KB SSO HTML 而非 JSON(HTTP 仍 200)
|
||||||
|
return {"_token_invalid": True, "_len": len(r.content)}
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
return {"_raw": r.text[:200]}
|
||||||
|
|
||||||
|
def whoami(self):
|
||||||
|
j = self.post("/user/info", {"appVersion": "", "miniVersion": ""})
|
||||||
|
if j.get("_token_invalid"):
|
||||||
|
return None, None
|
||||||
|
d = j.get("data") or {}
|
||||||
|
return j.get("code"), d.get("nickName") or ""
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
j = self.post("/mark31/markstatus")
|
||||||
|
if j.get("_token_invalid"):
|
||||||
|
return None
|
||||||
|
d = j.get("data") or {}
|
||||||
|
today = today_str()
|
||||||
|
e = next((x for x in (d.get("markstatus") or []) if x.get("date") == today), None)
|
||||||
|
return {"signed": bool(e and e.get("status") == "1"),
|
||||||
|
"days": (d.get("userinfo") or {}).get("accumulateTimes")}
|
||||||
|
|
||||||
|
def mark(self):
|
||||||
|
return self.post("/mark31/domark", {"date": today_str()})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 单号流程
|
||||||
|
|
||||||
|
def run_one_account(name, payload, stats):
|
||||||
|
"""payload: str=CMCC_TOKEN 直通;dict=素材。返回 (exit_code, 摘要)。"""
|
||||||
|
acct = {"name": name, "mask": "?", "ok": False, "detail": ""}
|
||||||
|
if isinstance(payload, str):
|
||||||
|
tok = payload
|
||||||
|
log("[%s] CMCC_TOKEN 直通(仅当天调试)" % name)
|
||||||
|
else:
|
||||||
|
sess, tried = replay_auto_login(payload, stats)
|
||||||
|
if not sess:
|
||||||
|
acct["detail"] = ("autoLogin %d 发未命中(%s)。诊断:64B多→素材/会话死,重抓;"
|
||||||
|
"88B多→素材活着未中,1小时后重跑;236B→通道异常,快节拍重试;"
|
||||||
|
"403/150→cURL 解析损坏" % (tried, stats))
|
||||||
|
return 2, acct
|
||||||
|
tok = mint_token(sess, stats)
|
||||||
|
if not tok:
|
||||||
|
acct["detail"] = "换票失败([C] 3 次重建 sid 未成)"
|
||||||
|
return 3, acct
|
||||||
|
|
||||||
|
m = Mark(tok)
|
||||||
|
code, nick = m.whoami()
|
||||||
|
if code is None:
|
||||||
|
acct["detail"] = "token 失效(HTML 非 JSON)"
|
||||||
|
return 3, acct
|
||||||
|
if code != "SUCCESS":
|
||||||
|
acct["detail"] = "验活返回 %s" % code
|
||||||
|
return 3, acct
|
||||||
|
acct["mask"] = MASK(nick)
|
||||||
|
log("[%s] 用户 %s" % (name, acct["mask"]))
|
||||||
|
|
||||||
|
st = m.state()
|
||||||
|
if st is None:
|
||||||
|
acct["detail"] = "markstatus 时 token 已失效"
|
||||||
|
return 3, acct
|
||||||
|
log("[%s] 今日已签=%s 累计=%s天" % (name, st["signed"], st["days"]))
|
||||||
|
if st["signed"]:
|
||||||
|
acct["ok"] = True
|
||||||
|
acct["detail"] = "今日已签到,幂等跳过(累计 %s 天)" % st["days"]
|
||||||
|
return 0, acct
|
||||||
|
|
||||||
|
mk = m.mark()
|
||||||
|
if mk.get("_token_invalid"):
|
||||||
|
acct["detail"] = "domark 时 token 失效"
|
||||||
|
return 3, acct
|
||||||
|
log("[%s] 签到 code=%s status=%s msg=%s" % (name, mk.get("code"), mk.get("status"), mk.get("msg")))
|
||||||
|
|
||||||
|
st2 = m.state()
|
||||||
|
if st2 and st2["signed"]:
|
||||||
|
acct["ok"] = True
|
||||||
|
acct["detail"] = "签到成功:累计 %s→%s 天(%s/%s)" % (
|
||||||
|
st["days"], st2["days"], mk.get("code"), mk.get("status"))
|
||||||
|
return 0, acct
|
||||||
|
acct["detail"] = "复核未通过:%s/%s" % (mk.get("code"), mk.get("status"))
|
||||||
|
return 1, acct
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 主流程
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if _HAVE_FCNTL:
|
||||||
|
try:
|
||||||
|
lf = open("/tmp/cmcc_mark.lock", "w")
|
||||||
|
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except Exception:
|
||||||
|
log("已有实例在跑(flock 冲突),退出防双进程重放")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
results = []
|
||||||
|
print("【中国移动签到】", flush=True)
|
||||||
|
|
||||||
|
accounts = []
|
||||||
|
tok = os.environ.get("CMCC_TOKEN", "").strip()
|
||||||
|
if tok:
|
||||||
|
accounts.append(("token-direct", tok))
|
||||||
|
accounts.extend(("acct%d" % (i + 1), seed_dict)
|
||||||
|
for i, (_label, seed_dict) in enumerate(load_seeds()))
|
||||||
|
|
||||||
|
if not accounts:
|
||||||
|
print("未配置 CMCC_AUTOLOGIN_CURL。抓包导出 autoLogin 为 cURL 贴进环境变量(见文件头说明)。", flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
for idx, (name, payload) in enumerate(accounts):
|
||||||
|
log("=== %s 开始 ===" % name)
|
||||||
|
rc, acct = run_one_account(name, payload, stats)
|
||||||
|
results.append((rc, acct))
|
||||||
|
log("=== %s 结束 rc=%d ===" % (name, rc))
|
||||||
|
if idx < len(accounts) - 1:
|
||||||
|
log("多号间静默 60s(防累计重放触发风暴冷却)")
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
print("\n========== 汇总 ==========")
|
||||||
|
lines = []
|
||||||
|
for rc, a in results:
|
||||||
|
mark = "✅" if rc == 0 else "❌"
|
||||||
|
lines.append("%s %s(%s): %s" % (mark, a["name"], a["mask"], a["detail"]))
|
||||||
|
print(lines[-1])
|
||||||
|
print("形态统计: %s" % stats, flush=True)
|
||||||
|
|
||||||
|
if any(rc != 0 for rc, _ in results):
|
||||||
|
send_notify("中国移动签到失败 %d/%d" % (sum(1 for rc, _ in results if rc != 0), len(results)),
|
||||||
|
"\n".join(lines))
|
||||||
|
elif os.environ.get("CMCC_NOTIFY_ON_SUCCESS", "").strip() == "1":
|
||||||
|
send_notify("中国移动签到成功 %d/%d" % (len(results), len(results)), "\n".join(lines))
|
||||||
|
|
||||||
|
return max(rc for rc, _ in results) if results else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user