- 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 同步收尾
242 lines
9.7 KiB
Python
242 lines
9.7 KiB
Python
"""真人轨迹采集 + 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))
|