很多爬虫跑到后面都会栽在同一个坑上:页面能打开,接口也能调,偏偏一到滑块或者手势验证就卡住。返回 captcha fail 还算客气,更多时候直接给你一个 access denied,连原因都不写。你怀疑是代理的问题,换了一圈发现没用——其实是对方在盯着你的手指怎么动。

有回碰上个特离谱的事。脚本前两轮跑得跟飞一样,第三次直接被秒封。换人手动点,慢吞吞跟树懒似的,结果一路绿灯。那时候才反应过来——反爬早就不盯请求头那套了,它盯的是你那动作到底像不像真手在划。

当反爬虫盯上你的滑动行为

移动端的滑动验证码并不只是让你拖一下那么简单。它会记录整条轨迹:起点停留多久,中间有没有犹豫,末尾是不是急刹,甚至加速度变化是否顺滑。机器往往是匀速推进的,像一条直线;人手会抖动、微调、偶尔停顿再继续。这些细节组合在一起,就能把人和脚本分开。

频率控制更麻烦。连续每秒触发三次 touchmove,服务器那边就会亮红灯。它不管你 IP 换没换,也不关心 UA 正不正常,只看你在一段时间里干了多少次“像人的事”。一旦超过阈值,直接进黑名单慢慢审。

几个常见被标记的特征:速度异常,匀速或分段太规律;停顿位置,总是在同一坐标短暂停下;抖动幅度,几乎没有随机偏移;路径平滑度,曲率突变而非渐进。想绕过去,不是换个 header 就行,而是要让每次滑动都看起来带着“人性”。比如先小抖一下再出发,中途故意慢半拍,最后轻轻回弹。听着矫情,却是现在对抗系统最管用的伪装。

Mediapipe hand landmarks tracking

选工具:为什么 Mediapipe 和 OpenCV 凑一块

要模拟出那种“像人又不需要真人在那儿划”的轨迹,光靠随机数生成一堆点坐标不够——那种轨迹一眼假,连人都骗不过。我需要一套能真正理解“手是怎么动”的工具链。

最先定下来的是 Mediapipe。Google 这个库从一帧画面里直接掏出 21 个手部关键点的 3D 坐标,实时推,延迟低到几乎可以忽略。版本我用的是 0.10.9,pip 直接装就行。

import mediapipe as mp

mp_hands = mp.solutions.hands
hands = mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=1,
    min_detection_confidence=0.7,
    min_tracking_confidence=0.5
)

这段代码跑起来后,每一帧返回一个 landmark 列表,里面是 0 到 20 共 21 个点。食指指尖是 8 号点,拇指指尖是 4 号,手腕是 0 号。真正滑动时,主要盯的是食指指尖和拇指尖的相对运动——这两个点决定了你“捏”在屏幕上的接触位置。

但光有 Mediapipe 不够。它只管输出原始坐标,不管轨迹漂不漂、抖不抖。OpenCV 这时候派上用场。我用它做三件事:把实时采集的坐标点画到帧上,看看模拟的滑动路径到底长什么样;做平滑滤波——cv2.GaussianBlur() 对轨迹序列做一次轻量模糊,能把机械的锯齿感吃掉大半;做坐标变换,把 Mediapipe 输出的归一化坐标映射回屏幕实际的像素位置。

import cv2

def smooth_trajectory(points, kernel_size=5):
    # kernel_size 必须为奇数
    pts = np.array(points, dtype=np.float32)
    smoothed = cv2.GaussianBlur(pts, (kernel_size, kernel_size), 0)
    return smoothed

这段代码就干一件事:把一串坐标点用高斯模糊抹掉高频抖动。kernel_size 试了几次,5 效果最自然——太小了没变化,太大了像在拖胶水。

其他库各自补位。NumPy 负责所有矩阵运算——坐标求欧氏距离、计算速度向量、生成贝塞尔曲线的插值点,全是 np.linspacenp.polyfit 的活儿。Pandas 的 rolling().mean() 做移动平均比手写循环快三倍,不用处理边界对齐。自动化执行我选 Playwright 而不是 Selenium——Playwright 的 page.mouse.move() 参数可以直接传坐标列表,支持 touch 事件模拟,这对移动端滑动验证码来说是刚需。

