- 换会话冷却自愈:单轮 15 败静默 600s 后自动重开(试数归零),最多 3 轮 (覆盖 ~36 分钟,穿出冷却期;实测静默 ≥10min 恢复命中) 号2 19:57 的 88B×15 就是素材冷却期,旧逻辑静默一次就放弃 → 现在自愈 - 日志前缀 [A][B][C][D] → [①换会话][②开门][③换票][④拿钥匙](用户可读)
626 lines
26 KiB
Python
626 lines
26 KiB
Python
#!/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 发
|
||
REPLAY_GAP = 5.0 # 3~5s 快节拍(慢节拍推向 236B 通道)
|
||
WALL_LIMIT = 6 * 60 # 单轮 [A] 墙钟上限(含长静默)
|
||
BACKOFF_AFTER = 15 # 连续 15 败 → 静默(防风暴冷却)
|
||
BACKOFF_SECS = 600
|
||
COOLDOWN_ROUNDS = 3 # ★ 冷却自愈:整轮未命中时,静默后重开一轮,最多 3 轮
|
||
# (素材在冷却期时 88B×15 + 600s 静默 ≈ 12min/轮;
|
||
# 实测静默 ≥10 分钟恢复命中,3 轮覆盖 ~36 分钟,足够穿出冷却期)
|
||
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={} {}".format(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 命中,命中即停。无状态 post(Session 的 jar 会污染素材 Cookie)。
|
||
★ 冷却自愈:单轮 15 败 → 静默 600s → 重开一轮(试数归零),最多 COOLDOWN_ROUNDS 轮。
|
||
素材在冷却期(88B)时靠这个穿出去;3 轮全 88B 才认输(诊断文案会说「稍后重跑」。)"""
|
||
total_tried = 0
|
||
for round_no in range(1, COOLDOWN_ROUNDS + 1):
|
||
start = time.time()
|
||
tried = 0
|
||
backed = False
|
||
while tried < REPLAY_MAX and time.time() - start < WALL_LIMIT:
|
||
tried += 1
|
||
total_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("[换会话] 第%d轮 #%d 网络错误 %r" % (round_no, 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("[换会话] 第%d轮 第%d发命中(统计=%s)" % (round_no, tried, stats))
|
||
return sess, total_tried
|
||
if tried % 5 == 0 or form.startswith(("403", "150")):
|
||
log("[换会话] 第%d轮 第%d发 %s" % (round_no, tried, form))
|
||
if tried >= BACKOFF_AFTER and not backed:
|
||
backed = True
|
||
if round_no < COOLDOWN_ROUNDS:
|
||
log("[换会话] 连续 %d 败 → 疑似冷却期,静默 %ds 后自动重开一轮" % (tried, BACKOFF_SECS))
|
||
time.sleep(BACKOFF_SECS)
|
||
break # break 出 while → 下一轮(tried 归零)
|
||
else:
|
||
log("[换会话] 末轮连续 %d 败,不再静默" % tried)
|
||
else:
|
||
time.sleep(REPLAY_GAP)
|
||
# while 正常跑满 REPLAY_MAX 也进入下一轮(若还有轮次)
|
||
if round_no < COOLDOWN_ROUNDS:
|
||
log("[换会话] 第%d轮 %d 发未命中,重开新一轮" % (round_no, tried))
|
||
return None, total_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("[②开门] 活动页请求异常 %r" % e)
|
||
time.sleep(2)
|
||
continue
|
||
if not m:
|
||
log("[②开门] 没取到 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("[③换票] 异常 %r" % e)
|
||
time.sleep(2)
|
||
continue
|
||
url = (j.get("data") or {}).get("url", "")
|
||
log("[③换票] %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("[④拿钥匙] 异常 %r" % e)
|
||
continue
|
||
tok = next((c.value for c in s.cookies if c.name == "QWHD_SESSION_TOKEN"), None)
|
||
if tok:
|
||
log("[④拿钥匙] 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()})
|
||
|
||
# ---------------- 签到奖励领取 + 月签转盘(2026-11-07 端点表全量逆向) ----------------
|
||
|
||
def claim_day_prizes(self):
|
||
"""领取「签到天数奖励」:mark31 的累计签到奖励(2/3/5/7/10/14/21/30天档)。
|
||
协议:GET taskAwardChance 查可领的 prizeId 列表 → 逐个 POST /mark31/taskAward/<id>。
|
||
(11-05 实测 21:29 真机领过「1签2GB」就是这个接口。)"""
|
||
out = []
|
||
r = self.post("/mark31/taskAwardChance", {})
|
||
if r.get("_token_invalid") or r.get("code") != "SUCCESS":
|
||
return out
|
||
for item in (r.get("data") or []):
|
||
pid = item.get("prizeId") or item.get("id")
|
||
name = item.get("prizeName") or item.get("name") or ("prize {}".format(pid))
|
||
if not pid:
|
||
continue
|
||
time.sleep(THROTTLE)
|
||
rr = self.post("/mark31/taskAward/%s" % pid, {})
|
||
ok = rr.get("code") == "SUCCESS"
|
||
out.append((name, ok, rr.get("msg") or ""))
|
||
log(" [奖] {} → {}".format(name, "✅领取" if ok else (rr.get("msg") or rr.get("code"))))
|
||
return out
|
||
|
||
def month_lottery(self):
|
||
"""「连续月签大转盘」:POST /month/getMonthPrize {channel,phone,prov,month}。
|
||
无池/无机会时服务端返回 SUCCESS+空 data(实测 monthInfo data=null)——不报错,静默跳过。"""
|
||
res = {"config": None, "draw": None}
|
||
r = self.post("/month/monthInfo", {})
|
||
if r.get("_token_invalid") or not r.get("data"):
|
||
return res # 无转盘池(本月未开放)
|
||
res["config"] = r.get("data")
|
||
# 抽奖参数从 whoami 的数据拿不到 phone/prov(服务端掩码),从 monthInfo 里带啥用啥
|
||
d = r["data"] or {}
|
||
payload = {k: d.get(k) for k in ("channel", "phone", "prov", "month") if d.get(k)}
|
||
if not payload.get("month"):
|
||
payload["month"] = today_str()[:6]
|
||
time.sleep(THROTTLE)
|
||
rr = self.post("/month/getMonthPrize", payload)
|
||
res["draw"] = rr
|
||
return res
|
||
|
||
|
||
# ---------------------------------------------------------------- 单号流程
|
||
|
||
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"] = ("换会话 %d 发未命中(%s)。诊断:64B多→素材/会话死,重抓;"
|
||
"88B多→素材活着未中,1小时后重跑;236B→通道异常,快节拍重试;"
|
||
"403/150→cURL 解析损坏" % (tried, stats))
|
||
return 2, acct
|
||
tok = mint_token(sess, stats)
|
||
if not tok:
|
||
acct["detail"] = "换票失败(③换票 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"]))
|
||
|
||
extras = []
|
||
if not st["signed"]:
|
||
mk = m.mark()
|
||
if mk.get("_token_invalid"):
|
||
acct["detail"] = "④拿钥匙后签到时 token 失效"
|
||
return 3, acct
|
||
log("[{}] 签到 code={} status={} msg={}".format(name, mk.get("code"), mk.get("status"), mk.get("msg")))
|
||
st2 = m.state()
|
||
if not (st2 and st2["signed"]):
|
||
acct["detail"] = "复核未通过:%s/%s" % (mk.get("code"), mk.get("status"))
|
||
return 1, acct
|
||
extras.append("签到✅ %s→%s天" % (st["days"], st2["days"]))
|
||
days_now = st2["days"]
|
||
else:
|
||
extras.append("已签(%s天)" % st["days"])
|
||
days_now = st["days"]
|
||
|
||
# 签到天数奖励(累计档位奖:7天10元话费这类,服务端发了才能领)
|
||
try:
|
||
prizes = m.claim_day_prizes()
|
||
for pname, ok, msg in prizes:
|
||
extras.append("奖[%s]%s" % (pname, "✅" if ok else "×"))
|
||
except Exception as e:
|
||
log("[%s] 天数奖励领取异常 %r" % (name, e))
|
||
|
||
# 月签转盘(有池才抽,无池静默)
|
||
try:
|
||
lot = m.month_lottery()
|
||
if lot.get("draw"):
|
||
dr = lot["draw"]
|
||
prize = ((dr.get("data") or {}).get("prize") if isinstance(dr.get("data"), dict) else None)
|
||
if dr.get("code") == "SUCCESS":
|
||
extras.append("转盘✅%s" % ((prize or {}).get("name", "中奖") if prize else "已抽"))
|
||
else:
|
||
extras.append("转盘×%s" % (dr.get("msg") or ""))
|
||
except Exception as e:
|
||
log("[%s] 月签转盘异常 %r" % (name, e))
|
||
|
||
acct["ok"] = True
|
||
acct["detail"] = " ".join(extras) + "|累计 %s 天" % days_now
|
||
return 0, 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())
|