Files
ql-scripts/cmcc/scrip_cmcc_mark.py
zhuhjay 57a49ab7f5 fix(cmcc): parse_curl 头键大小写不敏感(Reqable 等导出 Cookie 大写 C)
号2素材实测踩到:mitm/Reqable 导出的请求头是 'Cookie'(大写),
parse_curl 的 headers.get('cookie') 大小写敏感 → 自检误报缺 cookie 头。
改用小写化视图做自检判定,原始头键仍原样保留(服务端/request 都大小写不敏感)。
2026-11-07 18:50:04 +08:00

549 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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}
# 大小写不敏感视图(Reqable 等导出的头键大小写不定:Cookie/COOKIE)
lower = {k.lower(): v for k, v in headers.items()}
problems = [] # 解析后自检:坏 cURL 在这里就报,别到线上当素材失效
if "autoLogin" not in url:
problems.append("URL 不含 autoLogin")
if not lower.get("xs"):
problems.append("缺 xs 头")
if not lower.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())