"""探测快手官方验证页 DOM,为 ddddocr 滑块识别/拖动收集真实结构。 流程: 1. 复用 sms_login_cli --code 000000(不发短信)拿一个新鲜的 705 error_url; 2. 用系统浏览器 CDP(与正式 captcha-assisted 同路径,最不易被识别)加载; 3. 等待滑块渲染,dump:iframe/img/canvas、疑似滑块元素的 bbox、保存截图与图片。 用法: uv run python -m tools.captcha_inspect --mobile 17666663175 # 直接给已知的 error_url,跳过取号: uv run python -m tools.captcha_inspect --url 'https://app.m.kuaishou.com/verify/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_inspect" def _fresh_error_url(mobile: str, device_profile: str, base_url: str) -> str: cmd = [ sys.executable, "-m", "tools.sms_login_cli", "--mobile", mobile, "--code", "000000", "--base-url", base_url, "--transport", "okhttp4-android10", ] print(f"[inspect] 取新鲜 error_url: {' '.join(cmd[:1])} ... -m tools.sms_login_cli ...") 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("[inspect] 未从验证码校验输出里提取到 error_url") return m.group(0) def _dump_frame(frame: object, tag: str, dump: dict) -> None: """对单个 frame(主页面或 iframe)执行 JS 探测。""" js = r""" () => { const grab = (el) => ({ tag: el.tagName.toLowerCase(), id: el.id || '', cls: el.className && el.className.toString ? el.className.toString() : '', bbox: (() => { const r = el.getBoundingClientRect(); return {x:r.x|0,y:r.y|0,w:r.width|0,h:r.height|0}; })(), }); const imgs = [...document.querySelectorAll('img')].map(i => ({...grab(i), src:(i.src||'').slice(0,120), nw:i.naturalWidth, nh:i.naturalHeight})); const canvases = [...document.querySelectorAll('canvas')].map(c => ({...grab(c), cw:c.width, ch:c.height})); const cand = [...document.querySelectorAll('[class*="slide"],[class*="slider"],[class*="block"],[class*="btn"],[class*="track"],[class*="handle"],[id*="slide"],[id*="block"],[id*="btn"]')].map(grab); const iframes = [...document.querySelectorAll('iframe')].map(f => ({src:(f.src||'').slice(0,120), ...grab(f)})); return {url: location.href, imgs, canvases, cand, iframes, title: document.title}; } """ try: data = frame.evaluate(js) except Exception as exc: # noqa: BLE001 dump[tag] = {"error": f"{exc.__class__.__name__}: {exc}"} return dump[tag] = data def inspect(error_url: str, *, wait: int, channel: str) -> None: from core.captcha_assist import _find_system_chromium, _free_local_port, _wait_for_cdp_endpoint from playwright.sync_api import sync_playwright import subprocess as sp import shutil import tempfile OUT_DIR.mkdir(parents=True, exist_ok=True) browser_path = _find_system_chromium() port = _free_local_port() profile_dir = tempfile.mkdtemp(prefix="ksjsb-inspect-") 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, ) 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() print(f"[inspect] goto {error_url[:90]}...") page.goto(error_url, wait_until="domcontentloaded", timeout=30000) # 给滑块资源加载时间 time.sleep(min(max(wait, 2), 15)) dump: dict = {} _dump_frame(page, "main", dump) for idx, fr in enumerate(page.frames[1:]): _dump_frame(fr, f"frame{idx}:{fr.url[:60]}", dump) (OUT_DIR / "dom.json").write_text(json.dumps(dump, ensure_ascii=False, indent=2), encoding="utf-8") try: page.screenshot(path=str(OUT_DIR / "page.png"), full_page=True) except Exception as exc: # noqa: BLE001 print(f"[inspect] 截图失败: {exc}") # 抓所有 img 字节(含 data: 与 http:),供 ddddocr 分析 saved = [] for ftag, fdata in dump.items(): if not isinstance(fdata, dict): continue for i, img in enumerate(fdata.get("imgs") or []): src = img.get("src") or "" if not src: continue saved.append({"frame": ftag, "idx": i, "src": src[:80], "bbox": img.get("bbox")}) (OUT_DIR / "imgs_index.json").write_text(json.dumps(saved, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[inspect] dump 写入 {OUT_DIR}") print(json.dumps(dump, ensure_ascii=False, indent=2)[:4000]) 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) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--mobile", default="17666663175") ap.add_argument("--url", default="", help="直接给 error_url,跳过取号") 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=6, help="等待滑块渲染秒数") args = ap.parse_args() error_url = args.url or _fresh_error_url(args.mobile, args.device_profile, args.base_url) print(f"[inspect] error_url = {error_url}") inspect(error_url, wait=args.wait, channel="system") return 0 if __name__ == "__main__": raise SystemExit(main())