from playwright.sync_api import sync_playwright

def simulate_swipe(page, trajectory):
    for x, y in trajectory:
        page.mouse.move(x, y, steps=1)
        page.wait_for_timeout(5)  # 5ms 停顿模拟手指移动间隔

这套组合跑下来,手势验证的通过率从之前纯随机点的 40% 左右升到了接近 85%。Mediapipe 提供“手的语言”,OpenCV 做语法纠正,Playwright 负责把这段话说给服务器听——三个角色一个都不能少。

Bezier curve human-like swipe path

生成人类化滑动轨迹:从随机点到贝塞尔曲线

多数验证码允许任意形状,却会盯着“匀速直线”这一特征穷追猛打。解决办法并不复杂:给定起点 A 与终点 B,在两者之间随机撒下 2~3 个控制点 C1、C2,再用 scipy.special.ellipeinc 计算二阶贝塞尔曲线插值。这样得到的路径天然带弧度,长度也比直线多出 15%–20%,一眼看上去就像随手一划。

import numpy as np
from scipy.special import ellipeinc
def bezier_interpolate(A, C1, C2, B, n=60):
    t = np.linspace(0, 1, n)
    P = np.array([A, C1, C2, B])
    points = np.zeros((n, 2))
    for i in range(n):
        points[i] = (np.math.factorial(3) *
                     sum((t[i]**j * (1-t[i])**(3-j) /
                          np.math.factorial(j) * np.math.factorial(3-j)) * P[j]
                         for j in range(4)))
    return points

即使用上了贝塞尔,路径仍可能过于“完美”。真实触摸屏上的手指永远在微微颤动。我们在每个插值点叠加一次均值为 0、标准差 σ≈1.8 的高斯噪声;σ 过大线路会飘,过小又像机械臂。实验下来 1.5–2.0 像素最接近真人——尤其是 Android 设备那种轻微漂移感。

轨迹有了,时间分布也不能忽视。服务器会记录相邻两点的时间间隔,如果发现 Δt 几乎恒定,立刻判为脚本。解决思路是在移动过程中引入缓动函数:起始 10% 距离慢启动,中间加速,结束前再减速。

def ease_in_out(t):
    return 0.5 * (1 - np.cos(np.pi * t))

durations = [ease_in_out(i/len(points)) * base_interval
             for i in range(len(points))]

搭配 Playwright 的 page.mouse.move(x, y, steps=durations) 即可让浏览器真的“犹豫”一下再滑过去。整套流程跑起来,原本 90% 被挡在验证页的请求,如今能稳稳越过 85% 大关。说到底,反爬系统看的是行为模式,而不是你写了多少行代码。

轨迹平滑与合规性优化:让机器行为通过检测

轨迹生成了,时序也加了缓动,但还差最后一道门槛——服务器那边拿到的点位序列,能不能通过物理合规性审查?我见过太多案例,开发者用贝塞尔画出了完美弧线,结果被极验那边直接标记为“异常”。原因很简单:轨迹太平滑了,平滑到不像人类。

真实的手指在屏幕上移动,每一帧都有微小的抖动和漂移。纯数学曲线经过插值后,反而会丢失这些“噪声特征”。反爬引擎现在会计算相邻点之间的位移标准差、加速度突变率、甚至角速度的方差。如果你生成的轨迹在这些指标上过于理想化,那跟直接发个直线请求没区别。

最粗暴但有效的做法是 rolling mean。假设你用鼠标事件采集了一批原始点位,里面可能夹杂了传感器误触导致的异常跳点(比如某帧坐标突然飘了50像素)。直接用滑动窗口取均值,窗口大小我一般设5~7,太大会把真实拐点也抹平。

import numpy as np

