ksjsb/tools/captcha_headed_probe.py
2026-07-30 20:25:56 +08:00

143 lines
6.2 KiB
Python

"""决定性实验: 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())