验证码对抗(三):拟人鼠标轨迹——贝塞尔曲线 + 抖动、变速与停顿
算出坐标只是一半,点得太直太快会被拦。用贝塞尔+弧长参数化生成起步慢中段快的拟人轨迹,并给出按服务端多维度(时序/速度/形状/终点)自检的方法,全程可跑。
前两篇解决了"点哪里""转多少度",这篇解决最后一步:把动作做得像人。你算出了坐标,如果一条直线瞬移过去、还精确命中像素中心,风控一眼就看出不是人。这篇写成从零跑通的教程——完整脚本自己生成一条拟人轨迹并打印出来,只依赖 Python 标准库,pip 都不用装。
机器和人的差别在哪
先想清楚风控在看什么。真人用鼠标点东西有这些特征,缺一个都显假:
- 路径是弯的,不是两点一条直线;
- 速度会变,起步加速、临近目标减速,不是匀速;
- 有轻微抖动,手走不出数学上完美的曲线;
- 点击前有停顿,眼睛找目标、手要反应,有零点几秒;
- 落点有偏差,不会每次精准命中中心那个像素;
机器默认全反着来:直线、匀速、零抖动、零停顿、像素级精准。拟人轨迹要做的,就是把上面这些"不完美"一条条加回去。
第一步:环境
只用标准库,什么都不用装(有 Python 3 即可)。如果你想把轨迹画成图看,再装 pip install matplotlib(可选)。
第二步:完整代码,直接复制
新建 demo_track.py,整段贴进去:
# demo_track.py 只用标准库,无需安装任何东西
import random, math
# 三次贝塞尔曲线:由起点 p0、两个控制点 p1/p2、终点 p3 定义,t 从 0 到 1
def cubic_bezier(p0, p1, p2, p3, t):
x = ((1-t)**3*p0[0] + 3*(1-t)**2*t*p1[0] + 3*(1-t)*t**2*p2[0] + t**3*p3[0])
y = ((1-t)**3*p0[1] + 3*(1-t)**2*t*p1[1] + 3*(1-t)*t**2*p2[1] + t**3*p3[1])
return x, y
# 在起点终点连线附近,随机偏出两个控制点,让路径自然弯曲
def control_points(start, end):
dist = math.dist(start, end)
off = random.uniform(0.1, 0.4) * dist # 偏移量和距离成正比
def jp(a, b, ratio):
mx = a[0] + (b[0]-a[0])*ratio; my = a[1] + (b[1]-a[1])*ratio
return (mx + random.uniform(-off, off), my + random.uniform(-off, off))
return jp(start, end, 0.3), jp(start, end, 0.6)
# 生成从 start 到 end 的一段移动轨迹
def move(start, end):
p1, p2 = control_points(start, end)
dist = math.dist(start, end)
duration = max(100, min(int(dist / random.uniform(0.4, 1.0)), 2000)) # 总时长(ms)
# 弧长参数化:先密集采样贝塞尔,建一张“已走路程 → 曲线位置”的表
dense = [cubic_bezier(start, p1, p2, end, k/200) for k in range(201)]
cum = [0.0]
for k in range(1, 201):
cum.append(cum[-1] + math.dist(dense[k], dense[k-1]))
total = cum[-1] or 1.0
def point_at(frac): # 在“走完全程 frac 比例”处取点
target = frac * total; k = 0
while k < 200 and cum[k] < target: k += 1
return dense[k]
n = random.randint(10, 16)
pts = []
for i in range(n):
raw = i / (n - 1)
e = raw * raw * (3 - 2*raw) # smoothstep:让“路程”两头慢中间快
x, y = point_at(e) # 按路程比例取点(不是按贝塞尔参数 t)
if 0.1 < raw < 0.9: # 只在中段加抖动,首尾不抖
x += random.uniform(-0.6, 0.6); y += random.uniform(-0.6, 0.6)
pts.append([round(x, 2), round(y, 2), int(duration * raw)]) # 时间线性推进
return pts
# 把“移动→停顿→点击”按目标顺序串起来
def build(start, targets):
events, cur = [], start
for tgt in targets:
events.append(("move", move(cur, tgt)))
cx = tgt[0] + random.randint(-2, 2) # 落点在中心附近偏 ±2 像素
cy = tgt[1] + random.randint(-2, 2)
events.append(("click", [cx, cy, random.randint(200, 800)])) # 点前思考 0.2~0.8s
cur = (cx, cy)
return events
if __name__ == "__main__":
path = build((10, 230), [(60, 120), (180, 60), (300, 150)])
for kind, data in path:
if kind == "move":
print(f"移动:{len(data)} 个点,从 {data[0][:2]} 到 {data[-1][:2]},耗时 {data[-1][2]}ms")
print(f" 前 3 个点 [x,y,t]: {data[:3]}")
else:
print(f"点击:落点 {data[:2]},点击前思考 {data[2]}ms")
第三步:运行,看结果
python demo_track.py
输出类似这样(每次略有不同,这正是重点):
移动:10 个点,从 [10.0, 230.0] 到 [60.0, 120.0],耗时 159ms
前 3 个点 [x,y,t]: [[10.0, 230.0, 0], [6.61, 226.58, 17], [2.71, 215.23, 35]]
点击:落点 [59, 120],点击前思考 449ms
移动:16 个点,从 [59.0, 120.0] 到 [180.0, 60.0],耗时 211ms
前 3 个点 [x,y,t]: [[59.0, 120.0, 0], [60.78, 120.15, 14], [65.44, 120.22, 28]]
点击:落点 [182, 59],点击前思考 360ms
...
每个 [x, y, t] 就是某个时刻鼠标在哪,这个序列基本就是各类验证码提交时要的"轨迹"字段的样子。把它画出来是这样,路径带弧度、不是直线:

