# 滑块验证码（Slider Captcha）通用过验 SOP — GeeTest/易盾/防水墙/阿里 nc/通用

**适用场景**：网页滑块拼图类验证码（GeeTest v3/v4、网易易盾、腾讯防水墙、阿里 nc_1_n1z 等）。
**核心理念**：CV 双图差分定位缺口 → 类人手轨迹 → 五种派发后端任选其一（DrissionPage / Selenium / CDP-DevTools 桥 / 物理键鼠 / 纯 JS webDispatch）。

> **术语映射**（本 SOP 行文沿用作者团队内部命名，便于复制即用；公开等价物如下表）：
>
> | 内部名 | 通用等价物 | 作用 |
> |--------|-----------|------|
> | `TMWebDriver` | Chrome DevTools Protocol（CDP）桥（Playwright CDPSession / Puppeteer / `chrome-remote-interface`） | 接管已登录的真实浏览器、执行 `Input.dispatchMouseEvent` / `Input.dispatchTouchEvent` 等 |
> | `ljqCtrl` | Win32 `SendInput` 物理键鼠（替代品：`pyautogui` 用 `MOUSEEVENTF_*` 调用，或 C# / Rust 的 `windows-rs`） | 进系统事件队列的真物理鼠标，反检测最强 |
> | `screen_ocr` | `mss` 截图 + `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 条必记）

1. **缺口识别 ≠ 拖拽距离**
   CV 算出的 `gap_x` 是**缺口中心相对 bg 元素左边界**的像素。
   实际要拖的距离 = `gap_x - 滑块握点 x`（握点 ≈ `knob_grab_offset[0]`，一般 10px）。

2. **轨迹必须"非线性 + 末端减速"**
   `trajectory.py` 已封装：9-16 步 + 起点反冲 + 末端阶梯阻尼 + y 轴噪声。
   **禁止**用等距循环 + 线性 move——再好的识别也会被行为分析打回。

3. **后端选择决定成败**
   风控强度：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（最短路径）
```python
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，保留登录态）
```python
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，最强反检测）
```python
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 模式（易盾、阿里）
```python
solve_slide(page, bg_sel='.yidun_bg-img', knob_sel='.yidun_slider',
            mode='edge')
```

### 场景 E：纯函数调用（自定义 driver）
```python
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 的桥 |

## 常见坑（必读）

1. **GeeTest v4 `fullbg` 默认 `display:none`** → 截图全黑；必须 JS 强制显示再截。
2. **devicePixelRatio ≠ 1** 的高 DPI 屏（Surface/MacBook）：
   - DrissionPage 的 `get_screenshot` 已自动处理
   - Selenium 需乘 `window.devicePixelRatio`（见 `selenium_driver::_ele_shot`）
   - ljqCtrl 物理坐标全程保持物理像素，`SetProcessDPIAware()` 或除以 `dpi_scale`
3. **iframe 场景**（防水墙）：先切 frame 再操作；tmwd 用 `executionContextId`；Selenium 用 `switch_to.frame`。
4. **轨迹成功但验证失败**：多半是行为指纹检测，换更强后端（drission → tmwd → ljq）。
5. **失败重试**：失败时站点通常刷新缺口图，**必须重新截图识别**，不可复用旧 gap_x。
6. **seed 别固定**：生产代码 `seed=None`（每次不同轨迹），固定种子易被打为"轨迹重放"。
7. **canvas 元素截图**：优先 `canvas.toDataURL('image/png')`，比 CDP `Page.captureScreenshot` 精确（纯像素，无 DPR 烦恼）。
8. 🆕 **后台 tab `setTimeout` 被 throttle 到 ≥1s**（Chrome M88+ IntensiveWakeUpThrottling）
   → `web_execute_js` 里的 `await sleep(18)` 实际变 1000ms，17 步轨迹要 17 秒，超过 15s ACK。
   **修复**：同步密集派发（无 `await sleep`）或前台化 tab；详见下文「浏览器派发坑点」坑 1。
9. 🆕 **派发 target 矩阵**：Pointer Events 时代 `pointerdown→knob`，`pointermove/pointerup→document`
   （因为拖动中鼠标可能离开 knob，listener 统一绑 document）。派到 `window` 会静默失败。
10. 🆕 **`web_execute_js` 自占 debugger**：同一 tab 不能再 `chrome.debugger.attach`，
    CDP `Input.dispatchMouseEvent` 报 `Another debugger is already attached`；
    → 降级 `web_dispatch_driver`（纯 JS 派发，不抢 debugger）。
11. 🆕 **PointerEvent vs MouseEvent 不互通**：`dispatchEvent` 合成的 MouseEvent 不会触发
    pointermove 监听器（真实输入才合成）。现代滑块统一监听 PointerEvent；
    保险做法：**两种都派**（driver 已内置）。
12. 🆕 **`isTrusted=false` 被风控识破**：`dispatchEvent` 事件均为 `isTrusted=false`，
    强风控（GeeTest v4、防水墙）会拦。demo/弱风控可用；强风控走 tmwd（CDP `Input.*`，trusted）或 ljq（物理队列）。

## 可选增强：LLM 视觉兜底

当 CV 识别偏差 > 10px 仍未过时，用多模态 LLM：
```python
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。

