Files
douyin-captcha/try/replay_human.py
T
杨豪 cae37465d2 feat: 尺度精修上线,首次机器 PASS(live_160548 s=1.0375)
- method_l_shape: OK 候选尺度 ±0.0375 精修(步长 0.0125 + ±6px 窗口),
  GT live_165204 Δ 179→176(真人 177),离线 9/10 保持
- 插桩 JSON.stringify 捕获 SDK 日志明文;指针 probe 排除 coalesced/pressure
- 拖动不跟手指标作废:post_pos 晚于 SDK 重置滑块,鼠标事件流始终完整
- 剩余 VerifyErr = 候选整体选错(视觉确证),方向:多候选质量评估
- docs: experiments/solution/retrospective 同步收尾
2026-09-15 16:20:05 +08:00

242 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""真人轨迹采集 + CDP 回放(hook 路线的落地实验)
原理:
1. rec 阶段:在验证码 iframe document 上挂 capture 阶段监听,记录真人拖动产生的
pointerdown/move/up + mousedown/move/up 完整事件流(含原生时序)。
2. replay 阶段:CDP Input.dispatchMouseEvent 按录制的坐标与相对时序重放。
CDP 事件走浏览器输入管道,isTrusted=true,事件密度/节奏与真人一致。
用法:
.venv/bin/python try/replay_human.py record # 进入录制模式,等用户手动拖
.venv/bin/python try/replay_human.py replay # 刷新→solve→回放录制的真人轨迹
"""
import asyncio
import base64
import glob
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
import websockets
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
CDP_HTTP = "http://127.0.0.1:9222"
CAPT_DIR = Path(__file__).resolve().parent / "captures"
REPLAY_FILE = Path(__file__).resolve().parent / "human_trace.json"
IFRAME_X, IFRAME_Y = 770, 305 # 验证码 iframe 左上角视口坐标
SCALE = 340 / 552 # UI 显示 / 原图
def _json(path):
try:
with urllib.request.urlopen(f"{CDP_HTTP}{path}", timeout=5) as r:
return json.load(r)
except (OSError, ValueError):
return None
HOOK_JS = """
(() => {
if (window.__traceHook) return 'already';
window.__traceHook = 1;
window.__trace = [];
const types = ['pointerdown','pointermove','pointerup',
'mousedown','mousemove','mouseup'];
for (const t of types) {
document.addEventListener(t, e => {
window.__trace.push({
t, x: e.clientX, y: e.clientY,
ts: Math.round(e.timeStamp * 100) / 100,
buttons: e.buttons, trusted: e.isTrusted,
pressure: e.pressure || 0,
movementX: e.movementX || 0, movementY: e.movementY || 0,
});
}, {capture: true, passive: true});
}
return 'trace hook on';
})()
"""
async def main(mode: str):
ver = _json("/json/version")
targets = _json("/json/list") or []
if not ver:
print("CDP 9222 不可用")
return
page = next((t for t in targets if t["type"] == "page" and "douyin" in t["url"]), None)
ifr = next((t for t in targets if "rmc.bytedance.com" in t.get("url", "")), None)
if not page or not ifr:
print("找不到 douyin 页面或验证码 iframe")
return
async with websockets.connect(ver["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024) as ws:
mid = [0]
async def cdp(method, params=None, sid=None):
mid[0] += 1
m = {"id": mid[0], "method": method, "params": params or {}}
if sid:
m["sessionId"] = sid
await ws.send(json.dumps(m))
while True:
try:
r = json.loads(await ws.recv())
except json.JSONDecodeError:
continue
if r.get("id") == mid[0]:
if "error" in r:
raise RuntimeError(f"{method}: {r['error']}")
return r.get("result", {})
psid = (await cdp("Target.attachToTarget", {"targetId": page["id"], "flatten": True}))["sessionId"]
isid = (await cdp("Target.attachToTarget", {"targetId": ifr["id"], "flatten": True}))["sessionId"]
await cdp("Runtime.enable", sid=isid)
async def jse(expr, sid=isid):
r = await cdp("Runtime.evaluate",
{"expression": expr, "returnByValue": True, "awaitPromise": True}, sid)
return r.get("result", {}).get("value")
# 强制重挂(页面可能刷新过,旧 hook 已失效;先拆旧 listener
await jse("""
(() => {
window.__trace = [];
if (window.__traceFn) { for (const [t, f] of window.__traceFn) document.removeEventListener(t, f, {capture: true}); }
window.__traceFn = [];
const types = ['pointerdown','pointermove','pointerup','mousedown','mousemove','mouseup'];
for (const t of types) {
const f = e => {
window.__trace.push({t, x: e.clientX, y: e.clientY, ts: Math.round(e.timeStamp*100)/100,
buttons: e.buttons, trusted: e.isTrusted});
};
document.addEventListener(t, f, {capture: true, passive: true});
window.__traceFn.push([t, f]);
}
return 'rehooked';
})()
""")
if mode == "record":
print("== 录制模式:请在 20s 内用鼠标手动完成一次滑块拖动 ==")
await asyncio.sleep(20)
raw = await jse("JSON.stringify(window.__trace || [])")
try:
trace = json.loads(raw or "[]")
except (TypeError, ValueError) as e:
print(f"trace 解析失败: {e}")
return
mv = [e for e in trace if e["t"] == "mousemove"]
dn = [e for e in trace if e["t"] == "mousedown"]
print(f"共 {len(trace)} 事件: mousedown={len(dn)} mousemove={len(mv)}")
if len(dn) >= 1 and len(mv) > 10:
# 以第一次 mousedown 为拖动起点,截取到 mouseup
start_ts = dn[0]["ts"]
ups = [e["ts"] for e in trace if e["t"] == "mouseup" and e["ts"] > start_ts]
end_ts = ups[0] if ups else trace[-1]["ts"]
drag = [e for e in trace if start_ts - 50 <= e["ts"] <= end_ts]
try:
with open(REPLAY_FILE, "w") as f:
json.dump({"iframe_origin": [IFRAME_X, IFRAME_Y], "events": drag}, f)
except OSError as e:
print(f"保存失败: {e}")
return
print(f"已保存 {len(drag)} 事件({end_ts - start_ts:.0f}ms)→ {REPLAY_FILE}")
else:
print("没采到拖动,重试")
return
# ---- replay 模式 ----
if not REPLAY_FILE.exists():
print("没有录制文件,先跑 record")
return
try:
with open(REPLAY_FILE) as f:
rec = json.load(f)
except (OSError, ValueError) as e:
print(f"读取录制文件失败: {e}")
return
events = rec["events"]
ox, oy = rec["iframe_origin"]
# 找 mousedown 起点
dn = next(e for e in events if e["t"] == "mousedown")
start_x, start_y = ox + dn["x"], oy + dn["y"]
t0 = dn["ts"]
ups = [e["ts"] for e in events if e["t"] == "mouseup"]
total_ms = (ups[0] - t0) if ups else 1000
print(f"回放 {len(events)} 事件,拖动 {total_ms:.0f}ms")
# 刷新拿新题
rx, ry = IFRAME_X + 30, IFRAME_Y + 355
for typ in ("mousePressed", "mouseReleased"):
await cdp("Input.dispatchMouseEvent",
{"type": typ, "x": rx, "y": ry, "button": "left", "buttons": 1,
"clickCount": 1}, psid)
got = None
t0w = time.time()
while time.time() - t0w < 14 and not got:
await asyncio.sleep(0.5)
fs = sorted(glob.glob(str(CAPT_DIR / "net_*_get.json")), reverse=True)
if fs and time.time() - os.path.getmtime(fs[0]) < 10:
ts = os.path.basename(fs[0]).split("_")[1]
await asyncio.sleep(2.5)
jpgs = [f for f in glob.glob(str(CAPT_DIR / f"net_{ts}_*")) if f.endswith((".jpg", ".jpeg"))]
pngs = [f for f in glob.glob(str(CAPT_DIR / f"net_{ts}_*")) if f.endswith(".png")]
if jpgs and pngs:
got = (ts, jpgs[0], pngs[0])
if not got:
print("没等到题")
return
ts, big_f, mark_f = got
try:
with open(f"{CAPT_DIR}/net_{ts}_get.json") as f:
tip_y = json.load(f)["data"]["question"]["tip_y"]
except (OSError, KeyError, ValueError) as e:
print(f"get.json 读取失败: {e}")
return
from solve import solve
try:
with open(big_f, "rb") as f:
big_b64 = base64.b64encode(f.read()).decode()
with open(mark_f, "rb") as f:
mark_b64 = base64.b64encode(f.read()).decode()
except OSError as e:
print(f"图片读取失败: {e}")
return
result = solve(big_b64, mark_b64)
print(f"[{ts}] tip_y={tip_y} solve:", {k: result[k] for k in ("ok", "x", "score") if k in result})
if not result.get("ok"):
return
dist_ui = result["x"] * SCALE
print(f"需拖 {dist_ui:.1f}px(UI),录制拖动 {total_ms:.0f}ms")
# 回放:mousedown → 按 ts 差调度 mousemove → mouseup
await cdp("Input.dispatchMouseEvent",
{"type": "mousePressed", "x": start_x, "y": start_y,
"button": "left", "buttons": 1, "clickCount": 1}, psid)
t_start = time.time()
for e in events:
if e["t"] != "mousemove" or e["ts"] <= t0:
continue
delay = (e["ts"] - t0) / 1000.0 - (time.time() - t_start)
if delay > 0:
await asyncio.sleep(delay)
await cdp("Input.dispatchMouseEvent",
{"type": "mouseMoved", "x": ox + e["x"], "y": oy + e["y"],
"button": "left", "buttons": 1}, psid)
await asyncio.sleep(0.25)
await cdp("Input.dispatchMouseEvent",
{"type": "mouseReleased", "x": ox + events[-1]["x"], "y": oy + events[-1]["y"],
"button": "left", "buttons": 1, "clickCount": 1}, psid)
print("回放完成")
await asyncio.sleep(4)
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "record"
asyncio.run(main(mode))