每一块在干嘛
- 贝塞尔曲线让路径变弯:三次贝塞尔由 4 个点定义,起点终点固定,两个控制点放在连线偏一点的地方,曲线就被"拉"出弧度。控制点偏移量按距离的 10%~40% 取——近的基本直着过去,远的甩个弧线,符合人的习惯。
- 让速度真正变化,关键在"按路程缓动、时间线性":直觉上以为把参数走一下 smoothstep 就行,但有个坑——如果位置和时间用同一条缓动曲线,两边一约分,速度反而变回匀速;而且贝塞尔的参数
t和"实际走过的路程"并不成正比,同样的一步t,曲线不同位置走的距离不一样。所以正确做法是先把曲线密集采样、建一张"已走路程→位置"的表(弧长参数化),再让路程按 smoothstep 推进、时间线性推进。这样速度才会真正起步慢、中段快、临近慢:

- 抖动只加中段(
0.1 < raw < 0.9):首尾不抖,保证确实从起点出发、准确到达终点,只在途中有点毛刺。 - 思考时间:两个目标之间停 0.2~0.8 秒,模拟眼睛找、手反应。
- 落点偏差:
randint(-2, 2),不每次正中心。
生成完别急着提交:按服务端会看的几维自检
把滑块拖到位、把字点完,只是及格线。你提交的是整条轨迹(每个点的 x, y, t),服务端会从几个互相独立的维度分别判断它像不像人手——任何一维露馅就判失败,哪怕终点分毫不差。所以生成完轨迹,最好自己先按这几维量一遍:
- 时序:总耗时别太短(几十毫秒=瞬移),也别太规整(时间戳等间隔=脚本);
- 速度:别匀速,要起步慢、中段快、临近慢;
- 形状:该弯的要弯(拖动类不能是数学直线),但要带抖动,别是完美曲线;
- 终点:确实到位——这是最基本一维,但只有它远远不够。
下面这个自检函数只用标准库,接着上面的 move() 用:
import math
def check_move(seg, start, end):
xs=[p[0] for p in seg]; ys=[p[1] for p in seg]; ts=[p[2] for p in seg]
end_err = math.dist((xs[-1], ys[-1]), end) # ① 终点误差
mono = all(ts[i] < ts[i+1] for i in range(len(ts)-1)) # ② 时间戳递增
v=[math.dist((xs[i],ys[i]),(xs[i-1],ys[i-1]))/max(1,ts[i]-ts[i-1])
for i in range(1,len(seg))]
v0=sum(v[:2])/2; vm=sum(v[len(v)//2-1:len(v)//2+1])/2; ve=sum(v[-2:])/2 # ③ 分段速度
ax,ay=start; bx,by=end; L=math.dist(start,end) or 1 # ④ 离直线最大偏移
dev=max(abs((bx-ax)*(ay-y)-(ax-x)*(by-ay))/L for x,y in zip(xs,ys))
print(f"① 终点误差 : {end_err:.2f} px")
print(f"② 时间递增 : {mono} 总耗时 {ts[-1]}ms")
print(f"③ 速度 : 起步 {v0:.2f} / 中段 {vm:.2f} / 结尾 {ve:.2f} px/ms")
print(f"④ 非直线 : 最大偏离 {dev:.1f}px(>0 才不是机器直线)")
seg = move((10, 230), (300, 150))
check_move(seg, (10, 230), (300, 150))
跑出来是这样:
① 终点误差 : 0.00 px
② 时间递增 : True 总耗时 485ms
③ 速度 : 起步 0.35 / 中段 0.91 / 结尾 0.34 px/ms
④ 非直线 : 最大偏离 3.3px(>0 才不是机器直线)
四项都对:终点无误差、时间几百毫秒且递增、速度中段(0.91)明显比两头(0.35 / 0.34)快、轨迹离直线最大偏 3.3 像素(是弯的,不是机器直线)。反过来,如果③打出来三段几乎相等(匀速)、或④是 0(完美直线),就是典型脚本特征,八成过不了。旋转、缺口、弧度滑块这些拖动类验证码,提交前都能这样自检一遍。
想更稳,再加几刀
思路都一样——往"不完美"的方向加随机:
- 第一段移动更快更飘:人一开始在"找"目标,路径不那么直;找到后的后续移动更直接。第一段的时长和弯曲度给大一点的随机范围。
- 距离远时加 overshoot:先冲过目标一点点再拉回来,模拟"手刹不住"。
- 时间戳别太规整:每个点的间隔也加点随机,别是整齐的等差数列。
- 整体时长别固定:同一个验证码连做两次,总耗时应有明显差异。
核心一句:风控找的是"太完美"的破绽,你要做的是有分寸地把不完美加回去。 直线改曲线、匀速改缓动、精准改偏差、瞬间改停顿——这四样加齐,轨迹就基本过关了。
到这里,识别(点哪/转多少)+ 拟人(怎么动)+ 自检(这几维都像人)三块都齐了,一个完整的验证码自动化就能跑通。
评论 0