feat(captcha): 增加全题型图片落盘、字形一致性强校验与防秒换拟人交互机制

- 题目图片自动下载: 所有题型原图统一落盘至 scratch/captcha_debug,文字点选自动生成带编号红圈标注图便于肉眼核验
- 字形严格一致性校验: 在 SolveResult 增加 text_match_ok 检查,对图字不符或混淆干扰题直接拒答换题,避免错答引发 errorCode=12
- 防秒换与拟人延时: 遇到非预期题目换题等待 2.8~4.2s (对齐 HAR 真实抓包),求解失败等待 2.5~3.8s,作答前模拟交互 5.5~7.5s
- 代理容错增强: 在 OneMsAdapter 捕获代理断流与鉴权异常,支持动态轮换新代理重试
This commit is contained in:
yml2213
2026-10-11 20:51:12 +08:00
parent d136fb7328
commit 60ae14d54a
6 changed files with 462 additions and 115 deletions
+99 -35
View File
@@ -24,6 +24,7 @@ M32 = 0xFFFFFFFF
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js"
TDC_PROBE_PATH = Path(__file__).resolve().parent.parent / "tools" / "tdc_probe.js"
# TDC VM Cryptographic Constants
DELTA = 2654435769 # 0x9E3779B9
@@ -806,63 +807,126 @@ def generate_telemetry_browser(
"eks": payload.get("eks") or "", "source": "browser"}
def generate_telemetry_probe(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
start_time: Optional[float] = None,
node_bin: str = "node",
timeout: float = 15.0,
) -> Dict[str, str]:
"""
通过 tools/tdc_probe.js 深度 Proxy 探针在真实沙箱中执行服务端的真实 tdc.js。
- 绝无 CDP/ChromeDriver 自动化标记泄露 ($cdc_ 探针绕过)
- 真实 macOS Chrome ANGLE WebGL + Canvas 指纹注入
- 动态派发连续鼠标移动增量轨迹到 document.onmousemove
- 原生计算当前会话密钥、时间戳与校验和,彻底避免 errorCode 12。
"""
if not TDC_PROBE_PATH.exists():
raise FileNotFoundError(f"缺少 tdc_probe.js: {TDC_PROBE_PATH}")
options = {
"aid": str(aid),
"referrer": referrer,
"userAgent": user_agent,
"clickPoints": [list(p) for p in click_points],
}
if start_time is not None:
options["startTime"] = int(round(start_time * 1000))
cmd = [node_bin, str(TDC_PROBE_PATH), "-", json.dumps(options)]
proc = subprocess.run(
cmd,
input=tdc_js_code,
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "NODE_OPTIONS": ""},
)
if proc.returncode != 0:
raise RuntimeError(f"tdc_probe 执行失败 (rc={proc.returncode}): {proc.stderr[:400]}")
for line in proc.stdout.splitlines():
if line.startswith("{") and "collect" in line:
payload = json.loads(line)
if not payload.get("success"):
raise RuntimeError(f"tdc_probe 内部报错: {payload.get('error')}")
collect = payload.get("collect") or ""
return {
"collect": collect,
"tlg": str(payload.get("tlg") or len(collect)),
"eks": payload.get("eks") or "",
"source": "probe",
}
raise RuntimeError(f"tdc_probe 无有效输出: {proc.stdout[:300]}")
def generate_telemetry(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
mode: str = "python",
start_time: Optional[float] = None,
mode: str = "auto",
) -> Dict[str, str]:
"""
统一遥测生成入口。
mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms)
mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python**
mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s)
mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s)
mode="auto" : python -> node -> browser 逐级回退
为什么默认仍然是纯 Python
------------------------
实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**:
雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案
→ errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9)
而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文
`{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明
tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的,
但服务端并不校验收到的 collect 是否与 eks 同源。
因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了
更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12),
详见 har/1ms_dianxuan/ANALYSIS.md。
mode="probe" : 深度 Proxy 语义沙箱 (推荐, 零 CDP 泄露, 原生会话级时间戳/校验和, ~80ms)
mode="python" : 纯 Python 算法还原 (内置固定密钥; 无 Node 依赖, 适合宽松 aid)
mode="python_keyed": Node 仅提取当前构建的 XTEA 密钥, 模板由 Python 回填
mode="node" : 原旧版 Node VM 沙箱
mode="browser" : 真实 Chrome + CDP 执行 (~50s)
mode="auto" : probe -> python_keyed -> node -> python 逐级回退
"""
chain = {"python": ["python"], "python_keyed": ["python_keyed"],
"node": ["node"], "browser": ["browser"],
"auto": ["python", "python_keyed", "node", "browser"],
"auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"])
chain = {
"probe": ["probe"],
"python": ["python"],
"python_keyed": ["python_keyed"],
"node": ["node"],
"browser": ["browser"],
"auto": ["probe", "python_keyed", "node", "python"],
"auto_keyed": ["probe", "python_keyed", "node", "python"],
}.get(mode, ["probe"])
errors = []
for kind in chain:
try:
if kind == "probe":
return generate_telemetry_probe(
tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer,
start_time=start_time,
)
if kind == "browser":
return generate_telemetry_browser(tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer)
return generate_telemetry_browser(
tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer,
)
if kind == "node":
return generate_telemetry_node(tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer)
return generate_telemetry_node(
tdc_js_code, click_points, aid=aid,
user_agent=user_agent, referrer=referrer,
)
if kind == "python_keyed":
# Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python
key = extract_xtea_key(tdc_js_code)
collect = generate_collect_success(click_points, key)
return {"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
"key": key}
return {
"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
"key": key,
}
collect = generate_collect(click_points, aid=aid, user_agent=user_agent)
return {"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python"}
return {
"collect": collect, "tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code), "source": "python",
}
except Exception as exc:
errors.append(f"{kind}: {exc}")
if mode != "auto":
if mode not in ("auto", "auto_keyed"):
raise
raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors))