"""决定性实验: headed vs headless, 解 350014 之谜。 根因候选(按优先级): A) headless 触发 kwpsec 反爬指纹 -> 350014 (headed 则过) B) PC 桌面 canvas 指纹 -> 350014 (headed 也过不去 -> 真机路径) 证据矛盾: captcha_assist.py 注释称 headed(+offset=-48) 实测 result=1; 而我所有 350014 都来自 headless 的 e2e/probe。唯一未控变量 = headless。 本探针: 完全复刻 _run_playwright_challenge 的上下文(headed, msedge, 400x900, dsf=2, 默认 UA, 设备 cookie), 加 kSecretApiVerify 日志。headed 过 -> 确证 A, 答案=用 headed 浏览器接 CLI(纯静态/无抓包)。headed 不过 -> 确证 B, 走真机。 harvest 用 sms_login_cli --code 000000 (不发短信, 安全)。 """ from __future__ import annotations import json import re import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from core.captcha_assist import _auto_solve_slider, _update_captcha_assets, _wait_captcha_assets # noqa: E402 from playwright.sync_api import sync_playwright # noqa: E402 MOBILE = "17666663175" DEV_COOKIES = { "kpn": "NEBULA", "kpf": "ANDROID_PHONE", "userId": "0", "did": "ANDROID_" + "0" * 16, "didv": "1751000000000", "c": "CN", "ver": "14.5.50", "appver": "14.5.50.11631", "language": "zh-cn", "countryCode": "+86", "sys": "ANDROID_16", "mod": "OnePlus(PJZ110)", "deviceName": "OnePlus(PJZ110)", "net": "WIFI", "client_key": "2ac2a76d", "os": "android", } def harvest_error_url(mobile: str) -> str: out = subprocess.run( [sys.executable, str(ROOT / "tools" / "sms_login_cli.py"), "--mobile", mobile, "--code", "000000"], capture_output=True, text=True, cwd=str(ROOT), timeout=120, ).stdout m = re.search(r'https://app\.m\.kuaishou\.com/verify/captcha\.html\?key=-?\d+[^"]*', out) if not m: raise RuntimeError(f"harvest fail:\n{out[-700:]}") return m.group(0) def run(*, headless: bool, channel: str, offset: int = -48) -> dict: error_url = harvest_error_url(MOBILE) print(f"[probe] headless={headless} channel={channel} url={error_url[:88]}", flush=True) verify_results: list[dict] = [] bind_results: list[dict] = [] assets: dict = {"config": {}, "bg": b"", "cut": b""} def on_response(resp): _update_captcha_assets(resp, assets) if "kSecretApiVerify" in resp.url and resp.request.method == "POST": try: b = resp.json() except Exception: return verify_results.append({"result": b.get("result"), "desc": b.get("desc"), "token": bool(b.get("captchaToken"))}) print(f"[probe] VERIFY -> result={b.get('result')} desc={b.get('desc')} " f"token={bool(b.get('captchaToken'))}", flush=True) elif "/rest/wd/captcha/verify" in resp.url and resp.request.method == "POST": try: b = resp.json() except Exception: return bind_results.append({"result": b.get("result"), "desc": b.get("desc")}) print(f"[probe] BIND -> result={b.get('result')} desc={b.get('desc')}", flush=True) with sync_playwright() as pw: launch: dict = {"headless": headless} if channel: launch["channel"] = channel browser = pw.chromium.launch(**launch) # 完全复刻 _run_playwright_challenge 上下文 ctx = browser.new_context( viewport={"width": 400, "height": 900}, screen={"width": 400, "height": 900}, device_scale_factor=2, is_mobile=True, locale="zh-CN") ctx.add_cookies([{"name": k, "value": v, "domain": ".kuaishou.com", "path": "/"} for k, v in DEV_COOKIES.items()]) page = ctx.new_page() page.on("response", on_response) page.goto(error_url, wait_until="domcontentloaded", timeout=30000) for attempt in range(4): try: _wait_captcha_assets(page, assets, timeout=18) except Exception as e: print(f"[probe] wait assets fail: {e}", flush=True); break r = _auto_solve_slider(page, assets, timeout=18, offset=offset) print(f"[probe] drag#{attempt} {json.dumps(r, ensure_ascii=False)}", flush=True) deadline = time.monotonic() + 4 while time.monotonic() < deadline: page.wait_for_timeout(200) if verify_results and verify_results[-1]["result"] in (1,): # 等一下看 bind 是否触发 page.wait_for_timeout(1500); break if verify_results and verify_results[-1]["result"] == 1: page.wait_for_timeout(2500) # 给 bind 留时间 break assets["bg"] = b""; assets["cut"] = b"" page.wait_for_timeout(2500) browser.close() return {"verify": verify_results, "bind": bind_results, "last_result": verify_results[-1]["result"] if verify_results else None, "passed": any(v["result"] == 1 and v["token"] for v in verify_results)} def main(): print("\n################ EXPERIMENT A: HEADED (msedge) ################") a = run(headless=False, channel="msedge") print(f"=== A HEADED result: passed={a['passed']} last={a['last_result']} " f"verify={a['verify']} bind={a['bind']} ===", flush=True) print("\n################ EXPERIMENT B: HEADLESS (msedge) ################") b = run(headless=True, channel="msedge") print(f"=== B HEADLESS result: passed={b['passed']} last={b['last_result']} " f"verify={b['verify']} bind={b['bind']} ===", flush=True) print("\n================ VERDICT ================") if a["passed"] and not b["passed"]: print(">> HEADED 过 / HEADLESS 不过 => 350014 根因=HEADLESS 指纹. 解法=headed 浏览器接 CLI.") elif a["passed"] and b["passed"]: print(">> 两者都过 => 之前 350014 是其它偶发因(headless/dsf/UA 组合).") elif not a["passed"]: print(">> HEADED 也不过 => 350014 非 headless, 桌面 canvas 指纹被识破. 唯一路=真机 WebView.") return 0 if __name__ == "__main__": raise SystemExit(main())