滑块验证码为什么总拖不动: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 直达终点,或者严格等距的匀速移动。这两种都会被判机器。

模拟策略(实测有效):

  1. 把总距离切成 20–40 段
  2. 速度用"先加速、后减速"的缓动函数(ease-out 更像真人收手)
  3. 每步叠加 ±1 px 的垂直抖动
  4. 到位后 停顿 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 和视口原点。
  • 风控会升级。今天能过的轨迹,明天可能不行。所以拖动逻辑要写成可替换模块,别硬编码进主流程。

七、自检清单

遇到"拖不动",按这个顺序查:

  1. isTrusted 是 true 还是 false → 决定要不要换原生事件
  2. DPR 是不是 1 → 不是就乘上去
  3. 视口原点是不是 0 → 用 window.screenX 拿到的 0 多半是假的
  4. 位移是不是写死的 → 改成从图里算
  5. 轨迹是不是一条直线 → 切段 + 抖动 + 停顿
  6. 判定是不是 sleep → 改轮询

六条都对上,滑块基本就动了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