#!/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 import urllib3 # 静音 InsecureRequestWarning([A] 专用 legacy-SSL 池本就 CERT_NONE,刷屏无信息量) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ---------------------------------------------------------------- SSL 兼容 # 中国移动 client.app.coc.10086.cn 用 legacy TLS renegotiation(OpenSSL 3.x 默认拒: # UNSAFE_LEGACY_RENEGOTIATION_DISABLED,青龙容器 Python3.11 实测命中)。 # 自定义 ssl_context 放行 legacy 握手,全部请求统一走它。 def _make_ssl_context(): try: import ssl ctx = ssl.create_default_context() ctx.options |= 0x4 # SSL_OP_LEGACY_SERVER_CONNECT(OpenSSL 3.x 需显式放行) ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx except Exception: return None _SSL_CTX = _make_ssl_context() if _SSL_CTX is not None: try: _POOL = urllib3.HTTPSConnectionPool( 'client.app.coc.10086.cn', 443, ssl_context=_SSL_CTX, maxsize=4) except Exception: _POOL = None else: _POOL = None def post_seed(url, body_bytes, headers, timeout=30): """[A] 重放的统一出口。优先走预建 legacy-SSL 连接池;异常回落标准 requests。 无状态(不用 Session)——素材 Cookie 必须原样直发,jar 会污染。""" if _POOL is not None: try: r = _POOL.urlopen('POST', url, body=body_bytes, headers=headers, timeout=timeout) # urllib3 响应包一层成 requests 风格(classify 只用 headers/content/status_code/raw) return _U3Response(r) except Exception: pass return requests.post(url, data=body_bytes, headers=headers, timeout=timeout, verify=False) class _U3Response: """urllib3 HTTPResponse → requests 风格最小适配(headers/content/status_code/raw)。""" def __init__(self, u3): self.status_code = u3.status self.content = u3.data self._headers = u3.headers self._u3 = u3 @property def headers(self): # urllib3 HttpHeaders 大小写不敏感 get,直接透传 return self._headers @property def raw(self): return self._u3 # ---------------------------------------------------------------- 常量 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 = post_seed(seed["url"], seed["body"].encode("utf-8"), 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())