235 lines
9.4 KiB
Python
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())
|