190 lines
7.7 KiB
Python
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("&", "&")
|
|
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())
|