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

190 lines
7.7 KiB
Python

"""真实浏览器抓 verify 请求(ground truth): 对比纯HTTP solver 缺什么。
加载 captcha.html?key=<fresh>(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("&amp;", "&")
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 = "<binary>"
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())