"""真实浏览器抓 verify 请求(ground truth): 对比纯HTTP solver 缺什么。 加载 captcha.html?key=(web-fallback, 无APP) -> 抓 mint/config/bg/cut/verify 的全量 URL+headers+body+response, 并触发一次拖拽让 verify 真正发出。 回答两个问题: (1) web-fallback blob 出来的 captchaSn, verify 给 350002(接受) 还是 350005(拒绝)? (2) 浏览器 verify 请求比我多带了什么 header / query / body? """ from __future__ import annotations import json import re import sys import time from pathlib import Path from curl_cffi import requests as cr from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) MOBILE_UA = ( "Mozilla/5.0 (Linux; Android 16; PJZ110 Build/UKQ1.230917.001; wv) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.134 " "Mobile Safari/537.36" ) CAPTCHA_HOSTS = ("captcha.zt.kuaishou.com", "app.m.kuaishou.com") def harvest_key(mobile: str = "17666663175") -> tuple[str, str]: """dummy submit(不发短信) -> error_url 的整数 key + uri。""" import subprocess 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'"error_url":\s*"(https://[^"]+key=(-?\d+)[^"]*)"', out) if not m: raise RuntimeError(f"未 harvest 到 key, CLI 输出:\n{out[-800:]}") url = m.group(1).replace("&", "&") uri_m = re.search(r"[?&]uri=([^&\"']+)", url) from urllib.parse import unquote uri = unquote(uri_m.group(1)) if uri_m else "/rest/nebula/user/login/mobileVerifyCode" return m.group(2), uri def main() -> int: key, uri = harvest_key() page_url = (f"https://app.m.kuaishou.com/verify/captcha.html?key={key}" f"&type=7&uri={uri}") print(f"[probe] key={key} uri={uri}") print(f"[probe] page={page_url}") # 设备 cookie(.kuaishou.com -> 流到所有子域, 含 captcha.zt) did = "ANDROID_" + "0" * 16 dev_cookies = [ {"name": k, "value": v, "domain": ".kuaishou.com", "path": "/"} for k, v in { "kpn": "NEBULA", "kpf": "ANDROID_PHONE", "userId": "0", "did": did, "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", }.items() ] captured: list[dict] = [] verify_req: dict | None = None verify_resp: dict | None = None def on_request(req): if not any(h in req.url for h in CAPTCHA_HOSTS): return rec = {"phase": "request", "method": req.method, "url": req.url, "headers": dict(req.headers), "post_data": req.post_data} captured.append(rec) if "kSecretApiVerify" in req.url or "verify" in req.url.lower(): nonlocal verify_req if verify_req is None: verify_req = rec def on_response(resp): if not any(h in resp.url for h in CAPTCHA_HOSTS): return try: body = resp.text() except Exception: body = "" rec = {"phase": "response", "url": resp.url, "status": resp.status, "headers": dict(resp.headers), "body": body[:600]} captured.append(rec) if "kSecretApiVerify" in resp.url or ("verify" in resp.url.lower() and resp.request.method == "POST"): nonlocal verify_resp if verify_resp is None: verify_resp = rec with sync_playwright() as pw: browser = pw.chromium.launch( channel="chrome", headless=True, args=["--no-sandbox", "--disable-web-security"], ) ctx = browser.new_context( user_agent=MOBILE_UA, viewport={"width": 412, "height": 915}, device_scale_factor=3, is_mobile=True, has_touch=True, ) ctx.add_cookies(dev_cookies) page = ctx.new_page() page.on("request", on_request) page.on("response", on_response) print("[probe] goto captcha page ...") try: page.goto(page_url, wait_until="networkidle", timeout=30000) except Exception as e: print(f"[probe] goto warn: {e}") # window.kwaiCaptchaData 是否被注入(纯浏览器应为 undefined) try: for fr in page.frames: kcd = fr.evaluate("()=>{try{return JSON.stringify(window.kwaiCaptchaData)}catch(e){return 'ERR:'+e}}") if kcd and kcd != "undefined": print(f"[probe] frame {fr.url[:60]} window.kwaiCaptchaData = {kcd[:200]}") except Exception as e: print(f"[probe] kcd eval warn: {e}") # 等 slider 出现并触发一次拖拽, 让 verify 发出 frame = None for fr in page.frames: if fr is not page.main_frame and "captcha" in fr.url: frame = fr break if frame: try: frame.locator(".slider-btn").wait_for(state="visible", timeout=15000) btn = frame.locator(".slider-btn").bounding_box() if btn: x, y = btn["x"] + btn["width"] / 2, btn["y"] + btn["height"] / 2 page.mouse.move(x, y) page.mouse.down() steps = [60, 90, 70, 50, 40, 30, 20] for dx in steps: x += dx page.mouse.move(x, y + (2 if dx % 2 else -2)) page.wait_for_timeout(30) page.mouse.up() print("[probe] drag done, 等 verify 响应 ...") page.wait_for_timeout(3000) except Exception as e: print(f"[probe] drag warn: {e}") else: print("[probe] 未找到 captcha iframe; frames:", [f.url for f in page.frames]) ctx_cookies = ctx.cookies() browser.close() # 输出: 完整 headers (不过滤), 重点关注 config / verify def dump_full(label_pred): for rec in captured: if rec["phase"] == "request" and label_pred(rec["url"]): print(f"\n>> {rec['method']} {rec['url'][:90]}") for k, v in rec["headers"].items(): print(f" {k}: {v[:160]}") if rec.get("post_data"): print(f" BODY: {rec['post_data'][:400]}") print("\n========= captcha.html GET (full headers) =========") dump_full(lambda u: "/verify/captcha.html" in u) print("\n========= config POST (full headers) =========") dump_full(lambda u: "/sliding/config" in u) print("\n========= verify POST (full headers) =========") dump_full(lambda u: "kSecretApiVerify" in u) print("\n========= config RESPONSE headers (Set-Cookie?) =========") for rec in captured: if rec["phase"] == "response" and "/sliding/config" in rec["url"]: for k, v in rec["headers"].items(): print(f" {k}: {v[:160]}") print("\n========= context cookies after load =========") print(json.dumps([{"name": c.get("name"), "value": str(c.get("value"))[:30], "domain": c.get("domain")} for c in ctx_cookies], ensure_ascii=False, indent=1)) print("\n========= VERIFY 响应 =========") if verify_resp: print(json.dumps(verify_resp, ensure_ascii=False, indent=2)[:800]) else: print("(未捕获到 verify response)") return 0 if __name__ == "__main__": raise SystemExit(main())