157 lines
6.5 KiB
Python
157 lines
6.5 KiB
Python
"""探测快手官方验证页 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())
|