滑块验证码为什么总拖不动:CSS 坐标、DPR 与原生鼠标事件(附可运行代码)
滑块验证码为什么总拖不动:CSS 坐标、DPR 与原生鼠标事件
现象很典型:元素找得到、坐标算得出、脚本也"点"了,滑块纹丝不动;或者动了一半弹回去,报"请控制滑块拼合缺口"。换 Playwright、换 Selenium、换 Puppeteer,结果一样。
这类问题我踩了不止一次,最后通杀的方案不是换框架,而是换输入层:不再用浏览器合成事件,改用操作系统原生鼠标事件。下面把坐标换算的推导、坑位和一份能直接跑的代码都写清楚。
一、先分清三套坐标系
绝大多数"拖不动"的问题,根源是坐标不在同一套坐标系里。
| 坐标系 | 谁在用 | 取值来源 |
|---|---|---|
| CSS 视口坐标 | 页面 JS、getBoundingClientRect() | rect.x / rect.y |
| 屏幕 CSS 坐标 | 浏览器窗口在屏幕上的位置 | 视口原点 + CSS 坐标 |
| 物理像素坐标 | 操作系统鼠标事件 | 屏幕 CSS 坐标 × DPR |
关键点:
物理坐标 = (视口原点CSS + 元素CSS坐标) × DPR
在 Windows 上,DPR 通常不是 1。我本机是 1.5(150% 缩放)。如果你直接把 getBoundingClientRect() 的值喂给 ctypes 的鼠标事件,就会系统性偏移 1.5 倍——滑块要么不动,要么滑过头。
视口原点怎么拿
最容易踩的坑:用 window.screenX / window.screenY。
// 后台标签页里,这两个值会返回 0
window.screenX // 0
window.screenY // 0
标签页处于后台时(visibilityState === 'hidden'),浏览器不会给它真实的窗口位置,返回值是 0。你拿 0 当原点,算出来的坐标自然全错。
可靠做法:先 Page.bringToFront 把标签切到前台,再用 CDP 的 Browser.getWindowBounds 拿窗口位置,视口原点 = 窗口位置 + 浏览器工具栏高度。实测下来更省事的办法是标定一次:在页面上放一个已知位置的元素,用截图 + 像素比对反推原点。我本机标定结果是 CSS (239, 566),这个值在一个稳定的窗口布局下长期有效。
二、为什么合成事件过不了风控
滑块的判定不只看轨迹,还会看事件来源。
通过 CDP Input.dispatchMouseEvent 或 element.dispatchEvent(new MouseEvent(...)) 发出的事件,isTrusted 属性是 false。风控脚本只要读一下这个属性,就能区分"真人动的鼠标"和"程序伪造的事件"。
document.addEventListener('mousemove', e => {
console.log(e.isTrusted); // 真人: true 脚本: false
});
Playwright 的 mouse.move() 同理——它走的是 CDP Input 域,本质是合成事件。
解法:绕过浏览器事件栈,直接调用操作系统 API 产生真实的鼠标输入。Windows 上就是 user32.dll 的 SendInput / mouse_event。这样产生的事件,浏览器认为是真实硬件输入,isTrusted 为 true。
三、轨迹:不能一条直线拖过去
真实鼠标移动的特征:
- 加速度是先快后慢,中间有微小抖动
- 不是匀速直线
- 起手有短暂停顿,到位后可能有回拉修正
机器拖动的典型特征:一次 mouseMove 直达终点,或者严格等距的匀速移动。这两种都会被判机器。
模拟策略(实测有效):
- 把总距离切成 20–40 段
- 速度用"先加速、后减速"的缓动函数(ease-out 更像真人收手)
- 每步叠加 ±1 px 的垂直抖动
- 到位后 停顿 100–200 ms 再释放
import math, random, time
def make_track(distance, steps=30):
"""生成先快后慢、带轻微抖动的轨迹偏移量"""
offsets = []
for i in range(steps):
t = (i + 1) / steps
# ease-out:起步快,收尾慢
x = distance * (1 - math.pow(1 - t, 2))
y = random.uniform(-1.2, 1.2)
offsets.append((x, y))
return offsets
四、一份能跑的最小实现
下面是核心部分,Windows + Python,ctypes 直接调 SendInput:
import ctypes
from ctypes import wintypes
ULONG_PTR = ctypes.POINTER(ctypes.c_ulong)
INPUT_MOUSE = 0
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_ABSOLUTE = 0x8000
class MOUSEINPUT(ctypes.Structure):
_fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("mi", MOUSEINPUT)]
def _send(flags, dx=0, dy=0):
mi = MOUSEINPUT(dx, dy, 0, flags, 0, None)
inp = INPUT(INPUT_MOUSE, mi)
ctypes.windll.user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(inp))
def _abs(x, y):
"""物理像素 → SendInput 的 0..65535 归一化坐标"""
w = ctypes.windll.user32.GetSystemMetrics(0)
h = ctypes.windll.user32.GetSystemMetrics(1)
return int(x * 65535 / w), int(y * 65535 / h)
def drag(x0, y0, distance, dpr=1.5, origin=(239, 566), css=None):
"""
x0, y0 : 元素在页面里的 CSS 坐标
css : 元素相对视口的 CSS 坐标(若已含视口偏移则传 None)
"""
# 1) CSS → 屏幕 CSS → 物理像素
sx = (origin[0] + x0) * dpr
sy = (origin[1] + y0) * dpr
px, py = _abs(sx, sy)
_send(MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE, px, py)
time.sleep(0.08)
_send(MOUSEEVENTF_LEFTDOWN)
time.sleep(0.05)
last = 0
for dx, dy in make_track(distance * dpr): # 距离同样要乘 DPR
step_x = sx + dx
step_y = sy + dy
ax, ay = _abs(step_x, step_y)
_send(MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE, ax, ay)
time.sleep(random.uniform(0.008, 0.022))
last = dx
time.sleep(0.15)
_send(MOUSEEVENTF_LEFTUP)
return True
用法:
# 从页面拿到滑块的 CSS 坐标
rect = page.evaluate("() => { const r = document.querySelector('.slider').getBoundingClientRect(); return {x:r.x, y:r.y, w:r.width, h:r.height} }")
# 目标位移(CSS 像素),实测目标:缺口位置 - 滑块初始位置
dist = 168
drag(rect['x'] + rect['w']/2, rect['y'] + rect['h']/2, dist)
五、还有几个细节决定成败
1. 目标位移不要写死。 缺口的 x 位置每次都变,正确做法是从背景图里算:取滑块图与背景图的像素差,找最匹配的偏移。写死距离只能碰运气。
2. iframe 里的元素要叠偏移。 如果验证码在内层 iframe,getBoundingClientRect() 给的是相对 iframe 的坐标,必须加上 iframe 本身在页面里的位置;如果 iframe 跨窗口,还得再叠一层。
3. 结果判定要轮询,不要 sleep。 拖动之后是异步校验,固定 sleep 3 秒既可能不够也可能浪费。正确做法:
for _ in range(40):
if page.evaluate("() => !!document.querySelector('.success')"):
return True
time.sleep(0.25)
return False
4. 别信文案,信状态。 有些验证码失败时会保留上一次的"验证成功"文案,或者失败态只是换了个 class。判断依据要用接口响应或DOM 状态位,不要匹配"验证通过"这四个字。
六、效果与边界
这套做法在阿里云验证码 2.0、字节系拼图、百度轨迹验证上都实测通过,单次拖动耗时 不到 1 秒。
但要说清楚边界:
- 不要用于批量刷量。本文只讨论自动化流程里"必须过一次验证"的场景,频率控制是另一个话题。
- 坐标标定依赖窗口布局。窗口最大化状态、显示器缩放比例变了,都要重新标定一次 DPR 和视口原点。
- 风控会升级。今天能过的轨迹,明天可能不行。所以拖动逻辑要写成可替换模块,别硬编码进主流程。
七、自检清单
遇到"拖不动",按这个顺序查:
isTrusted是 true 还是 false → 决定要不要换原生事件- DPR 是不是 1 → 不是就乘上去
- 视口原点是不是 0 → 用
window.screenX拿到的 0 多半是假的 - 位移是不是写死的 → 改成从图里算
- 轨迹是不是一条直线 → 切段 + 抖动 + 停顿
- 判定是不是 sleep → 改轮询
六条都对上,滑块基本就动了。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)