滑块拼图验证码通用解法 (CV 双图差分+5 后端轨迹注入)

下载 .md

滑块验证码(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 物理键鼠(替代品:pyautoguiMOUSEEVENTF_* 调用,或 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(最短路径)

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 的桥

常见坑(必读)

  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→knobpointermove/pointerup→document (因为拖动中鼠标可能离开 knob,listener 统一绑 document)。派到 window 会静默失败。
  10. 🆕 web_execute_js 自占 debugger:同一 tab 不能再 chrome.debugger.attach, CDP Input.dispatchMouseEventAnother 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:

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.dispatchMouseEventljqCtrl 物理鼠标后端(二者不受 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 失败。

修复

  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 正确)

// 通过 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:
    document.querySelector('.geetest_canvas_fullbg').style.display = 'block'
    
    截完立即改回 'none',否则用户眼见异常触发风控。
  • 成功判定:.geetest_success_radar_tipwindow.captchaObjonSuccess 回调。
  • 轨迹校验:v4 引入 w 参数加密,包含鼠标事件流(时间戳+坐标)。纯 DOM 事件注入(非 CDP Input)会被判机。 → 优先 tmwd_driverljq_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 或类似。

处理策略

  • 若 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

# 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)

  1. 激活浏览器窗口到前台:
    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 识别失败)

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 回归用例时):
    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

# 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. 启动本地静态服务

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

评论(0)

登录 后可发表评论。

暂无评论。