test(huya): 风控场景/代理/扫码闭环探针与逆向文档

- probe_huya_login_scene: 各 appId/UA 组合的风控策略探测
- probe_huya_proxy: 走代理源对比直连的风控策略
- probe_huya_qr_bind: 扫码登录闭环(getQrId->scanQrPicNotify->
  bindQrLoginUser->tryQrLogin), 实测出有效web会话; 含wupData
  结构地图与证书定位实验结论(见 docs/虎牙扫码登录闭环逆向.md)
This commit is contained in:
yml2213
2026-08-25 11:51:01 +08:00
parent 705c51bf48
commit 9aea9851bd
4 changed files with 632 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
# 虎牙扫码登录闭环逆向(2026-08-25)
数据来源:用户抓包 `~/Desktop/抓包/hy/8.24`(手机扫码登录-1 / -手机端2 / -pc端2.har
探针脚本:`scripts/probe_huya_qr_bind.py`
## 一、协议链(qrLgn JSON 协议,纯 HTTPS,无原生 WUP 套接字)
```
PC角色(appId=5002, sign=1ce3bf68..., version=2.6, context WB-xxx)
① POST /qrLgn/getQrId uri=70001 -> data.qrId
⑥ POST /qrLgn/tryQrLogin uri=70003 轮询 stage: 0->1->2
stage=2 时返回 uid + biztoken(344B) => 构造 cookie 即登录成功
手机角色(appId=5131, sign=0ba67962ab9e12387648efeae2750777, version=2.5, context H5-xxx)
② GET https://aq.huya.com/r/confirm.html?k=<qrId>&id=5002 (App WebView 内)
③ POST /qrLgn/scanQrPicNotify uri=70005 data={qrId,wupData,...} -> stage=1 + 登录方信息
④ POST /qrLgn/bindQrLoginUser uri=70007 data={qrId,wupData,...} -> stage=2
```
注:`-1.har` 里还有旧版协议 `udbrtt.huya.com/auth/qr/notify|verify`confirm 页 s/confirm.html),
新流程走 qrLgn 直连,二者 wupData 结构相同。
## 二、web 会话构造(已验证 rc=0)
```python
cookies = {
'udb_uid': str(uid), 'yyuid': str(uid),
'udb_passport': 'hy_xxx', 'username': 'hy_xxx',
'udb_version': '1.0', 'udb_biztoken': <tryQrLogin 返回的 344B>,
'udb_origin': '1', 'udb_status': '1',
}
# 验证: udbreg /web/v2/modifyPswByCheck 无cookie/垃圾token -> rc=2005 USER_NO_LOGIN
# 扫码会话最小cookie -> rc=0 ★(不需要 udb_cred/udb_deviceid/udb_other
```
## 三、wupData 结构地图(998Bbase64 后随请求提交)
| offset | 内容 | 服务端校验 |
|---|---|---|
| 15 | servant `huyaudbwebui` func `default` | - |
| 70-273 | meta JSON `{associationId:184549392,id:184549392,session,traceId,type:2,uid:0,...}` | ❌ session/traceId 乱改仍通过 |
| 274 | `"5008"` | - |
| 282-463 | blob1 181B(与 safedeviceid 同前缀 `PQwemAN9...`) | ❌ 整段替换金标准 safedeviceid 仍通过 |
| 473-506 | hdid `ed0db8334cadd236c00cadf7e11ab5a5`=金标准) | 未单独测 |
| 508-686 | 设备信息 13.4.22 / 1.0.801386 / 127.0.0.1 / xiaomi / M2102J2SC+指纹hash / android / 1080x2120 / device_id hash | 未单独测 |
| **701-962** | **尾部 blob 261B —— 登录证书** | ✅ 破坏→`rc=40020 CERT_BIZERR_CREATE_ERROR`;清空→`rc=100 SYS_ERROR`;缺失→`rc=101 PARAM_ERROR` |
## 四、关键实验结论
1. **身份完全由尾部证书 blob 决定**
- 无 cookie / 垃圾 cookie / 真·APP WUP token 当 `udb_biztoken` → 全部无视,
绑定结果始终 = 抓包手机上登录的账号(hy_300023887 uid=1199666914671)。
- 先用 WUP 把 hy_299961311 登到同一设备身份再扫码 → 绑定仍是 hy_300023887
(服务端不做"设备最新登录"映射)。
2. **整个 web 登录不需要密码**getQrId → bind(wupData) → tryQrLogin 三步即出会话。
3. **证书可复用、可并发**:同一 wupData 已复用 10+ 次(跨 ~1h),5 路并发 2s 全成功。
4. APP WUP 密码登录(tools/huya_wup_encoder.py)对任意账号可用,
其响应中的 token(346B) 与 web biztoken(344B) 格式同族(解码后均以 `01 00` 开头),
但**不能**直接互换使用。
## 五、剩余缺口:每账号一张证书
尾部 blob 是与"App 内已登录会话"绑定的证书,生成逻辑在 App 原生 SDK
confirm.html 会先调 `udbdf.huya.com/dckey/check`,请求体本身加密)。
可行路径:
- **A. 每账号一次种子采集**:手机登录某号 → 触发一次任意 udb H5 confirm 流程 →
mitmproxy 抓 `bindQrLoginUser` 的 wupData 存库 → 该账号永久全自动。
(批量成本 = 每号一次人工,之后零人工)
- **B. 逆向证书生成算法**dckey/check 协议 + 原生加密,周期长不确定。
- 待观察:证书有效期(当前样本 >1h 仍有效)。
## 六、复现命令
```bash
.venv/bin/python scripts/probe_huya_qr_bind.py <账号> <密码> \
--har "/Users/yml/Desktop/抓包/hy/8.24/手机扫码登录-手机端2.har"
# 成功记录: evidence/qr_login_success.json(含有效 biztoken,已 gitignore
```
+105
View File
@@ -0,0 +1,105 @@
"""探测虎牙密码登录在不同 appId/UA 组合下的风控策略。
只走到「触发风控」这一步,打印风控策略(strategy 值 + data URL 路径),
不真正提交滑块/短信/扫码验证,也不写库。
用法:
.venv/bin/python scripts/probe_huya_login_scene.py <账号> <明文密码> [--app-id 5002|5008] [--ua desktop|android]
会依次测试多组组合,对比风控返回的是:
qr_auth -> 扫码验证(无法自动化)
dx_auth -> 短信验证(无法自动化)
pt_auth / get3 / slider -> 滑块/点选(可自动化)
"""
from __future__ import annotations
import argparse
import sys
from core.huya.login import (
APP_ID,
APP_ID_MOBILE,
APP_UA_MOBILE,
DEFAULT_UA,
HuyaPasswordLogin,
)
def classify_strategy(payload: dict) -> str:
"""从登录响应里识别风控策略类型。"""
data = payload.get("data") or {}
strategies = data.get("strategys") or []
if not strategies:
return "unknown"
urls = [str(s.get("data") or "") for s in strategies]
joined = " ".join(urls).lower()
strategy_values = [str(s.get("strategy")) for s in strategies]
if "qr_auth" in joined or "64" in strategy_values:
return "qr_auth(扫码)"
if "dx_auth" in joined:
return "dx_auth(短信)"
if "pt_auth" in joined or "get3" in joined or "slider" in joined:
return "pt_auth(滑块)"
return f"其他(strategy={','.join(strategy_values)})"
def probe(username: str, password: str, app_id: str, ua: str | None = None) -> dict:
login = HuyaPasswordLogin(username=username, password=password, app_id=app_id, ua=ua)
ua_label = login.ua[:40]
print(f"\n=== app_id={app_id} | app_sign={login.app_sign[:8]} | ua={ua_label}... ===")
login.prepare_device()
login.init_udb_middle()
payload = login._password_login_once()
return_code = int(payload.get("returnCode") or 0)
print(f"returnCode={return_code}")
if return_code == 0:
print(" → 登录成功(无需验证)")
return payload
if return_code in (10030, 10039):
kind = classify_strategy(payload)
print(f" → 风控策略: {kind}")
data = payload.get("data") or {}
for s in data.get("strategys") or []:
print(f" strategy={s.get('strategy')} dataType={s.get('dataType')}")
url = str(s.get("data") or "")
print(f" data_url={url.split('?')[0]}")
return payload
print(f" message={payload.get('message')} desc={payload.get('description')}")
return payload
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("username")
parser.add_argument("password")
parser.add_argument("--app-id", default=None, help="只测单个 appId")
parser.add_argument("--ua", default=None, choices=["desktop", "android"], help="只测单种 UA")
args = parser.parse_args()
combos = []
app_ids = [args.app_id] if args.app_id else [APP_ID, APP_ID_MOBILE]
for app_id in app_ids:
if args.ua == "desktop":
combos.append((app_id, DEFAULT_UA))
elif args.ua == "android":
combos.append((app_id, APP_UA_MOBILE))
else:
# 默认每组都测;5002 也测 Android UA(验证设备画像自洽假设)
if app_id == APP_ID_MOBILE:
combos.append((app_id, None)) # None -> 自动选 Android UA
else:
combos.append((app_id, None)) # None -> 桌面 UA
combos.append((app_id, APP_UA_MOBILE)) # 强制 Android UA
for app_id, ua in combos:
try:
probe(args.username, args.password, app_id, ua)
except Exception as exc: # noqa: BLE001
print(f" !! 探测异常: {type(exc).__name__}: {exc}")
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
"""用代理池实测虎牙密码登录:验证换 IP 能否把风控从扫码降回滑块。
用法:
.venv/bin/python scripts/probe_huya_proxy.py <账号> <明文密码> --proxy-api <代理API URL>
代理 API 返回格式支持:
- JSON: {"code":0, "data":[{"IP":"x","Port":y}, ...]} 或 {"code":0, "data":[{"ip":"x","port":y}]}
- 纯文本: 多行 "ip:port""ip,port"
流程:
1. 直连(当前出口 IP)走一次登录,记录风控策略
2. 从代理 API 取代理,换 IP 走登录,记录每个 IP 的风控策略
3. 汇总对比
不会写库,不会提交验证码(只走到触发风控)。
"""
from __future__ import annotations
import argparse
import sys
from core.douyu.proxy_fetcher import ProxyFetcher
from core.huya.login import HuyaPasswordLogin
def classify(payload: dict) -> str:
data = payload.get("data") or {}
strategies = data.get("strategys") or []
joined = " ".join(str(s.get("data") or "") for s in strategies).lower()
strategy_values = [str(s.get("strategy")) for s in strategies]
if "qr_auth" in joined or "64" in strategy_values:
return "扫码(qr_auth)"
if "dx_auth" in joined:
return "短信(dx_auth)"
if "pt_auth" in joined or "get3" in joined:
return "滑块(pt_auth)"
return f"其他(strategy={','.join(strategy_values) or ''})"
def one_pass(username: str, password: str, proxies=None, label: str = "") -> dict:
login = HuyaPasswordLogin(username=username, password=password, proxies=proxies)
login.prepare_device()
login.init_udb_middle()
payload = login._password_login_once()
rc = int(payload.get("returnCode") or 0)
if rc == 0:
kind = "成功(无需验证)"
elif rc in (10030, 10039):
kind = classify(payload)
else:
kind = f"code={rc} message={payload.get('message')}"
print(f" [{label or '直连'}] returnCode={rc} -> {kind}")
return payload
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("username")
parser.add_argument("password")
parser.add_argument("--proxy-api", required=True, help="代理提取 API URL")
parser.add_argument("--max-proxies", type=int, default=3, help="最多测几个代理 IP")
args = parser.parse_args()
print(f"=== 账号 {args.username} ===")
print("1) 直连基线:")
one_pass(args.username, args.password, label="直连")
print(f"\n2) 代理池测试 (最多 {args.max_proxies} 个 IP):")
fetcher = ProxyFetcher(api_url=args.proxy_api)
for i in range(args.max_proxies):
proxy = fetcher.fetch_new_proxy()
if not proxy:
print(" 代理耗尽/获取失败,停止")
break
proxies = {"http": proxy, "https": proxy}
print(f" --- 代理 #{i + 1}: {proxy} ---")
try:
one_pass(args.username, args.password, proxies=proxies, label=f"IP#{i + 1}")
except Exception as exc: # noqa: BLE001
print(f" !! 异常: {type(exc).__name__}: {exc}")
return 0
if __name__ == "__main__":
sys.exit(main())
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""虎牙扫码登录闭环探针:APP 协议登录态 + 自演手机端确认。
流程(依据 2026-08-25 用户抓包 /Users/yml/Desktop/抓包/hy/8.24):
1. APP WUP 密码登录(tools/huya_wup_encoder.py 动态构造)
POST https://udblgn.huya.com/open/hy/passwordLogin -> uid + token
2. token 作为 Cookie udb_biztokenApp WebView 的登录态载体)
3. PC 角色:POST /qrLgn/getQrId (uri 70001, appId 5002) -> qrId
4. 手机角色:POST /qrLgn/scanQrPicNotify (uri 70005, appId 5131) -> stage 1
5. 手机角色:POST /qrLgn/bindQrLoginUser (uri 70007, appId 5131) -> stage 2
6. PC 角色:POST /qrLgn/tryQrLogin 轮询 (uri 70003) -> stage 2 + uid + biztoken
用法:
.venv/bin/python scripts/probe_huya_qr_bind.py <账号> <密码> [--har 抓包文件]
--har 提供 8.24 抓包时,会提取其中的 wupData 用于重放对比实验。
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import random
import re
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import quote, urlencode
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import requests
from huya_wup_encoder import build_password_login_wup, make_trace_id # noqa: E402
from core.huya.device_fingerprint import get_huya_sdid # noqa: E402
BASE = "https://udblgn.huya.com"
APP_SIGN_WEB = "1ce3bf682483d03f146f58232ec10635" # appId 5002 (web)
APP_SIGN_H5 = "0ba67962ab9e12387648efeae2750777" # appId 5131 (App WebView H5)
UA_PC = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
UA_PHONE = (
"Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) "
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.0.0 "
"Mobile Safari/537.36"
)
GOLDEN_DEVICE = {
"app_version": "13.4.22",
"sdk_version": "1.0.80138",
"vendor": "xiaomi",
"model": "M2102J2SC",
"os": "android",
"ip": "127.0.0.1",
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
"screen": "M2102J2SC,30,11",
"width": "1080",
"height": "2120",
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
}
GOLDEN_SAFEDEVICEID = (
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
)
GOLDEN_HDID = "ed0db8334cadd236c00cadf7e11ab5a5"
def log(msg: str) -> None:
print(msg, flush=True)
# ---------------------------------------------------------------------------
# 第一步:APP WUP 密码登录 -> token
# ---------------------------------------------------------------------------
def app_password_login(account: str, password: str) -> dict:
"""纯 Python 复刻 App 密码登录,返回 {uid,name,mask,token}。"""
uid_str = account[2:] if account.startswith("hy_") else account
sha1_pwd = hashlib.sha1(password.encode()).hexdigest()
session = random.randint(3_100_000, 3_400_000)
trace_id = make_trace_id()
user_action = json.dumps(
{
"curl": "登录页", "furl": "我的", "latitude": "-1.0", "longitude": "-1.0",
"ssid": "",
"user_action": [
{"id": "24", "time": str(int(time.time() * 1000) - 500),
"x": str(random.randint(50, 300)), "y": str(random.randint(800, 1100))},
{"id": "11", "time": str(int(time.time() * 1000)),
"x": str(random.randint(50, 300)), "y": str(random.randint(800, 1100))},
],
},
ensure_ascii=False,
)
body = build_password_login_wup(
uid_str=uid_str,
sha1_password=sha1_pwd,
safedeviceid=GOLDEN_SAFEDEVICEID,
hdid=GOLDEN_HDID,
session=session,
trace_id=trace_id,
user_action_json=user_action,
device_info=GOLDEN_DEVICE,
)
s = requests.Session()
s.trust_env = False
r = s.post(
f"{BASE}/open/hy/passwordLogin",
data=body,
headers={
"Content-Type": "application/x-wup",
"User-Agent": UA_PHONE,
"Host": "udblgn.huya.com",
},
timeout=20,
)
log(f"[app-login] HTTP {r.status_code}, body {len(r.content)}B")
if r.status_code != 200 or len(r.content) < 64:
raise RuntimeError(f"WUP 登录失败: {r.status_code} {r.content[:120]!r}")
# 响应为 TAF 二进制,token 是其中最长的 base64url 风格字符串
m = max(re.finditer(rb"[A-Za-z0-9_\-]{300,}", r.content),
key=lambda x: len(x.group()), default=None)
if m is None:
raise RuntimeError(f"响应中未找到 token: {r.content[:160]!r}")
token = m.group().decode()
# uid: 紧跟 name 字符串 hy_xxx 附近;直接找 hy_<uid>
mn = re.search(rb"hy_(\d{6,})", r.content)
mask_m = re.search(rb"00\*+\d{4}", r.content)
return {
"uid_str": uid_str,
"name": mn.group(0).decode() if mn else account,
"uid": int(mn.group(1)) if mn else None,
"mask": mask_m.group(0).decode() if mask_m else "?",
"token": token,
}
# ---------------------------------------------------------------------------
# token -> udb_biztoken 候选变体
# ---------------------------------------------------------------------------
def biztoken_variants(token: str) -> dict[str, str]:
v = {"as-is": token}
v["strip2"] = token[2:]
try:
pad = lambda s: s + "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode(pad(token.replace("-", "_").replace("+", "/") ))
pass
except Exception:
pass
try:
std = token.replace("-", "+").replace("_", "/")
raw = base64.b64decode(std + "=" * (-len(std) % 4))
v["b64-strip2"] = base64.urlsafe_b64encode(raw[2:]).decode().rstrip("=")
except Exception:
pass
return v
# ---------------------------------------------------------------------------
# qrLgn JSON 协议两个角色
# ---------------------------------------------------------------------------
class QrRole:
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。pc=True 走 web(5002),否则 H5(5131)。"""
def __init__(self, pc: bool, sdid: str):
self.pc = pc
self.sdid = sdid
self.s = requests.Session()
self.s.trust_env = False
ctx_hex = uuid.uuid4().hex
tail = ("CBC9F93DBEB000011DB11EC02A401BAD-" if pc
else uuid.uuid4().hex.upper())
tail = tail if tail.endswith("-") else tail + "-"
prefix = "WB" if pc else "H5"
self.context = f"{prefix}-{ctx_hex}-{tail}"
self.page_id = random.randint(40_000_000, 41_000_000)
self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update({
"User-Agent": UA_PC if pc else UA_PHONE,
"Origin": BASE,
"content-type": "application/json;charset=UTF-8",
"Accept": "*/*",
})
def _headers(self, uri: str) -> dict:
mid = "2.6" if self.pc else "2.5"
return {
"context": self.context,
"uri": uri,
"reqid": str(self.req_counter),
"lcid": "2052",
"Referer": f"{BASE}/web/middle/{mid}/{self.page_id}/https/"
f"{self.context.split('-')[1]}",
}
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict:
envelope = {
"uri": uri,
"version": "2.6" if self.pc else "2.5",
"context": self.context,
"appId": "5002" if self.pc else "5131",
"appSign": APP_SIGN_WEB if self.pc else APP_SIGN_H5,
"authId": "",
"sdid": self.sdid,
"lcid": "2052",
"byPass": "3",
"requestId": str(self.req_counter),
"data": data,
}
self.req_counter += random.randint(120, 400)
r = self.s.post(f"{BASE}{path}", json=envelope,
headers=self._headers(uri), cookies=cookies, timeout=20)
try:
return r.json()
except Exception:
return {"returnCode": -1, "httpStatus": r.status_code,
"description": r.text[:200]}
def web_behavior() -> tuple[str, str]:
b = urlencode({"furl": "https://www.huya.com/g",
"curl": "https://www.wuya.com/g".replace("wuya", "huya"),
"user_action": []}, quote_via=quote)
p = quote("https://www.huya.com/g", safe="")
return b, p
def extract_replay_wupdata(har_path: str | None) -> str | None:
if not har_path:
return None
with open(har_path) as fp:
d = json.load(fp)
for e in d.get("log", {}).get("entries", []):
req = e.get("request", {})
u = req.get("url", "")
if "bindQrLoginUser" in u:
body = json.loads(req.get("postData", {}).get("text", "{}"))
wd = body.get("data", {}).get("wupData", "")
if wd:
return wd
return None
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("account")
ap.add_argument("password")
ap.add_argument("--har", default=None, help="8.24 抓包 har,用于提取重放 wupData")
ap.add_argument("--biz", default="auto",
choices=["auto", "as-is", "strip2", "b64-strip2"],
help="token->udb_biztoken 转换方式")
args = ap.parse_args()
log("== 步骤1: APP WUP 密码登录 ==")
info = app_password_login(args.account, args.password)
log(f" uid={info['uid']} name={info['name']} 掩码={info['mask']}")
log(f" token 长度={len(info['token'])} 前24={info['token'][:24]}")
variants = biztoken_variants(info["token"])
if args.biz != "auto":
variants = {args.biz: variants[args.biz]}
replay_wup = extract_replay_wupdata(args.har)
log(f"== 重放 wupData {'可用(' + str(len(replay_wup)) + 'B)' if replay_wup else '未提供'} ==\n")
pc_sdid_r = get_huya_sdid(allow_fallback=False)
ph_sdid_r = get_huya_sdid(allow_fallback=False)
pc = QrRole(pc=True, sdid=pc_sdid_r.sdid)
ph = QrRole(pc=False, sdid=ph_sdid_r.sdid)
beh, page = web_behavior()
confirm_page = quote(
f"https://aq.huya.com/r/confirm.html?k={{QRID}}&id=5002", safe="")
for bname, biz in variants.items():
log(f"\n======== 变体 [{bname}] biztoken 前20={biz[:20]} ========")
cookies = {"udb_biztoken": biz}
# --- PC: getQrId ---
resp = pc.call("/qrLgn/getQrId", "70001", {
"behavior": beh, "type": "", "domainList": "", "page": page})
rc = resp.get("returnCode")
qrid = ((resp.get("data") or {}).get("qrId")) if rc == 0 else None
log(f"[pc getQrId] rc={rc} qrId={qrid} desc={resp.get('description')}")
if not qrid:
continue
# --- 手机角色两种 wupData 各试一次 ---
wup_candidates = {"empty": ""} if not replay_wup else \
{"empty": "", "replay": replay_wup}
bound = False
for wname, wup in wup_candidates.items():
cpage = confirm_page.replace("{QRID}", qrid)
r1 = ph.call("/qrLgn/scanQrPicNotify", "70005", {
"qrId": qrid, "wupData": wup,
"behavior": quote(f'[{{"codSDK.{int(time.time()*1000)}}}":"28800.002"}}]'
.replace("}}]", "}]"), safe=""),
"page": cpage}, cookies=cookies)
st1 = (r1.get("data") or {}).get("stage")
log(f"[ph scanQr({wname})] rc={r1.get('returnCode')} stage={st1} "
f"msg={r1.get('message')} desc={r1.get('description')} "
f"data={json.dumps(r1.get('data'), ensure_ascii=False)[:160]}")
if r1.get("returnCode") != 0:
continue
r2 = ph.call("/qrLgn/bindQrLoginUser", "70007", {
"qrId": qrid, "wupData": wup,
"behavior": quote("[]", safe=""),
"page": cpage}, cookies=cookies)
st2 = (r2.get("data") or {}).get("stage")
log(f"[ph bindQr({wname})] rc={r2.get('returnCode')} stage={st2} "
f"msg={r2.get('message')} desc={r2.get('description')}")
if r2.get("returnCode") != 0 or st2 != 2:
continue
# --- PC 轮询 tryQrLogin ---
for i in range(12):
rt = pc.call("/qrLgn/tryQrLogin", "70003", {
"qrId": qrid, "remember": "1", "domainList": "",
"behavior": beh, "page": page})
dt = rt.get("data") or {}
stage = dt.get("stage")
if i % 3 == 0 or stage not in (0, 1):
log(f"[pc try#{i}] rc={rt.get('returnCode')} stage={stage} "
f"uid={dt.get('uid')} biz={'' if dt.get('biztoken') else ''}")
if stage == 2 and dt.get("biztoken"):
log("\n★★★ 扫码登录闭环成功! ★★★")
log(f" uid={dt.get('uid')}")
log(f" biztoken({len(dt['biztoken'])})={dt['biztoken'][:40]}...")
out = Path("evidence/qr_login_success.json")
out.write_text(json.dumps(
{"account": args.account, "qrId": qrid,
"uid": dt.get("uid"), "biztoken": dt["biztoken"],
"biz_variant": bname, "wup_variant": wname,
"ts": time.time()}, ensure_ascii=False, indent=1))
log(f" 已保存 {out}")
return 0
if rt.get("returnCode") != 0:
break
time.sleep(2.5)
bound = True
break
if bound:
break
log("\n==== 全部变体未打通 ====")
return 1
if __name__ == "__main__":
raise SystemExit(main())