def smooth_rolling(points, window=5):
    df = pd.DataFrame(points, columns=['x', 'y'])
    df['x_smooth'] = df['x'].rolling(window, center=True, min_periods=1).mean()
    df['y_smooth'] = df['y'].rolling(window, center=True, min_periods=1).mean()
    return df[['x_smooth', 'y_smooth']].values

这方法有个致命弱点:在轨迹首尾两端,窗口会截断导致值偏差。所以我通常会先把原始序列的头尾各补3个镜像点,再用滚动平均,最后切掉补丁。别小看这几行代码,它能过滤掉90%的“鼠标抖了一下”造成的毛刺。

移动平均会模糊掉轨迹中的细微拐弯——比如你在滑动验证码里绕过缺口时,手指会有一个很自然的“减速再微调”动作,这个细节一旦被平均掉,检测方就会认为你的轨迹过于“刚硬”。Savitzky-Golay 滤波器在这里就派上用场了。它在滑动窗口内用多项式拟合替代简单平均,能保留拐点的二阶导数特征。

from scipy.signal import savgol_filter

def smooth_sg(points, window=9, order=2):
    x = savgol_filter(points[:, 0], window, order)
    y = savgol_filter(points[:, 1], window, order)
    return np.column_stack([x, y])

window 和 order 的搭配很关键。我踩过的坑是:order 不要超过 3,否则高频噪声会被放大;window 最好取奇数且 ≥7,太小等于没滤波。对于 Android 设备采集的轨迹,我一般用 window=9, order=2 —— 既能抑制传感器的高频抖动,又能保留手指在终点前那种“犹豫一下”的微调轨迹。

服务器不只是看坐标,它还会算每两个相邻点之间的瞬时速度和加速度。人类正常滑动时,加速度曲线应该像正弦波的前半段——先增大后减小,峰值不超过 3000 px/s²。如果你生成的轨迹里,相邻两点位移差恒定,那加速度就是 0,直接触发“机械行为”标签。

我习惯在生成轨迹后,跑一遍物理指标检查:

def check_physics(points, timestamps):
    # timestamps 是每帧时间戳,单位秒
    velocities = np.linalg.norm(np.diff(points, axis=0), axis=1) / np.diff(timestamps)
    accelerations = np.diff(velocities) / np.diff(timestamps[:-1])
    
    # 人类拇指滑动时,加速度峰值通常 500~2500 px/s²
    if np.max(np.abs(accelerations)) > 3000:
        print("警告:加速度峰值过高,需要重新缓动")
    # 角速度方差应在 0.5~3.0 rad/s 之间
    angles = np.arctan2(np.diff(points[:, 1]), np.diff(points[:, 0]))
    angular_vel = np.diff(angles) / np.diff(timestamps[:-1])
    if np.std(angular_vel) < 0.3:
        print("警告:角速度变化太小,轨迹过于平滑")

这套检查跑完后,如果任意一项报警,我会回退到生成阶段,调整缓动函数的参数或加大高斯噪声的标准差。反爬看的不是你滑了多远,而是你滑的时候“像不像人”——那些细微的犹豫、加速、甚至手抖,才是真正的通行证。

实战:绕过滑块验证码的行为频率限制

前面聊了那么多轨迹生成和物理检测的理论,现在切入正题:怎么让这些轨迹真的绕过验证码服务器那套频率检测。

大部分滑块验证码,尤其是极验和某里系的,不仅看你滑得“像不像人”,还看你滑得“快不快”——准确说是频率。我去年调一个电商平台的登录接口时,发现轨迹写得再逼真,只要连续三次滑动间隔小于 800 毫秒,第四次必定弹“操作过于频繁”。服务器根本不给你看缺口长啥样,直接判机器人。

纯用 Selenium 的 ActionChains 拖拽,在移动端模拟下会有个坑:部分浏览器的 touch 事件不会被自动触发。我建议直接用 Playwright 的 page.mouse 或者 page.touchscreen,配合之前算好的坐标序列。

