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

235 lines
9.4 KiB
Python

"""隔离验证 + 诊断: 扫描 auto-drag 落点偏置, 找到能让 kSecretApiVerify result==1 的 offset。
自驱浏览器(不经 _run_system_browser_challenge, 以便加诊断):
- 每次 drag 用一个 offset (对 target_x 的原生像素修正)
- mouseup 后立即读拼图块真实落点 (vs gap_vp)
- 捕获每次 kSecretApiVerify 的 result/desc
- 失败后等自动刷新(新 bgPic)再试下一个 offset
判读: 若某个 offset 通过(result==1) -> 纯位置偏置(几何); 若整段都不通过 -> 行为/轨迹检测。
"""
from __future__ import annotations
import argparse, json, re, shutil, subprocess, sys, tempfile, 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 = ROOT / "out" / "captcha_autotest"
from core.captcha_assist import ( # noqa: E402
CaptchaVerificationState,
_auto_solve_slider,
_find_system_chromium,
_free_local_port,
_update_captcha_assets,
_wait_for_cdp_endpoint,
build_captcha_browser_cookies,
)
from core.device_profile import load_device_profile # noqa: E402
def fresh_error_url(mobile, profile, base):
cmd = [sys.executable, "-m", "tools.sms_login_cli", "--mobile", mobile,
"--code", "000000", "--base-url", base,
"--transport", "okhttp4-android10"]
print("[autotest] 取新鲜 error_url ...")
p = subprocess.run(cmd, capture_output=True, text=True, cwd=str(ROOT))
out = p.stdout + p.stderr
m = re.search(r'https://app\.m\.kuaishou\.com/verify/captcha\.html\?[^"\s\\]+', out)
if not m:
print(out[-1500:])
raise SystemExit("[autotest] 未提取到 error_url")
return m.group(0)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--mobile", default="17666663175")
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("--timeout", type=int, default=120)
ap.add_argument("--offsets", default="-48,-36,-24,-12,0,12,24,36,48",
help="逗号分隔, 对 target_x 的原生像素修正; 逐个试")
a = ap.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
offsets = [int(x) for x in a.offsets.split(",") if x.strip()]
profile = load_device_profile(a.device_profile)
url = fresh_error_url(a.mobile, a.device_profile, a.base_url)
print(f"[autotest] error_url = {url[:90]}")
cookies = build_captcha_browser_cookies(profile)
from playwright.sync_api import sync_playwright
import subprocess as sp
state = CaptchaVerificationState()
assets: dict = {"config": {}, "bg": b"", "cut": b""}
verify_log: list = [] # [{seq,result,desc,path}]
seq = {"n": 0}
bg_gen = {"n": 0} # 每来一张新 bgPic 自增
netlog: list = []
def observe(u, payload, *, status, request_payload=None):
state.observe(u, payload, status=status, request_payload=request_payload)
return state.verified
def on_response(resp):
try:
u = str(resp.url)
except Exception:
return
if any(k in u for k in ("captcha", "sliding", "verify", "/zt/", "/wd/")):
try:
st = int(resp.status)
except Exception:
st = -1
netlog.append({"url": u[:160], "status": st})
if "/sliding/bgPic" in u:
bg_gen["n"] += 1
if "kSecretApiVerify" in u or "/wd/captcha/verify" in u:
try:
body = resp.json()
except Exception:
body = None
seq["n"] += 1
verify_log.append({
"seq": seq["n"],
"result": (body or {}).get("result"),
"desc": (body or {}).get("desc"),
"path": u.split("/")[-1],
})
print(f"[verify #{seq['n']}] {u.split('/')[-1]} "
f"result={(body or {}).get('result')} desc={(body or {}).get('desc')}")
_update_captcha_assets(resp, assets)
try:
payload = resp.json()
except Exception:
return
observe(u, payload, status=int(resp.status),
request_payload=_request_payload(resp))
browser = _find_system_chromium()
port = _free_local_port()
prof = tempfile.mkdtemp(prefix="ksjsb-auto-")
proc = sp.Popen([browser, f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1", f"--user-data-dir={prof}",
"--no-first-run", "--no-default-browser-check",
"--window-size=430,920", "about:blank"],
stdout=sp.DEVNULL, stderr=sp.DEVNULL)
summary: list = []
try:
ep = _wait_for_cdp_endpoint(port, timeout=a.timeout)
with sync_playwright() as pw:
b = pw.chromium.connect_over_cdp(ep, timeout=20000)
page = b.contexts[0].pages[0] if b.contexts[0].pages else b.contexts[0].new_page()
b.contexts[0].add_cookies(cookies)
page.on("response", on_response)
page.goto(url, wait_until="domcontentloaded", timeout=30000)
print("[autotest] 等 bg/cut/config ...")
_wait_assets(page, assets, timeout=20)
print(f"[autotest] offsets = {offsets}")
for off in offsets:
cap_fr = None
for fr in page.frames:
if "captcha" in fr.url and fr is not page.main_frame:
cap_fr = fr
break
if cap_fr is None:
print("[sweep] 无 captcha frame, 中止")
break
try:
cap_fr.locator(".slider-btn").wait_for(state="visible", timeout=10000)
except Exception as e:
print(f"[sweep off={off:+d}] slider-btn 未就绪: {e}")
break
prev_seq = seq["n"]
geo = None
try:
geo = _auto_solve_slider(page, assets, timeout=20, offset=off)
except Exception as e:
print(f"[sweep off={off:+d}] solve 失败: {e}")
# 存本帧 bg + ddddocr target_x 红线, 供视觉核对 ddddocr 是否锁对缺口
if geo:
try:
from PIL import Image, ImageDraw
import io
bgim = Image.open(io.BytesIO(assets["bg"])).convert("RGB")
tx = geo["target_x"]
ImageDraw.Draw(bgim).line([(tx, 0), (tx, bgim.height)], fill="red", width=4)
mp = OUT / f"marked_off{off:+d}.png"
bgim.save(str(mp))
print(f"[mark] off={off:+d} target_x={tx} (bg {bgim.width}x{bgim.height}) -> {mp}")
except Exception as e:
print("[mark] 失败", e)
# 等 verify 触发
dl = time.monotonic() + 8
while seq["n"] == prev_seq and time.monotonic() < dl:
page.wait_for_timeout(150)
v = verify_log[-1] if (verify_log and verify_log[-1]["seq"] > prev_seq) else None
res = v["result"] if v else None
tx = geo.get("target_x") if geo else None
gp = f"{geo['gap_vp']:.1f}" if geo else "?"
ld = (f"{geo['landed_x']:.1f}" if geo and geo.get("landed_x") is not None else "?")
print(f"[sweep off={off:+d}] target_x={tx} gap_vp={gp} landed={ld} -> result={res}")
summary.append((off, res, ld, gp))
if res == 1:
print(f"[sweep] ✓ SUCCESS — offset {off:+d} 通过")
break
# 等自动刷新(新 bgPic)再试下一个
gen0 = bg_gen["n"]
dl = time.monotonic() + 8
while bg_gen["n"] == gen0 and time.monotonic() < dl:
page.wait_for_timeout(150)
if bg_gen["n"] == gen0:
print("[sweep] 未检测到刷新(可能次数用尽), 中止")
break
_wait_assets(page, assets, timeout=10)
print("===== SWEEP SUMMARY =====")
for off, res, ld, gp in summary:
mark = "" if res == 1 else ("x" if res else "?")
print(f" {mark} off={off:+d} landed={ld} gap_vp={gp} result={res}")
try:
page.screenshot(path=str(OUT / "after_sweep.png"), full_page=True)
except Exception:
pass
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except sp.TimeoutExpired:
proc.kill()
shutil.rmtree(prof, ignore_errors=True)
return 0 if any(r == 1 for _, r, _, _ in summary) else 1
def _request_payload(resp):
req = getattr(resp, "request", None)
try:
return req.post_data_json if req else None
except Exception:
return None
def _wait_assets(page, assets, *, timeout):
deadline = time.monotonic() + timeout
while not (assets["bg"] and assets["cut"] and assets["config"].get("bgPicWidth")):
if time.monotonic() >= deadline:
raise TimeoutError("等 bg/cut/config 超时")
page.wait_for_timeout(200)
if __name__ == "__main__":
raise SystemExit(main())