**修复**（由轻到重）：
1. ⭐**同步密集派发**：去掉所有 `await sleep()`，一次循环打完所有 pointermove。
   demo / 弱风控站点（不检查事件时序）够用，实测本地 demo 误差 3px 验证通过。
2. `chrome.tabs.update({active:true})` 前台化目标 tab 再回放；回放完切回。
3. 用 `requestAnimationFrame` 代替 `setTimeout`：后台 tab 虽也限速但不至于 1s。
4. 强风控（GeeTest v4/防水墙）检查事件时序 → 改走 **CDP `Input.dispatchMouseEvent`**
   或 **ljqCtrl 物理鼠标**后端（二者不受 tab 可见性影响）。

**探测命令**：
```javascript
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 合成 ≠ 真实输入）。

**探测**（必做）：
```javascript
// 在目标页面跑，看监听器用哪种
// 方法 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(...)` 才生效。

**根因**：绝大多数滑块实现的监听布局：
```javascript
knob.addEventListener('pointerdown', ...)     // 按下时绑定到 knob
document.addEventListener('pointermove', ...) // 移动期间监听整个文档（因为鼠标可能离开 knob）
document.addEventListener('pointerup', ...)   // 释放也在 document（同理）
```
**规律**：`down` 发 knob，`move/up` 发 document。派到 `window` 虽可冒泡但 window 不是
document 的后代，不会传到 document listener。

**通用安全写法**：
```javascript
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 失败。

**修复**：
1. ⭐**降级 JS 派发**（本 reference 坑 1-3 描述的同步密集派发方案）—— 不用 CDP。
2. 或 `chrome.debugger.detach({tabId:X})` 后用 WebSocket 裸连 CDP endpoint（需要
   `--remote-debugging-port`，用户浏览器一般未开）。
3. 或切到 `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 正确）

```javascript
// 通过 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 业务自定 */ };
```

---

## 一键自检脚本

```javascript
// 放入 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:
  ```javascript
  document.querySelector('.geetest_canvas_fullbg').style.display = 'block'
  ```
  截完立即改回 'none'，否则用户眼见异常触发风控。
- 成功判定：`.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` 或注入 iframe `document`）
- 设备指纹 + 行为 + 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](https://github.com/monoplasty/vue-monoplasty-slide-verify) 或类似。

**处理策略**：
- 若 DOM 中能直接看到 `puzzle` image 的 `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

```python
# 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

```python
# 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

```python
# 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

```python
# 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

```python
"""
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

```python
# 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，一步到位（纯前端路径）：

```javascript
// 这段 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）
1. 激活浏览器窗口到前台：
   ```python
   import pygetwindow as gw
   gw.getWindowsWithTitle('Chrome')[0].activate()
   ```
2. 取滑块/背景的屏幕物理矩形（通过 JS 的 `getBoundingClientRect` + 浏览器窗口物理偏移）。
3. 调 `ljq_driver.solve_physical(bg_rect, knob_rect)`。
4. 验证：再次 screen_ocr 读"验证成功"文本，或用 tmwd 检测 success DOM。

### 流程 4：多模态 LLM 兜底（CV 识别失败）
```python
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）。

---

## 调试技巧

1. **轨迹可视化**（写 CV 回归用例时）：
   ```python
   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()
   ```
2. **gap_detect 调参**：
   若 edge 模式找偏，打印 `candidates`，适当调 `min_area/max_area`。
3. **失败归因**：
   - 视觉偏差 < 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

```python
# 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 步骤跑即可。

## 作用
1. **离线自检**：无需连任何站点，验证 `gap_detect.py + trajectory.py + web_dispatch_driver.py` 全链路可用
2. **经验证据**：固化 2026-04 实战样本（详见前文「浏览器派发坑点」章节）
3. **新后端冒烟**：新增 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. 启动本地静态服务
```python
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
```python
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