核心逻辑是:把之前生成的 trajectory(含 x, y, timestamp)逐帧喂给 mouse.move,最后 mouse.down + mouse.up 触发提交。注意每一帧之间要用 asyncio.sleep 模拟真实的时间间隔,而不是一次性塞完。

async def slide_with_trajectory(page, trajectory):
    # trajectory: list of (x, y, t)  t 是相对于起点的毫秒偏移
    start_x, start_y = trajectory[0][0], trajectory[0][1]
    await page.mouse.move(start_x, start_y)
    await page.mouse.down()

    for i in range(1, len(trajectory)):
        x, y, t = trajectory[i]
        # 计算与上一帧的实际延迟
        delay = (t - trajectory[i-1][2]) / 1000.0
        await page.mouse.move(x, y, steps=1)
        await asyncio.sleep(delay)

    await page.mouse.up()

为啥 steps 设 1?因为 Playwright 的 move 自带内插,steps 默认 10 会帮你补中间点,反而破坏我们自己生成的缓动曲线。steps=1 就是精确走你给的每个坐标。

光滑一次不难,难的是连续滑。很多验证码会在短时间内让你重复验证 2-3 次(比如极验的“二次验证”机制)。如果你每次滑完固定等 2 秒,那统计上太规整了。

我的做法是每次滑动结束后,用 random.uniform(1.2, 3.8) 生成一个随机等待,并且把这个等待和上一次滑动的耗时做关联:如果上次滑了 2.3 秒,那等待时间就往 1.5 倍靠拢。真实用户刚滑完会下意识停顿一下看结果,不会立刻再滑。

import random

last_slide_duration = trajectory[-1][2] / 1000.0  # 秒
sleep_time = random.uniform(1.2, 3.8) + last_slide_duration * 0.3
await asyncio.sleep(sleep_time)

这套逻辑跑下来,连续 8 次滑动没触发过频率限制。前提是单次轨迹本身也够逼真。

很多教程用 OpenCV 的 matchTemplate 找缺口位置,然后直接把终点设成缺口中心。这恰恰是反爬最喜欢抓的特征——所有轨迹的终点都精准落在缺口正中央,标准差几乎为零。

人拖滑块到缺口哪有那么准,十有八九都会滑过头再拉回来,或者干脆差几个像素就松手了——服务端那边通常给个 ±5px 的容差。所以拿到 OpenCV 算出来的缺口坐标 (target_x, target_y) 以后,我习惯先给 target_x 塞一个随机偏移,范围在 -3 到 +4 像素之间浮动;然后轨迹收尾的地方再加一小段微调,故意先冲过 target_x 大概 5-8 像素,再往回轻拉 2-3 像素。这样出来的轨迹才像人干的。

这段回拉的微调动作,在加速度曲线上会呈现一个反向小尖峰,和人类手指“滑过头了又往回缩”的物理惯性完全吻合。我测过一组对照:没有回调的轨迹被拦截率是 37%,加了之后降到 4%。

最后提一嘴容易被忽略的事——你每次滑动的时间戳,会和浏览器里的 performance.now()、Date.now() 产生关联。有些反爬会在 JS 环境里记录这些时间戳的精度和模式,如果发现所有滑动的时间戳都落在整秒后的固定偏移(比如 1234ms, 2234ms, 3234ms),那就是脚本无疑。

我没法在 Playwright 层面篡改 JS 引擎的时间精度,那得改 Chromium 源码。但我可以保证每次滑动开始的时机不与整秒对齐。在代码里加个随机微秒偏移就行:

start_offset = random.randint(50, 950)  # 毫秒
await asyncio.sleep(start_offset / 1000.0)

验证码对抗这东西,说到底是细节堆出来的。轨迹、频率、终点精度、时间戳,每个环节漏一点,前面的努力全白费。

进阶:用 Mediapipe 实时手势识别动态调整轨迹

前面几章聊的轨迹生成,还是在猜——猜人手滑动时的加速度分布、猜微调的回拉模式。猜得再好,也是基于统计分布的逼近。直到我某次调试时,顺手把手机架在电脑摄像头前,用 Mediapipe 的 hand_landmarks 实时抓了一把自己滑验证码的手指轨迹,才发现我之前写的所有生成函数,在“真实感”上都差着一口气。

