"""抓取快手验证页的网络协议 + JS bundle,评估"扣 JS 纯算"可行性。 落盘: - out/captcha_net/timeline.json —— 所有 request(method/url/post_data head/headers) 与 response(status/content-type/url) 时间线 - out/captcha_net/js_*.js —— 页面加载的所有外部脚本源码 重点看: 1. challenge 拉图请求(哪个端点、什么参数、返回什么); 2. kSecretApiVerify 的请求体是明文距离/轨迹,还是加密 blob; 3. 构造 verify 体的 JS 能否单独扒出来 + 是否依赖 DOM/window(补环境成本)。 用法: uv run python -m tools.captcha_net --mobile 17666663175 uv run python -m tools.captcha_net --url 'https://...captcha.html?key=...' """ from __future__ import annotations import argparse import json import re import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) OUT_DIR = ROOT / "out" / "captcha_net" def _fresh_error_url(mobile: str, device_profile: str, base_url: str) -> str: cmd = [ sys.executable, "-m", "tools.sms_login_cli", "--mobile", mobile, "--base-url", base_url, "--code", "000000", "--transport", "okhttp4-android10", ] print("[net] 取新鲜 error_url ...") proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(ROOT)) out = proc.stdout + proc.stderr m = re.search(r'https://app\.m\.kuaishou\.com/verify/captcha\.html\?[^"\s\\]+', out) if not m: print(out[-1500:]) raise SystemExit("[net] 未提取到 error_url") return m.group(0) def capture(error_url: str, *, wait: int) -> None: import shutil import subprocess as sp import tempfile from core.captcha_assist import _find_system_chromium, _free_local_port, _wait_for_cdp_endpoint from playwright.sync_api import sync_playwright OUT_DIR.mkdir(parents=True, exist_ok=True) for f in OUT_DIR.glob("js_*.js"): f.unlink() browser_path = _find_system_chromium() port = _free_local_port() profile_dir = tempfile.mkdtemp(prefix="ksjsb-net-") proc = sp.Popen( [ browser_path, f"--remote-debugging-port={port}", "--remote-debugging-address=127.0.0.1", f"--user-data-dir={profile_dir}", "--no-first-run", "--no-default-browser-check", "--window-size=430,920", "about:blank", ], stdout=sp.DEVNULL, stderr=sp.DEVNULL, ) timeline: list[dict] = [] js_urls: list[str] = [] try: endpoint = _wait_for_cdp_endpoint(port, timeout=20) with sync_playwright() as pw: browser = pw.chromium.connect_over_cdp(endpoint, timeout=20000) context = browser.contexts[0] page = context.pages[0] if context.pages else context.new_page() def on_request(req: object) -> None: url = str(getattr(req, "url", "") or "") method = str(getattr(req, "method", "") or "") post = getattr(req, "post_data", None) if isinstance(post, (bytes, bytearray)): post = post.decode("utf-8", "replace") headers = dict(getattr(req, "headers", {}) or {}) if any(k in url for k in ("captcha", "sliding", "kSecret", "verify", "/zt/", "/wd/")): timeline.append({ "kind": "req", "method": method, "url": url, "post_head": (post or "")[:600], "header_keys": sorted(headers), }) if url.endswith(".js") or ".js?" in url: js_urls.append(url) def on_response(resp: object) -> None: url = str(getattr(resp, "url", "") or "") if any(k in url for k in ("captcha", "sliding", "kSecret", "verify", "/zt/", "/wd/")): headers = dict(getattr(resp, "headers", {}) or {}) timeline.append({ "kind": "resp", "url": url, "status": int(getattr(resp, "status", 0) or 0), "content_type": headers.get("content-type", ""), }) page.on("request", on_request) page.on("response", on_response) print(f"[net] goto {error_url[:90]}...") page.goto(error_url, wait_until="domcontentloaded", timeout=30000) time.sleep(min(max(wait, 2), 12)) # 列出页面实际加载的脚本 src,逐个落盘 scripts = page.evaluate( "Array.from(document.scripts).map(s=>s.src).filter(Boolean)" ) or [] # 合并 on_request 捕获到的 .js(含动态注入的) all_js = list(dict.fromkeys([*scripts, *js_urls])) print(f"[net] 脚本数={len(all_js)},开始下载源码") from curl_cffi.requests import get as cffi_get for idx, src in enumerate(all_js): try: r = cffi_get(src, timeout=20, headers={"Referer": error_url}) body = r.content or b"" except Exception as exc: # noqa: BLE001 print(f" [{idx}] 下载失败 {src[:80]}: {exc.__class__.__name__}") continue name = f"js_{idx:02d}_{re.sub(r'[^a-z0-9]+','_',src.lower())[-40:]}.js" (OUT_DIR / name).write_bytes(body) print(f" [{idx}] {len(body)}B {src[:90]}") finally: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=3) except sp.TimeoutExpired: proc.kill() shutil.rmtree(profile_dir, ignore_errors=True) (OUT_DIR / "timeline.json").write_text( json.dumps(timeline, ensure_ascii=False, indent=2), encoding="utf-8" ) print("\n=== 协议时间线(captcha 相关)===") for t in timeline: if t["kind"] == "req": print(f" REQ {t['method']} {t['url'][:95]}") if t.get("post_head"): print(f" post: {t['post_head'][:300]}") else: print(f" RESP[{t['status']}] {t.get('content_type','')} {t['url'][:95]}") print(f"\n[net] 落盘目录: {OUT_DIR}") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--mobile", default="17666663175") ap.add_argument("--url", default="") ap.add_argument("--device-profile", default="out/sms_device_latest.json") ap.add_argument("--base-url", default="https://az1-api.ksapisrv.com") ap.add_argument("--wait", type=int, default=8) args = ap.parse_args() error_url = args.url or _fresh_error_url(args.mobile, args.device_profile, args.base_url) print(f"[net] error_url = {error_url}") capture(error_url, wait=args.wait) return 0 if __name__ == "__main__": raise SystemExit(main())