From 27e3c95585f5c6889993eaa00404e04f16a15bdd Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Sat, 7 Nov 2026 16:48:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(cmcc):=20=E4=B8=AD=E5=9B=BD=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AD=BE=E5=88=B0=E6=9C=89=E7=A4=BC=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=EF=BC=88Python3=20=E9=87=8D=E6=94=BE?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=93=BE=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议链路 2026-11-07 全实验裁决后投产: - 重放 autoLogin 素材换会话(命中即停 + 15 败长静默防风暴冷却) - appTokenLogin 换 QWHD_SESSION_TOKEN(token=原始 Set-Cookie 全值) - mark31/domark 签到 + 幂等(markstatus 先行) - 出口 IP 无关/素材无小时级寿命 已实测证伪,云青龙可直接部署 - 通知走 webhook+pushplus 双通道(与 common.js 契约同名的 Python 实现) - 素材获取:保持登录态等自动续期抓一次即可,无每日仪式 --- cmcc/README.md | 60 +++++ cmcc/scrip_cmcc_mark.py | 486 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 cmcc/README.md create mode 100644 cmcc/scrip_cmcc_mark.py diff --git a/cmcc/README.md b/cmcc/README.md new file mode 100644 index 0000000..02f8a1f --- /dev/null +++ b/cmcc/README.md @@ -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) diff --git a/cmcc/scrip_cmcc_mark.py b/cmcc/scrip_cmcc_mark.py new file mode 100644 index 0000000..45d41f1 --- /dev/null +++ b/cmcc/scrip_cmcc_mark.py @@ -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())