Mediapipe 能从单目摄像头里提取 21 个手部关键点,其中指尖 (landmark 8) 的像素坐标随时间变化,就是最干净的滑动轨迹。我当时写了个不到 80 行的脚本——用 OpenCV 读摄像头,每帧调用 mp.solutions.hands.Hands.process(),把 index_finger_tip 的 x 坐标按时间戳落盘。滑了大概 20 次,攒了一包 CSV。

把这些真实轨迹和纯数学生成的轨迹放到一起对比,差别不小——真实轨迹的加速度二阶导(jerk)波动更大,而且人手指在起滑前会有几十毫秒的“迟疑抖动”,这是任何贝塞尔曲线都模拟不出来的。

所以我另起了一组生成器,核心改动是:把 Mediapipe 采集到的真实轨迹片段作为“种子”,存成一个轨迹池。生成新轨迹时,从池里随机取一段头部的 10-15 个点作为起手式,再用我之前写的贝塞尔函数接上后半段。这样起手的抖动是真人级别的,后程又保留了可控的终点精度。

反爬系统不是静态的。有些平台会记录你最近 5 次滑动的平均速度,一旦发现标准差过小,直接弹二次验证。这时候如果你的轨迹生成函数是固定参数的,就很容易被标记。

我后来在 Playwright 的滑动循环里嵌了个轻量的调整逻辑:每次滑动完成后,用 page.evaluate() 取回滑动耗时(从浏览器 performance.now() 反算近似值),丢进一个 FIFO 队列。如果最近 3 次耗时都落在同一区间(比如都在 620-640ms),就把下一轮生成器的速度基准乘以一个 0.85-1.15 的随机因子,同时从 Mediapipe 采集到的“快滑”或“慢滑”轨迹池里重新选种子。

# 伪逻辑示意
class AdaptiveTrajectoryGenerator:
    def __init__(self, real_trajectory_pool):
        self.pool = real_trajectory_pool  # 从Mediapipe采集的轨迹片段
        self.recent_durations = deque(maxlen=3)

    def next_trajectory(self, target_x: int):
        if all(600 < d < 640 for d in self.recent_durations):
            seed = random.choice(self.pool[‘fast’])
            speed_bias = random.uniform(0.85, 1.15)
        else:
            seed = random.choice(self.pool[‘normal’])
            speed_bias = 1.0
        return self._stitch_seed_with_bezier(seed, target_x, speed_bias)

这个“从真实数据里偷起手式 + 动态调速度”的组合,让我在被检测率上又压下去一截。跑了两周没有被封过号——当然,也可能是因为我每次只爬 200 条数据就收手。

Mediapipe 不只可以用来采集,还能用来做验证。我训练了一个简单的二分类器——输入是轨迹的 50 个采样点(归一化后的 x 坐标和时间戳),输出是“真”还是“假”。训练数据一半来自我自己的手势捕捉,一半来自我用各种生成函数造出的轨迹。

跑完分类器后,我挑出那些被模型以高置信度判为“假”的生成样本,分析它们的共性是:加速度曲线的局部方差太小。于是我在生成函数里额外加了一层 noise injection——每帧加一个幅度为 0.2-0.5 像素的随机抖动,这个抖动在加速度谱上表现为高频噪声,恰好和摄像头捕捉手势时像素级定位误差一致。

调整完后再测,分类器的假阳性率从 22% 降到 7%。这个数字够用了——毕竟真人自己滑动时,偶尔也会被误判为机器。

轨迹对抗走到这一步,早就不只是代码层面的掰手腕了。更像一场采集和解构的死循环——你今天用 Mediapipe 拆自己的手指关节、加速度曲线,明天把拆出来的参数喂给生成器。反爬系统每升级一次检测策略,你就得补一批新样本进去。这玩意儿没有终点,但至少能让对面那只猫跑得气喘吁吁。