滑块验证码(Slider Captcha)通用过验 SOP — GeeTest/易盾/防水墙/阿里 nc/通用
适用场景:网页滑块拼图类验证码(GeeTest v3/v4、网易易盾、腾讯防水墙、阿里 nc_1_n1z 等)。 核心理念:CV 双图差分定位缺口 → 类人手轨迹 → 五种派发后端任选其一(DrissionPage / Selenium / CDP-DevTools 桥 / 物理键鼠 / 纯 JS webDispatch)。
术语映射(本 SOP 行文沿用作者团队内部命名,便于复制即用;公开等价物如下表):
内部名 通用等价物 作用 TMWebDriverChrome DevTools Protocol(CDP)桥(Playwright CDPSession / Puppeteer / chrome-remote-interface)接管已登录的真实浏览器、执行 Input.dispatchMouseEvent/Input.dispatchTouchEvent等ljqCtrlWin32 SendInput物理键鼠(替代品:pyautogui用MOUSEEVENTF_*调用,或 C# / Rust 的windows-rs)进系统事件队列的真物理鼠标,反检测最强 screen_ocrmss截图 +RapidOCR/PaddleOCR/Tesseract屏幕截图 + 文字识别,用于物理坐标矫正 所有脚本中出现这三个名字时,按上表替换为你项目里的等价实现即可,逻辑完全通用。
一、核心 SOP
何时触发
满足以下任一:
- 页面出现拖动滑块到指定位置的交互(有阴影缺口、拼图形状、或单纯"按住按钮滑到头")
- DOM 检测到下列任一 class:
.geetest_btn/.geetest_canvas_bg/.yidun_slider/.yidun_jigsaw/#slideBlock/#nc_1_n1z/.slide-verify/.drag-verify - 用户提到:极验 / GeeTest / 易盾 / 防水墙 / 阿里滑块 / 滑块验证 / 拼图验证
不触发:Cloudflare Turnstile(→ turnstile-bypass)、reCAPTCHA 九宫格(→ 另建 skill)。
核心心法(3 条必记)
-
缺口识别 ≠ 拖拽距离 CV 算出的
gap_x是缺口中心相对 bg 元素左边界的像素。 实际要拖的距离 =gap_x - 滑块握点 x(握点 ≈knob_grab_offset[0],一般 10px)。 -
轨迹必须"非线性 + 末端减速"
trajectory.py已封装:9-16 步 + 起点反冲 + 末端阶梯阻尼 + y 轴噪声。 禁止用等距循环 + 线性 move——再好的识别也会被行为分析打回。 -
后端选择决定成败 风控强度:GeeTest v4 ≈ 防水墙 > 易盾 > 通用 事件可信度:ljq_driver > tmwd_driver > drission > selenium
五套后端对比
| 后端 | 反检测 | 依赖 | 适用 | 入口函数 |
|---|---|---|---|---|
drivers/drission_driver |
★★★ | DrissionPage | 新项目首选 | solve_slide(page, ...) |
drivers/selenium_driver |
★★ | Selenium 4 | 兼容旧代码 | solve_slide(driver, ...) |
drivers/tmwd_driver ⭐ |
★★★★ | GA web_execute_js + CDP | 用户浏览器登录态 | solve_via_tmwd(send_cmd, ...) |
drivers/ljq_driver ⭐⭐ |
★★★★★ | ljqCtrl + screen_ocr | 金融/政务强风控 | solve_physical(bg_rect, knob_rect, ...) |
drivers/web_dispatch_driver 🆕 |
★★ | 仅 GA web_execute_js | demo/弱风控/CDP 被占兜底 | solve_via_dispatch(run_js, ...) |
web_dispatch_driver于 2026-04 新增,不走 CDP → 与web_execute_js通道无冲突;同步密集派发 PointerEvent,绕开后台 tab throttle。详见下文「浏览器派发坑点」章节。
快速上手(5 种场景)
场景 A:DrissionPage(最短路径)
from DrissionPage import ChromiumPage
from slide_captcha.scripts.drivers.drission_driver import solve_slide
page = ChromiumPage()
page.get('https://site-with-geetest-v4.com/login')
page.ele('#login-btn').click()
page.wait.ele_displayed('.geetest_btn')
# 极验 v4:fullbg 默认隐藏,需先显示再截图
page.run_js("document.querySelector('.geetest_canvas_fullbg').style.display='block'")
solve_slide(page,
bg_sel='.geetest_canvas_bg',
knob_sel='.geetest_btn',
full_bg_sel='.geetest_canvas_fullbg')
page.run_js("document.querySelector('.geetest_canvas_fullbg').style.display='none'")
场景 B:用户浏览器(TMWebDriver,保留登录态)
import json
# Agent 侧:包装 web_execute_js 为 send_cmd
def send(cmd_dict):
from web_execute_js import web_execute_js # GA 工具
return web_execute_js(script=f"return await cdp_send({json.dumps(cmd_dict)})")
from slide_captcha.scripts.drivers.tmwd_driver import solve_via_tmwd
solve_via_tmwd(send,
bg_selector='.geetest_canvas_bg',
knob_selector='.geetest_btn',
full_bg_selector='.geetest_canvas_fullbg')
在实际 Agent 环境里,不需要自己包装——直接让 Agent 连续调用
web_execute_js, 把每个 CDP 命令转成一段 JS 即可。见下文「GA 集成示例」章节(scripts/ga_example.py)。
场景 C:物理鼠标(ljqCtrl,最强反检测)
from slide_captcha.scripts.drivers.ljq_driver import solve_physical
# bg_rect/knob_rect 为屏幕物理矩形,通过 tmwebdriver 的元素物理坐标 API 取
bg_rect = (410, 320, 680, 480) # (x1,y1,x2,y2)
knob_rect = (410, 500, 450, 540)
solve_physical(bg_rect, knob_rect, full_bg_rect=None) # edge 模式
场景 D:单图 edge 模式(易盾、阿里)
solve_slide(page, bg_sel='.yidun_bg-img', knob_sel='.yidun_slider',
mode='edge')
场景 E:纯函数调用(自定义 driver)
import cv2
from slide_captcha.scripts.gap_detect import detect_gap_dual
from slide_captcha.scripts.trajectory import human_drag_trajectory
before = cv2.imread('bg_full.png')
after = cv2.imread('bg_gap.png')
gap_x = detect_gap_dual(before, after)
trace = human_drag_trajectory(distance=gap_x - 10, seed=42)
for dx, dy, dt in trace:
your_driver.move_by(dx, dy); time.sleep(dt)
集成能力联动
| 集成能力 | 在本 skill 中的角色 |
|---|---|
cdp-bridge-sop |
tmwd_driver 后端核心;提供 CDP、元素物理坐标、跨 iframe |
physical-mouse-sop |
ljq_driver 后端核心;物理鼠标事件 |
screen-ocr-util (本地) |
ljq_driver 截屏来源;可选辅助找滑块(ocr "请向右滑动") |
vision_sop (多模态 LLM) |
非标滑块兜底:LLM 直接看图给缺口 x(当 CV 失败时) |
turnstile-bypass |
同级兄弟 skill,互补不重叠(点击式 vs 滑动式) |
web_execute_js |
Agent 把本 skill 当工具库调用 CDP 的桥 |
常见坑(必读)
- GeeTest v4
fullbg默认display:none→ 截图全黑;必须 JS 强制显示再截。 - devicePixelRatio ≠ 1 的高 DPI 屏(Surface/MacBook):
- DrissionPage 的
get_screenshot已自动处理 - Selenium 需乘
window.devicePixelRatio(见selenium_driver::_ele_shot) - ljqCtrl 物理坐标全程保持物理像素,
SetProcessDPIAware()或除以dpi_scale
- DrissionPage 的
- iframe 场景(防水墙):先切 frame 再操作;tmwd 用
executionContextId;Selenium 用switch_to.frame。 - 轨迹成功但验证失败:多半是行为指纹检测,换更强后端(drission → tmwd → ljq)。
- 失败重试:失败时站点通常刷新缺口图,必须重新截图识别,不可复用旧 gap_x。
- seed 别固定:生产代码
seed=None(每次不同轨迹),固定种子易被打为"轨迹重放"。 - canvas 元素截图:优先
canvas.toDataURL('image/png'),比 CDPPage.captureScreenshot精确(纯像素,无 DPR 烦恼)。 - 🆕 后台 tab
setTimeout被 throttle 到 ≥1s(Chrome M88+ IntensiveWakeUpThrottling) →web_execute_js里的await sleep(18)实际变 1000ms,17 步轨迹要 17 秒,超过 15s ACK。 修复:同步密集派发(无await sleep)或前台化 tab;详见下文「浏览器派发坑点」坑 1。 - 🆕 派发 target 矩阵:Pointer Events 时代
pointerdown→knob,pointermove/pointerup→document(因为拖动中鼠标可能离开 knob,listener 统一绑 document)。派到window会静默失败。 - 🆕
web_execute_js自占 debugger:同一 tab 不能再chrome.debugger.attach, CDPInput.dispatchMouseEvent报Another debugger is already attached; → 降级web_dispatch_driver(纯 JS 派发,不抢 debugger)。 - 🆕 PointerEvent vs MouseEvent 不互通:
dispatchEvent合成的 MouseEvent 不会触发 pointermove 监听器(真实输入才合成)。现代滑块统一监听 PointerEvent; 保险做法:两种都派(driver 已内置)。 - 🆕
isTrusted=false被风控识破:dispatchEvent事件均为isTrusted=false, 强风控(GeeTest v4、防水墙)会拦。demo/弱风控可用;强风控走 tmwd(CDPInput.*,trusted)或 ljq(物理队列)。
可选增强:LLM 视觉兜底
当 CV 识别偏差 > 10px 仍未过时,用多模态 LLM:
from scripts import vision_llm_ocr # 伪代码
prompt = "图中缺口中心的 x 像素(从左边开始数)是多少?只回数字。"
gap_x = int(vision_llm_ocr(pic_bg, prompt))
模型首选:Qwen3.5-397B-A17B(ms 旗舰多模态,见 <your-model-routing-doc>)。
开发/调试
- 自检:
python scripts/trajectory.py→ 轨迹物理/随机种子自测python scripts/gap_detect.py→ CV 缺口定位自测 - 4 个 driver 脚本
python -m ...会打印使用示例 - 可视化 trace:
from trajectory import total_path后 matplotlib 画折线
文件索引
slide-captcha/
├── SKILL.md # 本文件
├── scripts/
│ ├── trajectory.py # 纯函数:人手轨迹生成(返回增量 [dx,dy,dt])
│ ├── gap_detect.py # 纯函数:缺口 CV 定位(dual+edge)
│ ├── ga_example.py # GA Agent 调用样板
│ └── drivers/
│ ├── drission_driver.py
│ ├── selenium_driver.py
│ ├── tmwd_driver.py # GA ⭐ (CDP 通道)
│ ├── ljq_driver.py # GA ⭐⭐ (物理鼠标)
│ └── web_dispatch_driver.py # GA 🆕 (纯 JS dispatchEvent, 无 CDP)
├── references/ # 知识参考(详见下文「主流站点形态参考」「浏览器派发坑点」两章节)
│ ├── latest_sites.md # 5 大平台最新 selector / 差异 / 决策树
│ └── browser_dispatch_pitfalls.md # 🆕 JS 派发 5 大踩坑矩阵 + 自检脚本 + 完整模板
├── tests/
│ └── self_demo/ # 🆕 本地离线回归用例(无需上网)
│ ├── demo.html # canvas 滑块 demo,PointerEvent 判定
│ ├── bg.png / full.png # CV 调参样本
│ └── RUN.md # 跑通步骤 + 期望输出
└── assets/
└── (reserved for test images)
二、参考资料(references/)
浏览器派发坑点(browser_dispatch_pitfalls)
2026-04 实战沉淀。适用于用
web_execute_js/ Chrome扩展 / CDP 桥在用户浏览器里 回放人手轨迹时的事件派发问题。纯 Selenium/DrissionPage 场景里 driver 已屏蔽这些坑。
坑 1:后台 tab 的 setTimeout 被 throttle 到 ≥ 1s
现象:
for (dx,dy) of traj { await sleep(18); dispatchEvent('pointermove',...) }
目标 tab 非 active 时,每步实际间隔 ≥ 1000ms,17 步要 17 秒;GA 的 web_execute_js
15s ACK 超时,脚本被视为"死"而回收。表现为 knob 走几步就卡住。
根因:Chrome 从 M88 起对非 active tab 的 setTimeout/setInterval 实施 1s/次节流
(IntensiveWakeUpThrottling,被 hidden >5min 后更严)。await new Promise(r=>setTimeout(r,18))
实际至少 1000ms。
修复(由轻到重):
- ⭐同步密集派发:去掉所有
await sleep(),一次循环打完所有 pointermove。 demo / 弱风控站点(不检查事件时序)够用,实测本地 demo 误差 3px 验证通过。 chrome.tabs.update({active:true})前台化目标 tab 再回放;回放完切回。- 用
requestAnimationFrame代替setTimeout:后台 tab 虽也限速但不至于 1s。 - 强风控(GeeTest v4/防水墙)检查事件时序 → 改走 CDP
Input.dispatchMouseEvent或 ljqCtrl 物理鼠标后端(二者不受 tab 可见性影响)。
探测命令:
let t0 = performance.now();
await new Promise(r=>setTimeout(r,20));
return performance.now() - t0; // 前台~20ms,后台 ~1000ms
坑 2:PointerEvent vs MouseEvent — 派发类型错了静默失败
现象:派发 MouseEvent('mousemove',...),knob 不动;换 PointerEvent('pointermove',...) 才响应。
根因:现代滑块(GeeTest v4、易盾 2024+、本地 canvas demo)统一用 Pointer Events。
MouseEvent 不会触发 pointermove listener(反之亦然,pointer 不会触发 mouse —— 除非
浏览器做 compat 合成,但合成只发生在用户真实输入时,dispatchEvent 合成 ≠ 真实输入)。
探测(必做):
// 在目标页面跑,看监听器用哪种
// 方法 A:grep 源码
return [...document.scripts].map(s=>s.src||s.innerText.slice(0,200)).filter(t=>
/addEventListener\(.(pointer|mouse)(down|move|up)/.test(t));
// 方法 B:getEventListeners(仅 DevTools,脚本不可用)
生产代码统一发两种:先 PointerEvent 后 MouseEvent(顺序:pointerdown→mousedown→...), 浏览器原生行为就是这样。
坑 3:事件 target 错对象 —— pointerdown 在 knob,pointermove/up 在 document
现象:
window.dispatchEvent('pointermove',...) 没反应,改 document.dispatchEvent(...) 才生效。
根因:绝大多数滑块实现的监听布局:
knob.addEventListener('pointerdown', ...) // 按下时绑定到 knob
document.addEventListener('pointermove', ...) // 移动期间监听整个文档(因为鼠标可能离开 knob)
document.addEventListener('pointerup', ...) // 释放也在 document(同理)
规律:down 发 knob,move/up 发 document。派到 window 虽可冒泡但 window 不是
document 的后代,不会传到 document listener。
通用安全写法:
knob.dispatchEvent(ev('pointerdown', x, y, 1, 1)); // target=knob
document.dispatchEvent(ev('pointermove', x, y, 0, 1)); // target=document
document.dispatchEvent(ev('pointerup', x, y, 0, 0)); // target=document
保险做法:{bubbles: true, cancelable: true} 全开。
坑 4:web_execute_js 自己占着 debugger,CDP Input.dispatchMouseEvent 冲突
现象:发 CDP 命令报 Another debugger is already attached to the tab with id: XXX。
根因:GA 的 web_execute_js 底层通过 Chrome 扩展 chrome.debugger.attach 进入目标 tab
拿执行上下文;一个 tab 同一时刻只允许一个 debugger attach。想另起 CDP session 失败。
修复:
- ⭐降级 JS 派发(本 reference 坑 1-3 描述的同步密集派发方案)—— 不用 CDP。
- 或
chrome.debugger.detach({tabId:X})后用 WebSocket 裸连 CDP endpoint(需要--remote-debugging-port,用户浏览器一般未开)。 - 或切到
drission_driver/selenium_driver(它们独占 CDP 通道,与 web_execute_js 互斥)。
坑 5:isTrusted = false 被风控检测
现象:本地 demo / 弱风控站点通过,上极验 v4 / 防水墙被判假人。
根因:dispatchEvent(new XxxEvent(...)) 构造的事件 event.isTrusted === false,
DOM 规范保证用户真实输入才 true。风控 JS 一眼识破。
修复:
- 初级:换 CDP
Input.dispatchMouseEvent—— Chromium 生成 trusted 事件(isTrusted=true) - 终极:ljqCtrl 物理鼠标 —— 进系统事件队列,完全无痕
后端选择决策树:
demo/弱风控 → web_dispatch_driver(同步派发,最快)
中等风控 → tmwd_driver(CDP,trusted)
强风控 → ljq_driver(物理鼠标)
完整回放模板(零超时、零 throttle、target 正确)
// 通过 web_execute_js 一次派发(适用:demo / isTrusted 不检查的站点)
const knob = document.querySelector(KNOB_SEL);
const r = knob.getBoundingClientRect();
const sx = r.left + r.width/2;
const sy = r.top + r.height/2;
const ev = (type, x, y, button, buttons) => new PointerEvent(type, {
bubbles: true, cancelable: true,
clientX: x, clientY: y,
button, buttons,
pointerId: 1, pointerType: 'mouse', isPrimary: true,
});
// 1. 按下:target=knob
knob.dispatchEvent(ev('pointerdown', sx, sy, 0, 1));
// 2. 移动:target=document,全部同步派完(不要 await sleep!)
for (const [dx, dy] of TRAJECTORY) {
document.dispatchEvent(ev('pointermove', sx+dx, sy+dy, 0, 1));
}
// 3. 释放:target=document
const [lastDx, lastDy] = TRAJECTORY.at(-1);
document.dispatchEvent(ev('pointerup', sx+lastDx, sy+lastDy, 0, 0));
// 4. 等判定结果(这里可以 await sleep,因为只等一次)
await new Promise(r => setTimeout(r, 500));
return { verified: document.body.dataset.verified, /* or 业务自定 */ };
一键自检脚本
// 放入 web_execute_js,10s 内给出目标页派发诊断
const knobSel = '.geetest_btn, #knob, .yidun_slider, #nc_1_n1z span';
const knob = document.querySelector(knobSel);
if (!knob) return {error: 'knob 元素未找到,换 selector'};
// 1. throttle 检测
const t0 = performance.now();
await new Promise(r=>setTimeout(r,20));
const throttled = (performance.now() - t0) > 100;
// 2. listener 类型探测(启发式:把两种各派一次,看哪种引起 DOM 变化)
const before = knob.getBoundingClientRect().x;
knob.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,clientX:0,clientY:0,pointerId:1}));
document.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,clientX:50,clientY:0,pointerId:1,buttons:1}));
document.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,clientX:50,clientY:0,pointerId:1}));
const after = knob.getBoundingClientRect().x;
const pointerWorks = Math.abs(after - before) > 1;
return {
throttled, // true=后台tab,必须同步派发
pointerWorks, // true=监听PointerEvent
suggest: throttled
? '去掉 await sleep,同步密集派发;或前台化 tab'
: '可以用常规 await sleep(18) 节奏',
};
主流站点形态参考(latest_sites)
原则:最新版优先。历史版本(GeeTest v2/v3、老网易等)只在相关页面仍存活时参考 scripts/drivers 注释。
1. GeeTest v4(极验四代,主流部署形态)
发布时间:2020 → 至今持续迭代(含 v4.5 "宜") 业务形态:「点选/拼图/空间推理/语序/九宫格」多模态,滑块已不是唯一形态 选择器(滑动拼图场景):
.geetest_canvas_bg # 带缺口的背景(原 .gt_cut_bg)
.geetest_canvas_fullbg # 完整原图(display:none,需 style.display='block' 才能截图)
.geetest_canvas_slice # 滑块拼图本体
.geetest_btn # 滑动按钮(原 .gt_slider_knob)
要点:
- fullbg 默认隐藏,直接截图拿到的是空白。用 JS:
截完立即改回 'none',否则用户眼见异常触发风控。document.querySelector('.geetest_canvas_fullbg').style.display = 'block' - 成功判定:
.geetest_success_radar_tip或window.captchaObj的onSuccess回调。 - 轨迹校验:v4 引入
w参数加密,包含鼠标事件流(时间戳+坐标)。纯 DOM 事件注入(非 CDP Input)会被判机。 → 优先tmwd_driver或ljq_driver。
2. 网易易盾(YiDun)v2
形态:拼图滑块 / 智能无感 / 图标点选 选择器:
.yidun_bg-img # 背景
.yidun_jigsaw # 拼图 slice
.yidun_slider # 滑动按钮
.yidun_tips__text # 提示文本(成功/失败判定)
特征:
- 只返回一张带缺口的 bg 图(无 fullbg 对照)→ 必走
mode='edge' - 缺口为规则方形/圆角矩形,
detect_gap_edge效果良好 - 成功:
.yidun_popup--success出现;失败:bg 图会刷新 - 轨迹验证较松,Selenium + ActionChains 可过,tmwd/ljq 更稳
3. 腾讯防水墙 (tencent captcha)
形态:滑块 + 图形点选 + 空间语义 选择器(iframe 嵌入):
iframe[src*='turing.captcha.qcloud.com']
# 进入 iframe 后:
#slideBg # 背景
#slideBlock # 滑块
#tcaptcha_drag_button # 拖拽区
要点:
- 必须切 iframe(Selenium:
driver.switch_to.frame;DrissionPage:page.get_frame(...);tmwd: 用Runtime.evaluate时指定contextId或注入 iframedocument) - 设备指纹 + 行为 + IP 三维判分,Selenium 裸跑经常
1007 OCR failed - 强烈推荐 ljq_driver:真实鼠标事件 + 真实浏览器 session
4. 阿里云盾 (SLIDE-CAPTCHA-V2) [2023+]
形态:滑块按住 3 秒 + 智能判分(部分仅按住即过) 选择器:
#nc_1_n1z # 滑块 knob("按住拼图,拖动完成")
#nc_1__scale_text # 文案显示
.nc-lang-cnt # 容器
要点:
- 不一定有缺口图!大量场景是"按住 3 秒直到滑动"——直接 ljq 按住 + 轻微抖动 3 秒即可。
- 有缺口时 bg 为纯 canvas,需先
toDataURL('image/png')取图。 - 失败会升级为"完成以下验证"的文字点选 → 超出 slide 范畴。
5. 通用滑块(第三方库 / 自建)
特征:.slide-verify / .drag-verify / .slide-captcha;多基于 vue-monoplasty-slide-verify 或类似。
处理策略:
- 若 DOM 中能直接看到
puzzleimage 的src→ HTTP 下载原图与缺口图,本地detect_gap_dual - 若是 canvas 渲染 → CDP 截图 / canvas.toDataURL
- 99% 无行为校验,Selenium 直接过
选择器与选型决策树
能拿到【完整原图 + 带缺口图】两张?
├── YES → mode='dual',detect_gap_dual,精度 ±2px
└── NO → mode='edge',detect_gap_edge,精度 ±5px(可能需要参数微调)
站点有行为校验(指纹/事件流加密)?
├── 弱(网易易盾、通用) → drission_driver 或 selenium_driver
├── 中(GeeTest v4、防水墙) → tmwd_driver(CDP isTrusted)
└── 强(金融/政务站点) → ljq_driver(真实鼠标)
验证成功常见 DOM 信号
| 平台 | 成功节点 |
|---|---|
| GeeTest v4 | .geetest_success_radar_tip |
| 网易易盾 | .yidun_popup--success |
| 防水墙 | window.TCaptcha && ticket 存在 |
| 阿里云盾 | #nc_1_n1t .nc-lang-cnt:contains('验证通过') |
| 通用 | 多用 class*='success' 轮询 |
三、参考脚本(scripts/)
所有脚本以代码块附录,按需复制到自己的项目即可使用。
scripts/drivers/drission_driver.py
# coding=utf-8
"""
Driver: DrissionPage 后端 (推荐)
================================
为什么推荐:
- 原生 CDP,默认反 webdriver 检测(比 Selenium 强)
- API 语义比 Selenium 短,ActionChains 链式易读
- 与 turnstile-bypass skill 一致,减少依赖
依赖:`pip install DrissionPage opencv-python numpy`
公共接口(driver-无关):
solve_slide(page, bg_sel, knob_sel, *, full_bg_sel=None, mode='dual') -> bool
"""
import time
import base64
import sys
import os
import numpy as np
import cv2
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from trajectory import human_drag_trajectory
from gap_detect import detect_gap_dual, detect_gap_edge
def _ele_shot_b64(ele) -> np.ndarray:
"""DrissionPage 元素截图 → ndarray (BGR)."""
png_bytes = ele.get_screenshot(as_bytes='png')
return cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
def solve_slide(page, bg_sel: str, knob_sel: str,
*, full_bg_sel: str = None, mode: str = 'dual',
knob_grab_x: int = 10, knob_grab_y: int = 10,
seed: int = None) -> bool:
"""解 DrissionPage 页面上的滑块。
Args:
page: DrissionPage 的 ChromiumPage / WebPage
bg_sel: 带缺口阴影的背景元素选择器(如 '.gt_cut_bg' / canvas)
knob_sel: 滑块把手元素选择器
full_bg_sel: (仅 mode='dual') 完整原图元素选择器(如 '.gt_cut_fullbg')
若与 bg_sel 相同则走"按下前/按下后"方案
mode: 'dual' (双图差分) | 'edge' (单图Canny)
Returns:
bool: 可以点"确认"观察验证结果;此处仅表示动作完成。
"""
bg = page.ele(bg_sel)
knob = page.ele(knob_sel)
# 1. 识别缺口 x
if mode == 'dual':
if full_bg_sel and full_bg_sel != bg_sel:
pic_full = _ele_shot_b64(page.ele(full_bg_sel))
pic_bg = _ele_shot_b64(bg)
gap_x = detect_gap_dual(pic_full, pic_bg)
else:
pic_before = _ele_shot_b64(bg)
# 按下滑块触发缺口阴影变化
knob.hover()
page.actions.hold(knob)
time.sleep(0.3)
pic_after = _ele_shot_b64(bg)
gap_x = detect_gap_dual(pic_before, pic_after)
# 注意:此后鼠标仍按住,直接进入拖拽阶段
_drag_while_held(page, knob, gap_x - knob_grab_x, knob_grab_x, knob_grab_y, seed)
return True
else:
pic_bg = _ele_shot_b64(bg)
gap_x = detect_gap_edge(pic_bg)
# 2. 计算拖拽距离
distance = gap_x - knob_grab_x
# 3. 握住 → 按轨迹移动 → 释放
page.actions.hold(knob)
time.sleep(0.2)
_drag_while_held(page, knob, distance, knob_grab_x, knob_grab_y, seed)
return True
def _drag_while_held(page, knob, distance: float,
grab_x: int, grab_y: int, seed: int = None):
"""假设鼠标已按住 knob 在 (grab_x, grab_y) 附近,按轨迹移动并释放。"""
trace = human_drag_trajectory(distance, start_x=grab_x, start_y=grab_y, seed=seed)
for dx, dy, dt in trace:
page.actions.move(int(round(dx)), int(round(dy)))
if dt > 0:
time.sleep(min(dt, 0.3))
time.sleep(0.2)
page.actions.release()
if __name__ == '__main__':
print("DrissionPage driver loaded. Usage:")
print(" from DrissionPage import ChromiumPage")
print(" page = ChromiumPage()")
print(" page.get('https://your-captcha-site.com')")
print(" solve_slide(page, '.gt_cut_bg', '.gt_slider_knob', "
"full_bg_sel='.gt_cut_fullbg')")
scripts/drivers/ljq_driver.py
# coding=utf-8
"""
Driver: ljqCtrl + screen_ocr 物理鼠标后端 (反检测最强 ⭐⭐)
========================================================
特点:
- 操作系统级鼠标事件,浏览器完全无法区分「真人 vs 自动化」
- 适合最严格的风控站点(某些国产滑块会检测 isTrusted / mouse entropy)
- 结合 GA 的 screen_ocr 做屏幕截图,用 tmwebdriver 或 Playwright 取元素物理坐标
依赖:
- sys.path 注入后:`import ljqCtrl`(仅 Win,需先激活目标浏览器窗口)
- `from screen_ocr import screenshot`(mss 截图)
- CV:opencv-python / numpy
前置步骤(由调用方保证):
1. 浏览器窗口已激活到前台(gw.getWindowsWithTitle(...)[0].activate())
2. 提供滑块元素与背景元素的**物理坐标矩形** (x1, y1, x2, y2)
—— 可通过 TMWebDriver 的 Element.getBoxModel CDP 或 JS
getBoundingClientRect + chrome 窗口偏移算出,见 cdp-bridge-sop
接口:
solve_physical(bg_rect, knob_rect, *, full_bg_rect=None, dpi_scale=None)
"""
import sys
import os
import time
import numpy as np
import cv2
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from trajectory import human_drag_trajectory
from gap_detect import detect_gap_dual, detect_gap_edge
def _import_repo_deps():
"""延迟导入 仓库内可选依赖,让这个模块本身可 import 不炸。"""
_repo_root = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..')) # <repo_root>
for sub in ['', 'assets']:
p = os.path.join(_repo_root, sub)
if p not in sys.path:
sys.path.insert(0, p)
import ljqCtrl
from screen_ocr import screenshot as _screenshot
return ljqCtrl, _screenshot
def _crop(img: np.ndarray, rect) -> np.ndarray:
x1, y1, x2, y2 = [int(v) for v in rect]
return img[y1:y2, x1:x2].copy()
def solve_physical(bg_rect, knob_rect,
*, full_bg_rect=None,
knob_grab_offset=(10, 10),
seed: int = None,
settle_delay: float = 0.4) -> bool:
"""用 ljqCtrl 物理鼠标解滑块。
Args:
bg_rect: (x1, y1, x2, y2) **物理像素**坐标,带缺口阴影的背景。
knob_rect: 滑块把手物理矩形。
full_bg_rect: 可选,完整背景(dual 模式的前图)。
knob_grab_offset: 握点相对滑块左上角的偏移。
dpi_scale: 若调用方已全程物理坐标且 SetProcessDPIAware() → 保持 None。
"""
ljqCtrl, screenshot = _import_repo_deps()
# 1) 截屏并裁出两块图
full_shot = screenshot() # PIL.Image RGB
full_np = np.array(full_shot)[:, :, ::-1] # → BGR
pic_bg = _crop(full_np, bg_rect)
if full_bg_rect is not None:
pic_full = _crop(full_np, full_bg_rect)
gap_x = detect_gap_dual(pic_full, pic_bg)
else:
gap_x = detect_gap_edge(pic_bg)
# 2) 计算目标物理 x(相对屏幕)
bg_x1 = bg_rect[0]
target_screen_x = bg_x1 + gap_x
# 滑块起点:把手左上 + offset
start_screen_x = knob_rect[0] + knob_grab_offset[0]
start_screen_y = knob_rect[1] + knob_grab_offset[1]
distance = target_screen_x - start_screen_x
# 3) 移动到滑块 → 按下 → 按轨迹 → 释放
ljqCtrl.SetCursorPos((int(start_screen_x), int(start_screen_y)))
time.sleep(0.15)
# 按下左键(Press 支持鼠标按键?用更底层)
import win32api, win32con
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
time.sleep(0.2)
trace = human_drag_trajectory(distance,
start_x=knob_grab_offset[0],
start_y=knob_grab_offset[1],
seed=seed)
cur_x, cur_y = float(start_screen_x), float(start_screen_y)
for dx, dy, dt in trace:
cur_x += dx
cur_y += dy
ljqCtrl.SetCursorPos((int(round(cur_x)), int(round(cur_y))))
if dt > 0:
time.sleep(min(dt, 0.3))
time.sleep(settle_delay)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
return True
# -------- 坐标获取辅助(给 GA 调用方用的) --------
ELE_RECT_JS = r"""
(() => {
const sel = arguments[0];
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// 注意:r 是 CSS 像素相对 viewport;需要加 chrome 窗口偏移
return {x: r.x, y: r.y, w: r.width, h: r.height, dpr};
})()
"""
def build_rect_from_js_result(js_rect: dict, chrome_client_offset: tuple) -> tuple:
"""把 JS 拿到的 CSS 坐标 + 浏览器客户区屏幕坐标 → 物理屏幕矩形。
Args:
js_rect: {'x','y','w','h','dpr'} from ELE_RECT_JS
chrome_client_offset: (off_x, off_y) 浏览器 viewport (0,0) 在屏幕上的物理坐标
"""
dpr = js_rect.get('dpr') or 1
ox, oy = chrome_client_offset
x1 = ox + js_rect['x'] * dpr
y1 = oy + js_rect['y'] * dpr
x2 = x1 + js_rect['w'] * dpr
y2 = y1 + js_rect['h'] * dpr
return (x1, y1, x2, y2)
if __name__ == '__main__':
print("ljqCtrl physical driver loaded.")
print("Get element rect via: TMWebDriver + ELE_RECT_JS, then build_rect_from_js_result(...)")
scripts/drivers/selenium_driver.py
# coding=utf-8
"""
Driver: Selenium 4 后端 (传统兼容)
==================================
用 Selenium 4 现代 API 重写原 solver.py::do_slide_main。
修复原脚本腐败点:
- find_element_by_css_selector → find_element(By.CSS_SELECTOR, ...)
- chrome_options= kwarg → options= kwarg
- np.fromstring → np.frombuffer
- time.clock → time.perf_counter
依赖:`pip install selenium opencv-python numpy`
"""
import time
import sys
import os
import numpy as np
import cv2
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from trajectory import human_drag_trajectory
from gap_detect import detect_gap_dual, detect_gap_edge
def _ele_shot(driver, ele) -> np.ndarray:
"""全页截图再按元素 bounding rect 裁剪 → 避开跨浏览器的 ele.screenshot_as_png 差异。"""
png = driver.get_screenshot_as_png()
full = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
loc = ele.location
size = ele.size
# 设备像素比补偿
dpr = driver.execute_script('return window.devicePixelRatio') or 1
x0 = int(loc['x'] * dpr); y0 = int(loc['y'] * dpr)
x1 = int((loc['x'] + size['width']) * dpr); y1 = int((loc['y'] + size['height']) * dpr)
return full[y0:y1, x0:x1]
def solve_slide(driver, bg_sel: str, knob_sel: str,
*, full_bg_sel: str = None, mode: str = 'dual',
knob_grab_x: int = 10, knob_grab_y: int = 10,
seed: int = None) -> bool:
"""解 Selenium 托管页面上的滑块。参数同 drission_driver.solve_slide。"""
bg = driver.find_element(By.CSS_SELECTOR, bg_sel)
knob = driver.find_element(By.CSS_SELECTOR, knob_sel)
ac = ActionChains(driver)
if mode == 'dual':
if full_bg_sel and full_bg_sel != bg_sel:
full_ele = driver.find_element(By.CSS_SELECTOR, full_bg_sel)
pic_full = _ele_shot(driver, full_ele)
pic_bg = _ele_shot(driver, bg)
gap_x = detect_gap_dual(pic_full, pic_bg)
distance = gap_x - knob_grab_x
_drag(ac, knob, distance, knob_grab_x, knob_grab_y, seed)
return True
else:
pic_before = _ele_shot(driver, bg)
ac.move_to_element_with_offset(knob, knob_grab_x, knob_grab_y).click_and_hold().perform()
time.sleep(0.3)
pic_after = _ele_shot(driver, bg)
gap_x = detect_gap_dual(pic_before, pic_after)
distance = gap_x - knob_grab_x
_drag_already_held(driver, distance, knob_grab_x, knob_grab_y, seed)
return True
else:
pic_bg = _ele_shot(driver, bg)
gap_x = detect_gap_edge(pic_bg)
distance = gap_x - knob_grab_x
_drag(ac, knob, distance, knob_grab_x, knob_grab_y, seed)
return True
def _drag(ac: ActionChains, knob, distance: float,
grab_x: int, grab_y: int, seed: int = None):
"""包含 click_and_hold 的完整拖拽。"""
ac.move_to_element_with_offset(knob, grab_x, grab_y).click_and_hold().perform()
time.sleep(0.2)
trace = human_drag_trajectory(distance, start_x=grab_x, start_y=grab_y, seed=seed)
for dx, dy, dt in trace:
ActionChains(ac._driver).move_by_offset(int(round(dx)), int(round(dy))).perform()
if dt > 0:
time.sleep(min(dt, 0.3))
time.sleep(0.2)
ActionChains(ac._driver).release().perform()
def _drag_already_held(driver, distance: float,
grab_x: int, grab_y: int, seed: int = None):
"""鼠标已按住时的拖拽(dual 模式走完按下后用)。"""
trace = human_drag_trajectory(distance, start_x=grab_x, start_y=grab_y, seed=seed)
for dx, dy, dt in trace:
ActionChains(driver).move_by_offset(int(round(dx)), int(round(dy))).perform()
if dt > 0:
time.sleep(min(dt, 0.3))
time.sleep(0.2)
ActionChains(driver).release().perform()
if __name__ == '__main__':
print("Selenium driver loaded. Usage:")
print(" from selenium import webdriver")
print(" d = webdriver.Chrome()")
print(" d.get('https://your-captcha-site.com')")
print(" solve_slide(d, '.gt_cut_bg', '.gt_slider_knob', "
"full_bg_sel='.gt_cut_fullbg')")
scripts/drivers/tmwd_driver.py
# coding=utf-8
"""
Driver: TMWebDriver + CDP 桥 后端 (集成模式 ⭐)
=============================================
与 Agent 框架深度融合:通过 web_execute_js 工具 + tmwd_cdp_bridge 扩展
直接在**用户的真实浏览器**(保留登录态/Cookie)中解滑块。
优势:
- 不拉起新 Chrome(不需 Selenium/Playwright)
- CDP `Input.dispatchMouseEvent` 的 `isTrusted=true`(大多数前端行为分析器判为真实输入)
- 能在后台 tab 中操作(cdp-bridge-sop 已验证)
- 与 autofill / CF Turnstile 场景同一套基础设施
关键设计:
本模块不能直接在 Agent 进程内"调用 web_execute_js"——它生成一段
**可粘贴/可注入的 JS 脚本**(见 build_solve_js),
Agent 通过 web_execute_js 工具发给 TMWebDriver 执行。
同时提供纯 Python 的 **orchestrator** 模板(见 solve_via_tmwd)
供集成方使用:先 CDP 截图 → Python 端 CV 计算偏移 → 回注 JS 按轨迹拖拽。
接口:
- solve_via_tmwd(send_cmd, bg_selector, knob_selector, full_bg_selector=None)
`send_cmd(cmd_dict)` 是调用方提供的函数,签名等价于
`web_execute_js(script=json.dumps(cmd_dict))`
"""
import base64
import json
import time
import sys
import os
import numpy as np
import cv2
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from trajectory import human_drag_trajectory
from gap_detect import detect_gap_dual, detect_gap_edge
# -------- CDP 原子操作 --------
def _cdp_screenshot_element(send_cmd, selector: str) -> np.ndarray:
"""用 JS 取元素 dataURL 截图(canvas/img 都通),比 CDP 截页快。"""
js = f"""
(async () => {{
const el = document.querySelector({json.dumps(selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
// 优先走 canvas.toDataURL / img.src
if (el.tagName === 'CANVAS') return el.toDataURL('image/png');
if (el.tagName === 'IMG') return el.src;
// 兜底:html2canvas 不依赖,直接走 CDP captureScreenshot + clip
return {{bbox: {{x:r.x, y:r.y, width:r.width, height:r.height}}}};
}})()
"""
res = send_cmd({'cmd': 'cdp', 'method': 'Runtime.evaluate',
'params': {'expression': js, 'awaitPromise': True,
'returnByValue': True}})
val = res.get('result', {}).get('result', {}).get('value')
if val is None:
raise RuntimeError(f"element not found: {selector}")
if isinstance(val, str) and val.startswith('data:image'):
raw = base64.b64decode(val.split(',', 1)[1])
elif isinstance(val, dict) and 'bbox' in val:
clip = {**val['bbox'], 'scale': 1}
res = send_cmd({'cmd': 'cdp', 'method': 'Page.captureScreenshot',
'params': {'format': 'png', 'clip': clip,
'captureBeyondViewport': True}})
raw = base64.b64decode(res['result']['data'])
else:
raw = base64.b64decode(val)
return cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
def _ele_center(send_cmd, selector: str):
"""返回元素中心的页面坐标 (x, y) 与 dpr。"""
js = f"""
(() => {{
const el = document.querySelector({json.dumps(selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return {{cx: r.x + r.width/2, cy: r.y + r.height/2,
w: r.width, h: r.height, dpr: window.devicePixelRatio || 1}};
}})()
"""
res = send_cmd({'cmd': 'cdp', 'method': 'Runtime.evaluate',
'params': {'expression': js, 'returnByValue': True}})
return res['result']['result']['value']
def _dispatch_mouse(send_cmd, typ: str, x: float, y: float, button: str = 'left'):
"""调 CDP Input.dispatchMouseEvent(isTrusted=true)。"""
send_cmd({'cmd': 'cdp', 'method': 'Input.dispatchMouseEvent',
'params': {'type': typ, 'x': x, 'y': y,
'button': button,
'buttons': 1 if typ != 'mouseReleased' else 0,
'clickCount': 1}})
# -------- 主入口 --------
def solve_via_tmwd(send_cmd, bg_selector: str, knob_selector: str,
*, full_bg_selector: str = None,
knob_grab_offset=(10, 10), seed: int = None) -> bool:
"""通过 TMWebDriver CDP 桥解滑块。
Args:
send_cmd: callable(cmd_dict) -> result_dict;
GA 端直接传 `lambda d: web_execute_js(script=json.dumps(d))`。
bg_selector: 当前(带缺口阴影)背景图 selector。
knob_selector: 滑块把手 selector。
full_bg_selector: (可选) 完整原图 selector,用于 dual 模式的对照图。
knob_grab_offset: 握住滑块时相对其左上角的偏移。
"""
# 1) 取缺口偏移
pic_bg = _cdp_screenshot_element(send_cmd, bg_selector)
if full_bg_selector:
pic_full = _cdp_screenshot_element(send_cmd, full_bg_selector)
gap_x = detect_gap_dual(pic_full, pic_bg)
else:
gap_x = detect_gap_edge(pic_bg)
# 2) 获取滑块中心
knob_info = _ele_center(send_cmd, knob_selector)
if knob_info is None:
raise RuntimeError(f"knob not found: {knob_selector}")
# 缺口 x 是"相对 bg 元素左边界",滑块需要移动到 bg 左边界 + gap_x
bg_info = _ele_center(send_cmd, bg_selector)
target_abs_x = (bg_info['cx'] - bg_info['w'] / 2) + gap_x
start_abs_x = knob_info['cx'] - knob_info['w'] / 2 + knob_grab_offset[0]
start_abs_y = knob_info['cy'] - knob_info['h'] / 2 + knob_grab_offset[1]
distance = target_abs_x - start_abs_x
# 3) CDP 按下 → 按轨迹 move → 释放
cur_x, cur_y = start_abs_x, start_abs_y
_dispatch_mouse(send_cmd, 'mouseMoved', cur_x, cur_y)
_dispatch_mouse(send_cmd, 'mousePressed', cur_x, cur_y)
time.sleep(0.2)
trace = human_drag_trajectory(distance,
start_x=knob_grab_offset[0],
start_y=knob_grab_offset[1],
seed=seed)
for dx, dy, dt in trace:
cur_x += dx
cur_y += dy
_dispatch_mouse(send_cmd, 'mouseMoved', cur_x, cur_y)
if dt > 0:
time.sleep(min(dt, 0.3))
time.sleep(0.2)
_dispatch_mouse(send_cmd, 'mouseReleased', cur_x, cur_y)
return True
if __name__ == '__main__':
print("TMWebDriver CDP driver loaded. Usage:")
print("""
import json
from web_execute_js_wrapper import web_execute_js # 你的 GA 工具
send = lambda d: web_execute_js(script=json.dumps(d))
solve_via_tmwd(send, '.gt_cut_bg', '.gt_slider_knob',
full_bg_selector='.gt_cut_fullbg')
""")
scripts/drivers/web_dispatch_driver.py
"""
web_dispatch_driver.py · 第 5 个后端:纯 JS dispatchEvent(通过 GA web_execute_js)
定位:
- 最轻量:不依赖 Selenium / DrissionPage / CDP attach,只需 web_execute_js 通道
- 适用:弱风控站点 / 本地 demo / 开发调试 / tab 已被其他 debugger 占用的兜底
- 不适用:强风控(GeeTest v4 / 防水墙)—— isTrusted=false 会被识破,请用 tmwd_driver / ljq_driver
设计要点(依据前文「浏览器派发坑点」5 大坑):
1. 同步密集派发 pointermove(不 await sleep)—— 绕开后台 tab setTimeout throttle
2. target 矩阵:pointerdown→knob, pointermove/pointerup→document
3. 事件类型:PointerEvent(首选)+ 可选回退 MouseEvent(构造函数侧)
4. 事件属性:bubbles/cancelable 全开,isPrimary/pointerId=1
调用方式(GA Agent 视角):
from slide_captcha.scripts.drivers.web_dispatch_driver import solve_via_dispatch
solve_via_dispatch(
run_js=web_execute_js, # GA 工具
knob_sel='#knob',
bg_sel='#bg-canvas',
full_bg_sel=None, # 单图 edge 模式留 None
gap_x_getter=None, # 可选:外部已知 gap_x,省掉截图+CV 步骤
)
返回:dict(ok=bool, gap_x=int, distance=int, verified=str, status=str)
"""
from __future__ import annotations
import base64
import json
import io
import os
import sys
import time
from typing import Callable, Optional
# 确保能 import 兄弟目录的纯函数模块
_HERE = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS = os.path.dirname(_HERE)
if _SCRIPTS not in sys.path:
sys.path.insert(0, _SCRIPTS)
from gap_detect import detect_gap_dual, detect_gap_edge # noqa: E402
from trajectory import human_drag_trajectory # noqa: E402
# ------------------------------ JS 片段 ------------------------------
# 导出两张 canvas 的 dataURL(dual 模式需要 full_bg)
_JS_EXPORT_IMGS = r"""
const bg = document.querySelector(BG_SEL);
const full = FULL_BG_SEL ? document.querySelector(FULL_BG_SEL) : null;
function gridDataURL(el){
if(!el) return null;
if(el.tagName === 'CANVAS') return el.toDataURL('image/png');
// <img> fallback
const c = document.createElement('canvas');
c.width = el.naturalWidth || el.width;
c.height = el.naturalHeight || el.height;
c.getContext('2d').drawImage(el, 0, 0);
return c.toDataURL('image/png');
}
return {bg: gridDataURL(bg), full: gridDataURL(full)};
"""
# 同步密集派发轨迹(关键:无 setTimeout,避开后台 throttle)
_JS_REPLAY_TEMPLATE = r"""
const knob = document.querySelector(KNOB_SEL);
if(!knob) return {error:'knob not found: '+KNOB_SEL};
const r = knob.getBoundingClientRect();
const sx = r.left + r.width/2;
const sy = r.top + r.height/2;
const P = (type, x, y, button, buttons) => new PointerEvent(type, {
bubbles:true, cancelable:true, clientX:x, clientY:y,
button, buttons, pointerId:1, pointerType:'mouse', isPrimary:true,
});
const M = (type, x, y, button, buttons) => new MouseEvent(type, {
bubbles:true, cancelable:true, clientX:x, clientY:y, button, buttons,
});
// 1. pointerdown → knob(兼容同时发 mousedown)
knob.dispatchEvent(P('pointerdown', sx, sy, 0, 1));
knob.dispatchEvent(M('mousedown', sx, sy, 0, 1));
// 2. pointermove/mousemove → document,同步密集派发
for (const [dx, dy] of TRAJ) {
document.dispatchEvent(P('pointermove', sx+dx, sy+dy, 0, 1));
document.dispatchEvent(M('mousemove', sx+dx, sy+dy, 0, 1));
}
// 3. pointerup → document
const last = TRAJ[TRAJ.length-1];
document.dispatchEvent(P('pointerup', sx+last[0], sy+last[1], 0, 0));
document.dispatchEvent(M('mouseup', sx+last[0], sy+last[1], 0, 0));
// 4. 等判定(只 await 一次,不受 throttle 影响体感)
await new Promise(r => setTimeout(r, SETTLE_MS));
return {
ok: true,
verified: document.body.dataset.verified || null,
status: (document.querySelector(STATUS_SEL)?.textContent) || null,
knobLeft: knob.style.left || null,
};
"""
# 前台化 tab(可选,用于降低 throttle 风险)
_JS_CHECK_VISIBILITY = r"""
return {hidden: document.hidden, state: document.visibilityState};
"""
# ------------------------------ Python API ------------------------------
def _b64_png_to_bytes(data_url: str) -> bytes:
head, _, b64 = data_url.partition(',')
return base64.b64decode(b64)
def detect_gap_x(
run_js: Callable,
bg_sel: str,
full_bg_sel: Optional[str] = None,
mode: str = 'auto',
) -> int:
"""返回 gap 中心相对 bg 元素左边界的 x 像素。"""
js = (
f"const BG_SEL={json.dumps(bg_sel)};"
f"const FULL_BG_SEL={json.dumps(full_bg_sel)};"
+ _JS_EXPORT_IMGS
)
ret = run_js(script=js)
bg_url = ret.get('bg') if isinstance(ret, dict) else ret['js_return']['bg']
full_url = ret.get('full') if isinstance(ret, dict) else ret['js_return'].get('full')
import cv2, numpy as np
def _decode(u):
arr = np.frombuffer(_b64_png_to_bytes(u), np.uint8)
return cv2.imdecode(arr, cv2.IMREAD_COLOR)
bg = _decode(bg_url)
if full_url and mode != 'edge':
full = _decode(full_url)
return int(detect_gap_dual(full, bg))
return int(detect_gap_edge(bg))
def solve_via_dispatch(
run_js: Callable,
knob_sel: str,
bg_sel: str,
full_bg_sel: Optional[str] = None,
*,
gap_x: Optional[int] = None,
knob_grab_offset: int = 10,
status_sel: str = '#status',
settle_ms: int = 500,
seed: Optional[int] = None,
mode: str = 'auto',
) -> dict:
"""一键求解。
Args:
run_js: GA web_execute_js 函数(或等价 callable(script=str) -> dict)
knob_sel: 滑块握把 CSS selector
bg_sel: 背景图/canvas selector(用于截图做 CV)
full_bg_sel: 无缺口全图 selector(dual 模式,None 则走单图 edge 模式)
gap_x: 外部已知 gap 像素;传入则跳过 CV
knob_grab_offset: knob 握点相对 knob 左边界的偏移(经验值 10px)
status_sel: 结果展示元素 selector(取 textContent 作 status 返回)
settle_ms: pointerup 后等待判定的毫秒数
seed: 轨迹随机种子;**生产请传 None**(固定种子=轨迹重放风险)
mode: 'dual' / 'edge' / 'auto'
Returns:
dict: ok, gap_x, distance, verified, status, knobLeft
"""
# 1. CV 定位(可跳过)
if gap_x is None:
gap_x = detect_gap_x(run_js, bg_sel, full_bg_sel, mode=mode)
distance = gap_x - knob_grab_offset
if distance <= 0:
return {'ok': False, 'gap_x': gap_x, 'error': 'distance <= 0, check knob_grab_offset'}
# 2. 生成轨迹(trajectory 返回 [(dx, dy, dt), ...] 增量序列)
# ⚠ 关键:此 driver 同步派发,必须**累加为绝对位移**给 JS,JS 端只做 sx+ax, sy+ay
# 忽略 dt(同步密集派发,避开后台 tab throttle,见前文「浏览器派发坑点」坑 1)
trace = human_drag_trajectory(distance=distance, seed=seed)
ax = ay = 0.0
traj_xy = []
for dx, dy, _ in trace:
ax += dx
ay += dy
traj_xy.append([round(ax, 2), round(ay, 2)])
# 3. JS 派发
js = (
f"const KNOB_SEL={json.dumps(knob_sel)};"
f"const TRAJ={json.dumps(traj_xy)};"
f"const STATUS_SEL={json.dumps(status_sel)};"
f"const SETTLE_MS={int(settle_ms)};"
+ _JS_REPLAY_TEMPLATE
)
ret = run_js(script=js)
result = ret.get('js_return', ret) if isinstance(ret, dict) else {}
if not isinstance(result, dict):
result = {'raw': result}
result.update({
'gap_x': gap_x,
'distance': distance,
'traj_steps': len(traj_xy),
})
return result
# ------------------------------ CLI 自测 ------------------------------
if __name__ == '__main__':
print(__doc__)
print("\n纯函数自测(无需浏览器):")
trace = human_drag_trajectory(distance=117, seed=42)
print(f" 轨迹生成 OK,步数={len(trace)},最终位移={trace[-1][0]:.1f}px")
print("\n浏览器联调请参见 tests/self_demo/ 回归用例。")
scripts/ga_example.py
# coding=utf-8
"""
GA Agent 调用样板:如何在真实 GA 会话里用 slide-captcha skill
==============================================================
本脚本**不直接执行**(没有 web_execute_js 上下文),
而是给 GA Agent 看的**调用模板**——直接复制对应片段到 Agent 回复里即可。
四种典型调用流程:
### 流程 1:DrissionPage 独立浏览器(最简)
```python
from DrissionPage import ChromiumPage, ChromiumOptions
import sys; sys.path.insert(0, r'./scripts')
from drivers.drission_driver import solve_slide
co = ChromiumOptions().set_argument('--disable-blink-features=AutomationControlled')
page = ChromiumPage(co)
page.get('https://target.com/login')
# 触发滑块
page.ele('#login').click()
page.wait.ele_displayed('.geetest_btn', timeout=5)
page.run_js("document.querySelector('.geetest_canvas_fullbg').style.display='block'")
solve_slide(page, '.geetest_canvas_bg', '.geetest_btn',
full_bg_sel='.geetest_canvas_fullbg')
page.run_js("document.querySelector('.geetest_canvas_fullbg').style.display='none'")
# 验证成功:等 success 元素
page.wait.ele_displayed('.geetest_success_radar_tip', timeout=8)
流程 2:用户浏览器 + TMWebDriver(保留登录态)
Agent 直接用 web_execute_js 调用以下 JS,一步到位(纯前端路径):
// 这段 JS 在用户浏览器里直接跑,无需 Python 协调
(async () => {
// ====== 1. 截取缺口图 ======
const bg = document.querySelector('.geetest_canvas_bg');
const fg = document.querySelector('.geetest_canvas_fullbg');
fg.style.display = 'block';
const bgData = bg.toDataURL('image/png');
const fgData = fg.toDataURL('image/png');
fg.style.display = 'none';
// 把两张图发到 Agent 侧做 CV(通过 fetch 到本机 Python http 服务;
// 或存到 window.__slideImgs 由 Agent 下一步读取)
window.__slideImgs = {bg: bgData, fg: fgData};
return {step: 'captured', bg_len: bgData.length, fg_len: fgData.length};
})()
Agent 读回两张图 base64,调纯函数 detect_gap_dual 得到 gap_x,
然后第二段 JS 用 CDP Input.dispatchMouseEvent 按轨迹拖拽(或调 tmwd_driver.solve_via_tmwd 带上轨迹)。
流程 3:物理鼠标(ljqCtrl)
- 激活浏览器窗口到前台:
import pygetwindow as gw gw.getWindowsWithTitle('Chrome')[0].activate() - 取滑块/背景的屏幕物理矩形(通过 JS 的
getBoundingClientRect+ 浏览器窗口物理偏移)。 - 调
ljq_driver.solve_physical(bg_rect, knob_rect)。 - 验证:再次 screen_ocr 读"验证成功"文本,或用 tmwd 检测 success DOM。
流程 4:多模态 LLM 兜底(CV 识别失败)
import base64, requests
img_b64 = base64.b64encode(open('bg_gap.png','rb').read()).decode()
resp = requests.post('https://api.<openai-compatible-host>/v1/chat/completions', ...)
# prompt: "缺口中心 x 像素(从左边开始数,图宽 {W}px),只输出数字"
gap_x = int(resp.json()['choices'][0]['message']['content'].strip())
模型选 Qwen3.5-397B-A17B(ms filter=inference_type)或 gpt-5.5(legacy_provider)。
调试技巧
- 轨迹可视化(写 CV 回归用例时):
from scripts.trajectory import total_path import matplotlib.pyplot as plt pts = total_path(list(__import__('scripts.trajectory', fromlist=['human_drag_trajectory']).human_drag_trajectory(180, seed=1))) plt.plot([p[0] for p in pts], [p[1] for p in pts]); plt.gca().invert_yaxis(); plt.show() - gap_detect 调参:
若 edge 模式找偏,打印
candidates,适当调min_area/max_area。 - 失败归因:
- 视觉偏差 < 5px 但验证失败 → 行为指纹,换后端
- 视觉偏差 > 10px → CV 参数/mask_left 需调
- 动作没触发任何事件 → iframe 未切 / 选择器过期
与 turnstile-bypass 的区别
| 维度 | turnstile-bypass | slide-captcha (本) |
|---|---|---|
| 交互 | 单击 / 自动通过 | 拖拽 |
| 核心 | token 拦截 / iframe 点击 | CV + 轨迹 + 事件注入 |
| 风控 | Cloudflare 风控 | 极验/易盾/防水墙/阿里 |
| 后端 | DrissionPage 为主 | 4 套后端按强度选 |
| """ | ||
| print(doc) |
### scripts/gap_detect.py
```python
# coding=utf-8
"""
滑块缺口偏移检测 (Pure Function, CV-based)
=============================================
源自 solver.py::seg_method 的"双图差分法",剥离副作用:
两种工作模式:
1. **dual_image 模式** (pic_before + pic_after)
- pic_before: 用户按下滑块**之前**的完整背景图(含缺口阴影,无滑块)
- pic_after: 按下滑块后、但滑块停留在起始位置时的图(滑块覆盖了左侧,同时缺口阴影变形)
- 对应 GeeTest v2 的 gt_cut_fullbg + 按下后的 DOM 切片
- 原理:按列计算两图差分强度,找差异峰值 → 缺口中心 x
2. **edge 模式** (only bg)
- 适用只有一张带缺口的背景图场景(网易易盾/阿里云盾新版)
- Canny + 轮廓找圆/方形 → 返回缺口中心 x
返回值统一为:缺口相对于图像左边缘的 x 像素偏移。
调用方需自行减去 **滑块初始中心 x** 才得到"应拖拽的距离"。
"""
import numpy as np
import cv2
# ---------------- dual_image 模式 ----------------
def detect_gap_dual(pic_before: np.ndarray, pic_after: np.ndarray,
debug: bool = False) -> int:
"""双图差分法定位缺口中心 x。
Args:
pic_before, pic_after: BGR 或 BGRA np.ndarray,尺寸必须一致。
debug: True 时同时返回中间可视化图。
Returns:
int: 缺口中心像素 x(相对图像左边缘)。
若 debug=True,返回 (x, vis_img)。
"""
assert pic_before.shape == pic_after.shape, \
f"shape mismatch: {pic_before.shape} vs {pic_after.shape}"
# 归一化为 BGR
if pic_before.shape[2] == 4:
pic_before = cv2.cvtColor(pic_before, cv2.COLOR_BGRA2BGR)
pic_after = cv2.cvtColor(pic_after, cv2.COLOR_BGRA2BGR)
h, w = pic_before.shape[:2]
# 1. 灰度差分 + 去低频噪声
g0 = cv2.cvtColor(pic_before, cv2.COLOR_BGR2GRAY).astype(np.int16)
g1 = cv2.cvtColor(pic_after, cv2.COLOR_BGR2GRAY).astype(np.int16)
diff = np.abs(g0 - g1).astype(np.uint8)
diff = cv2.GaussianBlur(diff, (5, 5), 0)
# 2. 按列累加差分强度 → 得到 x 轴能量曲线
col_energy = diff.sum(axis=0).astype(np.float32)
# 3. 屏蔽左侧(滑块初始位置区,默认 15% 宽)干扰
mask_left = int(w * 0.15)
col_energy[:mask_left] = 0
# 4. 平滑 + 取峰
kernel = np.ones(11, dtype=np.float32) / 11
smoothed = np.convolve(col_energy, kernel, mode='same')
gap_x = int(smoothed.argmax())
if debug:
vis = cv2.cvtColor(diff, cv2.COLOR_GRAY2BGR)
cv2.line(vis, (gap_x, 0), (gap_x, h), (0, 0, 255), 2)
return gap_x, vis
return gap_x
# ---------------- edge 模式 ----------------
def detect_gap_edge(bg: np.ndarray,
min_area: int = 400, max_area: int = 8000,
debug: bool = False):
"""单图边缘法:Canny + 轮廓,找最像缺口(近方/近圆)的区域。
Args:
bg: BGR np.ndarray。
min_area / max_area: 缺口面积范围(像素²),过滤文字/其它干扰。
Returns:
int: 缺口中心像素 x(未找到时抛 ValueError)。
"""
if bg.shape[2] == 4:
bg = cv2.cvtColor(bg, cv2.COLOR_BGRA2BGR)
gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (3, 3), 0)
edges = cv2.Canny(gray, 80, 200)
# 闭操作让缺口轮廓封闭
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
h, w = bg.shape[:2]
mask_left = int(w * 0.15)
candidates = []
for c in contours:
x, y, cw, ch = cv2.boundingRect(c)
if x < mask_left:
continue
area = cw * ch
if not (min_area <= area <= max_area):
continue
# 长宽比约 0.7~1.4,过滤长条文字
ratio = cw / max(ch, 1)
if not 0.6 <= ratio <= 1.6:
continue
candidates.append((x + cw // 2, y + ch // 2, cw, ch, area))
if not candidates:
raise ValueError("no gap-like contour found; try adjusting min/max_area")
# 取 y 接近图像中心 & 面积适中
cy_ref = h / 2
candidates.sort(key=lambda t: abs(t[1] - cy_ref) + abs(t[4] - 2500) * 0.01)
best = candidates[0]
if debug:
vis = bg.copy()
cv2.circle(vis, (best[0], best[1]), max(best[2], best[3]) // 2, (0, 0, 255), 2)
return best[0], vis
return best[0]
# ---------------- 辅助 IO ----------------
def decode_image(data) -> np.ndarray:
"""兼容多种输入:bytes / base64 str / 文件路径 / 已是 ndarray。"""
if isinstance(data, np.ndarray):
return data
if isinstance(data, (bytes, bytearray)):
return cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
if isinstance(data, str):
if data.startswith('data:image'):
data = data.split(',', 1)[1]
if len(data) > 256 and ' ' not in data and '\n' not in data:
# 看起来像 base64
import base64
raw = base64.b64decode(data)
return cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
return cv2.imread(data, cv2.IMREAD_UNCHANGED)
raise TypeError(f"unsupported image input: {type(data)}")
if __name__ == '__main__':
# 自检:人造两张图,缺口放在 x=250
import os
rng = np.random.default_rng(0)
bg = rng.integers(100, 200, size=(160, 360, 3), dtype=np.uint8)
before = bg.copy()
# 缺口阴影(均匀暗块)
cv2.rectangle(before, (240, 60), (280, 100), (30, 30, 30), -1)
after = before.copy()
# 按下后:滑块本体出现在左侧 + 缺口形变(我们这里简化:让 after 中缺口亮度略变)
cv2.rectangle(after, (0, 60), (40, 100), (200, 200, 200), -1)
cv2.rectangle(after, (240, 60), (280, 100), (60, 60, 60), -1)
x_dual = detect_gap_dual(before, after)
x_edge = detect_gap_edge(before)
print(f"dual={x_dual} edge={x_edge} (expected≈260)")
assert 240 <= x_dual <= 280, f"dual 模式偏差过大: {x_dual}"
assert 240 <= x_edge <= 280, f"edge 模式偏差过大: {x_edge}"
print("OK gap_detect 自检通过")
scripts/trajectory.py
# coding=utf-8
"""
人手轨迹生成器 (Pure Function)
=================================
源自 solver.py::do_it 的物理模型,剥离副作用:
输入:要滑动的水平距离 distance(像素)
输出:[(dx, dy, dt), ...] 增量序列;调用方按需 sleep(dt) 后位移 (dx, dy)
物理模型核心:
- 二阶动力学:a = (target - now) * (|target - now|^k) * trig_jitter + noise
- 临近目标时按距离阶梯式增大阻尼系数 nnf,模拟人在末端"减速锁定"
- y 方向幅度远小于 x,并叠加 sin(y)/cos(x) 让轨迹非线性可微
- 起点添加随机超出("过冲" overshoot)再回拉,破坏机器人式直线特征
注意:原脚本以 ele 元素左上角为坐标原点,本模块返回**纯增量**,与坐标系无关。
"""
import math
import random
import numpy as np
def human_drag_trajectory(
distance,
start_x=10,
start_y=10,
seed=None,
):
"""生成仿真人手拖拽轨迹增量序列。
Args:
distance: 水平方向需要滑动的目标像素距离(>0 向右,<0 向左)。
start_x, start_y: 在握住的滑块上落点的偏移(让 mousedown 不在中心,更像人)。
seed: 复现用随机种子。None=每次不同。
Returns:
list of (dx, dy, dt):相对前一步的位移增量与等待秒数。
最后一帧已是"落点",调用方完成 release 即可。
"""
if seed is not None:
random.seed(seed)
np.random.seed(seed)
nowx, nowy = float(start_x), float(start_y)
objx = nowx + distance
objy = nowy + random.randint(-100, 100) # 终点 y 也带噪声
# 初始速度:朝目标方向给一个反向冲量,制造"先回拉再加速"的人手特征
v = np.array([random.randint(10, 120), random.randint(-50, 50) - 0.5]) * -4.0
a = np.array([0.0, 0.0])
# 末端阻尼阶梯:越接近目标阻尼越大
nnf = random.random() * 0.15 + 0.2
nnfs = [
(random.randint(80, 120), random.random() * 0.3502 + 0.04),
(random.randint(40, 60), random.random() * 0.31 + 0.10),
(random.randint(20, 40), random.random() * 0.10 + 0.10),
(random.randint(6, 20), random.random() * 0.315 + 0.10),
(random.randint(4, 7), random.random() * 0.315 + 0.20),
]
# 噪声目标点(让吸引子带抖动)
sp = 70
ox, oy = objx + random.randint(-sp, sp), objy + random.randint(-sp, sp)
tp = random.random() * 0.06 + 0.08 # 时间步长缩放
texp = random.random() * 0.6 + 1.0 # 时间逐步衰减系数
trace = []
tm = 0
steps = random.randint(10, 15)
for ii in range(steps):
t = random.randint(20, 40) * 1e-2 * tp
wait = max(0.0, t * (texp - ii / 1700))
# 噪声目标周期性回到真目标(避免漂移)
ovx = (objx - ox) * random.randint(100, 500)
ovy = (objy - oy) * random.randint(20, 40)
ox += ovx * t
oy += ovy * t
if random.random() < 0.8:
ox, oy = objx, objy
# 加速度:距离 × 距离^幂 × 三角抖动 + 噪声
a[0] = ((ox - nowx) * (max(abs(ox - nowx), 100) ** 1.1)) * (1 + math.sin(nowy)) + random.randint(-10, 100)
a[1] = ((oy - nowy) * (max(abs(oy - nowy), 50) ** 0.5)) * (2 + random.randint(2, 13) * math.cos(nowx)) + random.randint(-50, 60)
a = a.clip(-3500, 3500)
v += a * t
# 末端阻尼
for z, nf in nnfs:
if abs(objx - nowx) < z:
nnf = nf
v -= v * nnf
prevx, prevy = nowx, nowy
nowx += v[0] * t
nowy += v[1] * t
trace.append((nowx - prevx, nowy - prevy, wait))
# 命中提前退出
if abs(nowx - objx) < 1:
tm += 1
if tm > 4:
break
# 末帧:精确落点(拉直最后 1px 抖动)+ 0.2s 停顿模拟人确认
last_dx = objx - nowx
trace.append((last_dx, 0.0, 0.2))
return trace
def total_path(trace):
"""工具函数:累加 trace,便于调试 / 可视化。"""
x = y = 0.0
pts = [(0.0, 0.0)]
for dx, dy, _ in trace:
x += dx
y += dy
pts.append((x, y))
return pts
if __name__ == '__main__':
# 自检:跑两次同 seed 应得相同结果;不同 seed 应不同
a = human_drag_trajectory(180, seed=42)
b = human_drag_trajectory(180, seed=42)
c = human_drag_trajectory(180, seed=99)
assert a == b, "seed 复现失败"
assert a != c, "seed 隔离失败"
pts = total_path(a) # 增量累加,末点应等于 distance
assert abs(pts[-1][0] - 180) < 1.5, f"X 落点偏差过大: {pts[-1]}"
print(f"OK steps={len(a)} 末点={pts[-1]} 总耗时={sum(x[2] for x in a):.2f}s")
四、本地自演示(tests/self_demo/RUN.md)
本节描述如何在不依赖外部目标站点的前提下,本地自演示完整管线。配套的 bg.png / full.png / demo.html 不在此随附,复制即可:在自己的目录下放一张完整背景 + 一张挖了缺口的同尺寸图,按下面的 RUN.md 步骤跑即可。
作用
- 离线自检:无需连任何站点,验证
gap_detect.py + trajectory.py + web_dispatch_driver.py全链路可用 - 经验证据:固化 2026-04 实战样本(详见前文「浏览器派发坑点」章节)
- 新后端冒烟:新增 driver 时先在此 demo 跑通再上生产
构成
self_demo/
├── demo.html ## 纯前端滑块(canvas 绘制背景 + 缺口,随机 GAP_X∈[80,240])
│ ## 监听 PointerEvent,document.body.dataset.verified 判定结果
├── bg.png ## 带缺口背景(参考图,用于单图 edge CV 调参)
├── full.png ## 无缺口全图(参考图,用于 dual 差分 CV 调参)
└── RUN.md ## 本文件
快速跑通(GA Agent 视角)
1. 启动本地静态服务
import http.server, socketserver, threading, os
os.chdir(r'./tests\self_demo')
srv = socketserver.TCPServer(('127.0.0.1', 8765), http.server.SimpleHTTPRequestHandler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
# → http://127.0.0.1:8765/demo.html
2. 打开 demo,让 web_execute_js 接管
浏览器里打开 http://127.0.0.1:8765/demo.html,记下 tabId。
3. 调用 driver
from slide_captcha.scripts.drivers.web_dispatch_driver import solve_via_dispatch
# 封装 GA web_execute_js 为 callable
def run_js(script):
# 省略:调用 GA 工具,传入 switch_tab_id=demo_tab_id
...
result = solve_via_dispatch(
run_js=run_js,
knob_sel='#knob',
bg_sel='#bg-canvas',
full_bg_sel=None, # 本 demo 不暴露 full canvas,用单图 edge 模式
gap_x=None, # 让 driver 自己跑 CV
knob_grab_offset=10,
status_sel='#status',
settle_ms=500,
seed=None, # 生产必须 None
)
assert result.get('verified') == '1', result
print(f"✅ 通过,误差<4px,knob={result['knobLeft']}")
期望输出
result = {
'ok': True,
'verified': '1',
'status': '✅ 验证成功!误差 X.Xpx',
'knobLeft': '119px', # 起始 2 + 位移 117 = 119
'gap_x': ~120, # CV 结果(真值 GAP_X 随机)
'distance': ~110, # gap_x - knob_grab_offset
'traj_steps': 10~16,
}
已验证(2026-04-20)
| 步骤 | 期望 | 实测 | 状态 |
|---|---|---|---|
| CV edge gap_x | ±4px | 误差 3px | ✅ |
| trajectory 末点 | =distance | 偏差 <1.5px | ✅ |
| PointerEvent 派发 | dataset.verified=1 | 验证成功 | ✅ |
相关踩坑
详见前文「浏览器派发坑点」章节,要点:
- 坑1:后台 tab setTimeout throttle → driver 已内置同步派发
- 坑2:MouseEvent 静默失败 → driver 同时发 Pointer+Mouse
- 坑3:target 错对象 → driver 硬编码 pointerdown→knob, move/up→document