做自动化测试、写数据采集脚本、跑RPA机器人流程的朋友,多半都遇到过这种尴尬:程序本身逻辑一点问题没有,功能全部正常,可一到目标系统那边,要么被要求多走一步身份验证,要么直接被提示“操作频繁,请稍后再试”。你心里清楚,这不是业务代码写得不对,而是你的程序在别人眼里“太像机器人了”。
问题出在哪?出在“机器人特征”上。这里说的仿真,不是Simulink、Gazebo、Wokwi那类物理或电路仿真,而是对真人的操作行为做仿真,也就是常说的拟人输入。它解决的问题很具体:当你需要让自动化程序以接近真人操作的方式完成输入、点击、滚动、切换页面时,如何让这些操作的时间节奏、轨迹形态、行为序列,在统计上更像一个人类,而不是一段for循环。这篇文章不教怎么刷量、怎么干扰平台,只聊在软件测试、合法数据采集、办公自动化这些正当场景下,怎么避免自己的自动化脚本被误判成机器人。
文章会从识别机制的原理出发,拆解时间轴、鼠标轨迹、键盘节奏、浏览器标识这几个核心维度,最后给一套可以直接拿去用的实现思路和代码样例。适合正在写UI自动化测试、爬虫采集、RPA流程的开发者参考,也适合想搞懂“为什么脚本老是被识别”的人阅读。
1. 核心问题:算法到底在识别什么
1.1 为什么自动化脚本一眼就被识破
很多写脚本的人对“被识别”有个误解,以为对方系统里藏着一个“AI大模型”,能看懂你屏幕上发生了什么。真实情况远没有这么玄乎。绝大多数检测引擎做的不是语义理解,而是特征统计。它把你的操作拆成一堆可量化的指标,再和人类操作的基线数据做对比,一旦偏离程度超过阈值,就判定为异常。
最容易被抓的其实是这类特征:
- 输入节奏过于均匀。真人打字时,相邻两次按键的时间间隔在几十毫秒到几百毫秒之间波动,而且波动本身也不规律。自动化脚本如果用
send_keys一把梭,所有字符几乎是同一次事件里瞬间到达,这跟人类的输入速率差了至少一个数量级。 - 鼠标轨迹过于笔直。真人移动鼠标时,轨迹是一条带有弧度和微小抖动的曲线,而不是从A点到B点的绝对直线。检测端只要对轨迹做运动学分析,加速度、曲率、抖动频率都是暴露点。
- 行为序列过于规律。真人操作页面会有停顿、回看、犹豫,会先滚动一下再点击,会在某个输入框里删掉重打。自动化脚本如果每次都执行完全相同的操作序列,多跑几轮就会被行为聚类算法抓出来。
注意,这里说的“识别”不是一次点击就判断,而是累积证据。单次操作可能只是打了低分,但当时间轴、轨迹、频率、指纹多个维度都指向“非人类”时,风险分自然就上去了。
1.2 人类的输入特征到底长什么样
想仿真拟人输入,第一步得知道人类的输入数据大概是什么分布。我不是让你去发论文,但至少要有最基本的数值概念。
拿键盘输入来说,一个熟练的成年用户在自由输入状态下,平均按键间隔大约在120毫秒到250毫秒之间。但这个数字远不是固定值,它受文字难度、输入法状态、用户疲劳程度影响。更重要的是,间隔的分布不是均匀分布,也不是正态分布,更接近对数正态分布——偶尔会出现一次长达一两秒的停顿(比如思考下一步打什么字),但大多数时间保持在100毫秒上下。
鼠标轨迹也很有特点。人类移动鼠标遵循一个近似Fitts定律的规律:移动时间跟目标距离和目标尺寸的对数成正比,距离越远、目标越小,移动越慢。而且人类很少一次性精确命中目标,通常会有一个“先快速接近、再微调对准”的过程,有时候还会冲过头再拉回来。
页面行为上,真人打开一个页面后,一般有几百毫秒的渲染等待期,然后才滚动或点击。滚动也不是匀速的,而是先快后慢,中间可能停一下再继续。
这些特征单独看都不明显,但组合起来就是一组“人类指纹”。拟人输入的底层逻辑,就是让自动化程序在统计层面贴近这组指纹。
1.3 识别系统的三层判定模型
我习惯把检测系统粗分为三层,理解清楚有助于你有的放矢:
第一层是单点特征检测。它看的是每一次输入事件的属性,比如isTrusted标志位、事件时间戳间隔、输入设备类型。这一层最简单,也最容易被优化的脚本骗过。
第二层是行为序列建模。它把一段时间内的操作串成序列,用隐马尔可夫模型、循环神经网络或者更简单的统计规则来分析“操作之间的关系是否符合人类习惯”。比如,人类点击按钮前通常会有几十到几百毫秒的悬停,而脚本往往是坐标一旦出现就瞬间点击。这一层已经能过滤掉绝大多数不做优化的自动化程序。
第三层是会话级画像。它结合账号历史、IP、设备指纹、操作时间段、页面停留时长等跨会话信息做综合判断。到了这一层,单次操作的拟人化已经不够了,你还需要让你的程序在“整个会话时间线”上像人一样活动。
所以你做拟人输入,不能只盯着某一处,而是要在三个维度同时下功夫。单点做得再漂亮,会话级行为是机器人节奏,照样会被揪出来。
2. 思路拆解与整体设计
2.1 拟人化不等于随机化
新手最常见的错误,是把“拟人”理解成“随机”。给输入间隔加个random.randint(100, 300),给鼠标路径加几个随机偏移点,就以为大功告成。实测下来效果很差,因为均匀随机数产生的序列在统计上和真实人类行为差得很远。人类的按键间隔是长尾分布,也就是说,大多数时候快,偶尔会有一个很长的停顿;而均匀随机数只会让操作变得“没有规律地不稳定”,这在检测模型眼里同样是异常。
正确做法是给行为参数建立概率分布模型。比如用对数正态分布生成按键间隔:
import numpy as np def human_delay(mean_ms=180, sigma_ms=0.4): # 对数正态分布,底层均值为180ms,标准差通过sigma控制 ms = np.random.lognormal(mean=np.log(mean_ms), sigma=sigma_ms) # 控制极端值,避免出现过于夸张的停顿 return max(60, min(2000, ms))这个方法的核心逻辑是:不直接产生“一个随机数”,而是产生“一个符合人类统计规律的随机数”。绝大多数情况下生成的值集中在120到250毫秒,偶尔会出现一次400到800毫秒的思考停顿,整体分布贴合真人打字特征。
同样的思路延伸到鼠标轨迹、滚动节奏、甚至两次页面操作之间的空档时间。所有这些时间参数,都应该来自分布采样,而不是拍脑袋随机。
2.2 分层仿真:从时间轴到行为指纹
我给自己定的拟人输入框架分四层,从底层到上层分别是:
第一层是时间轴层。这是最重要的一层,也是性价比最高的一层。它决定每一次操作的触发时机,包括输入间隔、点击间隔、页面切换间隔、会话内的暂停和恢复。时间轴做好了,即使轨迹和按键方式还有瑕疵,很多检测系统也不会第一时间判死。
第二层是轨迹层。负责鼠标从A点移到B点的路径形态、速度变化、微小抖动,以及滚动的加速度曲线。
第三层是事件层。关注键盘事件本身如何被发出,是模拟真实的物理按键按下和释放,还是直接填值;鼠标事件是真实的移动事件序列,还是一个click直接触发。
第四层是环境层。包括浏览器指纹、UserAgent、屏幕分辨率、时区、字体列表、Canvas渲染特征等,保证你的自动化浏览器“看起来”像一台普通用户的设备。
四层之间是递进关系。时间轴是地基,轨迹和事件是墙体,环境指纹是外墙涂料。很多人只做第二三层,忽略了第一层,结果时间规律一暴露,其他全白搭。
2.3 常用工具链怎么选
做拟人输入,绕不开浏览器自动化工具。我这些年用下来,对照如下:
| 工具 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Selenium | 生态老、资料多、兼容性广 | 事件级控制较弱,轨迹拟真实现成本高 | 简单填表自动化、老系统兼容测试 |
| Playwright | 事件模型先进,支持移动端、多标签,自带网络拦截 | 部分指纹特征仍需手动处理 | 大多数Web自动化、反检测测试 |
| pyppeteer | 基于CDP协议,轻量灵活 | 维护不太活跃,调试体验一般 | 对Chrome DevTools协议有深度定制需求的场景 |
| Puppeteer | Node生态好,控制力强 | 只支持Chromium系 | Node环境下的自动化 |
如果只是快速跑通流程,Selenium完全够用。但如果目标是做高质量拟人输入,我更推荐Playwright。它对键盘、鼠标、触摸事件都有细粒度的API,可以分步触发mouse.move、mouse.down、mouse.up,也可以在输入前先聚焦、再逐字符输入,每步之间加自定义延迟。这些都是拟人化的关键基础。
3. 核心实现细节:键盘、鼠标与浏览器指纹
3.1 键盘输入的“人类节奏”
键盘输入拟人化,核心是三件事:逐字符输入而不是整体填值、字符间隔符合分布、处理好输入法场景。
逐字符输入很容易理解。用fill方法一次性把字符串填进输入框,在检测端看就是一个瞬间事件,信息量极少,怀疑值极高。正确的做法是拿到元素焦点后,逐字press或insert_text,每个字符之间调用上面说的human_delay函数来控制节奏。
这里有个细节值得注意:不同字符的输入间隔其实有差异。比如连续输入两个相同字母,或者输入到单词边界,间隔会比普通字符略长,因为人类在打字时会有极细微的“位置确认”过程。这个差异非常微小,但叠加起来能让整个输入过程更自然。
中文场景更特殊。真人用输入法打中文时,流程是“依次敲击拼音字母->候选词渲染->选择候选词->确认上屏”,如果用的是拼音输入法,每个拼音字母之间的间隔很短,但从最后一个拼音字母到选择候选词之间,通常会有一次200到500毫秒的停顿。如果你在做中文表单填写,建议构造一个“伪输入法流程”:
async def type_chinese(page, selector, text, pinyin_groups): # pinyin_groups 是逐字的拼音字符组,比如 [["n", "i"], ["h", "a", "o"]] await page.click(selector) for group in pinyin_groups: for ch in group: await page.keyboard.type(ch) await asyncio.sleep(human_delay(mean_ms=120, sigma_ms=0.35)) # 模拟候选词渲染和选择间隔 await asyncio.sleep(human_delay(mean_ms=350, sigma_ms=0.5)) # 按下空格上屏 await page.keyboard.press("Space") await asyncio.sleep(human_delay(mean_ms=100, sigma_ms=0.3))如果遇到输入出错,也可以模拟回删重打。真人打字时,误触和回删是很常见的,出现频率大约在1%到3%之间。当然这个比例不能太高,否则又变成另一种异常特征。
3.2 鼠标轨迹:运动学与微抖动
鼠标轨迹拟人化的核心,是路径和速度都要符合人的运动特征。一条人类移动轨迹,大体可以描述为:初始有一个短暂的加速阶段,中间维持一个相对稳定的高速移动,接近目标时减速,最后会有数次像素级的微调。
业界常用贝塞尔曲线来拟合这种路径。用一个三次贝塞尔曲线,把起点、终点、两个控制点确定下来,再按时间采样,就能生成一条带弧度的移动轨迹。控制点的位置决定了曲线弯曲程度,一般取在起点和终点连线的垂直方向偏移一定像素的位置。
轨迹速度上,可以分三段处理:
- 起始段(前20%时间):速度从0开始线性增加,模拟发力过程
- 中间段(60%时间):保持一个较快速度,但叠加幅度约2-4像素的随机抖动
- 终止段(最后20%时间):速度线性下降,并且追加微调,模拟定位
我之前试过最简单可用的方案,是每10到20毫秒取一个路径点,用CDP的Input.dispatchMouseEvent把每个点都发出去。Playwright里也可以通过多次调用mouse.move实现。需要注意,轨迹总时长不要一味追求均匀,距离越远,允许的总时长越长,这符合Fitts定律。
一个能直接在Playwright中用的贝塞尔采样函数大概长这样:
import numpy as np def bezier_points(start, end, control1, control2, steps=50): t = np.linspace(0, 1, steps) # 三次贝塞尔公式 x = (1 - t)**3 * start[0] + 3 * (1 - t)**2 * t * control1[0] + 3 * (1 - t) * t**2 * control2[0] + t**3 * end[0] y = (1 - t)**3 * start[1] + 3 * (1 - t)**2 * t * control1[1] + 3 * (1 - t) * t**2 * control2[1] + t**3 * end[1] return list(zip(x.tolist(), y.tolist()))生成点之后,再为每个点加上2到3像素的微小抖动,接近目标时抖动幅度变小,模拟“精确瞄准”的肌肉控制。最后一步点击,不要路径走完立刻click,建议在终点悬停300到600毫秒再按下,这个悬停时间是真人执行确认动作的关键特征。
3.3 浏览器与网络层的指纹一致性
行为仿真做得再真,如果浏览器指纹跟真人设备差太多,照样会被一眼识破。浏览器指纹检测的东西很杂:navigator.userAgent、navigator.language、navigator.platform、屏幕分辨率、时区、字体列表、WebGL渲染器信息、Canvas指纹、音频上下文指纹,还有window.chrome对象里的一些属性。
Playwright 和 Puppeteer 启动的浏览器,通常会在window.navigator上暴露出webdriver属性,这是最经典的自动化标识。处理办法是在页面加载前注入脚本把相关属性覆盖掉。这个问题在网上有很多讨论,做法也相对成熟。
另外,网络层面也有指纹。TLS握手的指纹(比如JA3/JA4)在服务端很容易辨认出你是不是常见浏览器发出的请求。Python生态里有一个做法是用curl_cffi来模拟浏览器的TLS指纹,在纯HTTP请求场景下,它比requests要难识别得多。但如果是走浏览器自动化,这种问题一般由浏览器内核自己处理,改动空间不大。
环境层的核心原则是“全局一致”。比如你设置了UserAgent为Windows Chrome,那么navigator.platform、屏幕尺寸、时区、语言列表都应该匹配这个设定,不要出现UA写着Mac、实际字体列表里却全是Windows字体这种自相矛盾的情况。
我通常会在启动上下文时固定一组参数:视口尺寸用1920x1080或1440x900这类常见分辨率,设备缩放比设为1,语言只用zh-CN,时区固定为Asia/Shanghai,取消自动化提示条。这套组合能覆盖绝大多数常规检测。
4. 实操案例:基于Playwright的拟人输入组件
4.1 环境准备与项目结构
下面这套组件我称它为humanizer,不依赖第三方复杂库,只需要Playwright和NumPy。安装命令:
pip install playwright numpy playwright install chromium项目结构很简单:
humanizer/ __init__.py timing.py # 时间轴生成器 mouse.py # 鼠标轨迹生成 actions.py # 键盘/鼠标动作封装 demo.py # 完整示例timing.py负责产出所有延迟;mouse.py负责产出轨迹点;actions.py把两者组合成可直接调用的动作函数;demo.py是演示脚本。
4.2 模拟人类时间轴的核心代码
时间轴生成器是全组件的地基。我一般提供三个函数:一个产生产键间隔,一个产生点击前悬停时间,一个产生页面切换时的空档时间。
# timing.py import numpy as np def key_delay(mean_ms=160, sigma=0.4): """按键间隔:对数正态分布,偶尔出现思考停顿""" ms = np.random.lognormal(mean=np.log(mean_ms), sigma=sigma) return max(50, min(1800, ms)) def hover_delay_before_click(mean_ms=350, sigma=0.3): """点击前悬停时间:正偏态分布""" ms = np.random.lognormal(mean=np.log(mean_ms), sigma=sigma) return max(120, min(1200, ms)) def page_switch_delay(mean_ms=1200, sigma=0.5): """页面操作之间的空档:通常较长,偶尔会有一个更长的停顿""" ms = np.random.lognormal(mean=np.log(mean_ms), sigma=sigma) return max(300, min(6000, ms))这里所有函数都在同一个逻辑下工作:用对数正态分布采样,然后裁剪到合理区间。裁剪不是为了让分布更漂亮,而是防止极端值让流程超时。
实际使用中,不要每调一次就重新np.random.seed,保持全局随机状态即可。否则每次生成的值都会按固定序列重复,在检测端看起来就像同一个人的行为模板在反复回放。
4.3 生成贝塞尔鼠标轨迹并执行输入
鼠标模块包了一层轨迹生成和动作执行。bezier_points生成轨迹后,我通常还会再做一次“轨迹清洗”:把相邻两个点的欧氏距离小于0.5像素的点合并掉,避免发出无意义的微小移动事件。事件数量过多本身也是一种异常。
# mouse.py import numpy as np def bezier_points(start, end, control1_offset=(80, -40), control2_offset=(-40, 80), steps=45): sx, sy = start ex, ey = end c1x, c1y = sx + control1_offset[0], sy + control1_offset[1] c2x, c2y = ex + control2_offset[0], ey + control2_offset[1] t = np.linspace(0, 1, steps) x = (1 - t)**3 * sx + 3 * (1 - t)**2 * t * c1x + 3 * (1 - t) * t**2 * c2x + t**3 * ex y = (1 - t)**3 * sy + 3 * (1 - t)**2 * t * c1y + 3 * (1 - t) * t**2 * c2y + t**3 * ey # 加抖动,接近终点时抖动幅度减小 jitter = np.linspace(3.0, 0.8, steps) x += np.random.normal(0, jitter, steps) y += np.random.normal(0, jitter, steps) # 去重,合并过近的点 points = [] last = None for px, py in zip(x, y): if last is None or np.hypot(px - last[0], py - last[1]) > 0.5: points.append((px, py)) last = (px, py) return points控制点偏移量不是固定的。我测试下来,(80, -40)和(-40, 80)这种组合产生的弧度比较接近人类的“先向右偏再向左回”的习惯。你也可以随机化偏移量,方向、大小都可以变动,这样每次移动的曲率不完全一样,更自然。
4.4 完整调用示例与效果验证
把这两个模块接起来,大概就是下面这个样子:
# actions.py import asyncio import numpy as np from .mouse import bezier_points from .timing import key_delay, hover_delay_before_click async def move_mouse_humanized(page, x, y): start = page.mouse.position if start is None: start = (0, 0) points = bezier_points(start, (x, y)) for px, py in points: await page.mouse.move(px, py) # 每步间隔10-20ms,模拟事件刷新频率 await asyncio.sleep(np.random.uniform(0.008, 0.018)) await asyncio.sleep(hover_delay_before_click() / 1000) async def click_humanized(page, x, y): await move_mouse_humanized(page, x, y) await page.mouse.down() await asyncio.sleep(np.random.uniform(0.06, 0.12)) await page.mouse.up() async def type_humanized(page, selector, text): await page.click(selector) await asyncio.sleep(np.random.uniform(0.1, 0.3)) for ch in text: await page.keyboard.type(ch) await asyncio.sleep(key_delay() / 1000)在演示脚本里,可以打开一个测试页面,输入一段文字、点击几个按钮,观察控制台事件的时间戳分布是否自然:
# demo.py import asyncio from playwright.async_api import async_playwright from actions import type_humanized, click_humanized async def main(): async with async_playwright() as p: browser = await p.chromium.launch(headless=False) context = await browser.new_context( viewport={"width": 1920, "height": 1080}, locale="zh-CN", timezone_id="Asia/Shanghai" ) page = await context.new_page() await page.goto("https://example.com") await type_humanized(page, "#username", "test_user") await click_humanized(page, 200, 300) asyncio.run(main())验证效果时,我会重点看三件事:按键间隔的直方图是不是长尾分布、鼠标轨迹是不是有弧度且末端有微调、整段操作有没有出现超过1.5秒的卡死。前两个好理解,第三个是常见翻车点——不管多拟人的逻辑,只要有几次超长卡顿,会话级画像就会得到一个“异常拖延”的标签。
5. 常见问题与排查实录
5.1 为什么模拟完还是会被识别
加了一堆拟人逻辑,结果还是被识别,这是我收到最多的反馈。排查思路有优先级:先看时间轴,再看轨迹,最后看指纹。
时间轴是最容易出问题的地方。很多人只在字符串填值时加了间隔,忽略了点击、滚动、页面切换之间的空档。实际检测模型里,长时间线上的“行为密度”才是关键。真人不会连续十分钟每秒都在操作,中间必然有停歇。如果你脚本跑起来像一台永动机,节奏再拟人也没用。
另外检查一下是不是把每一步的延迟都设置成了固定数值。比如点击前悬停统一写死为350毫秒,这个值看起来没问题,但所有操作都是同一个值,聚类算法一跑就能发现这是“模板”。
5.2 逻辑正确却被风控,先查这几个点
如果确认行为逻辑正常,还是被风控,我建议按清单快速自查:
| 排查点 | 常见问题 | 处理建议 |
|---|---|---|
| 浏览器指纹 | webdriver属性未处理,或UA与系统不匹配 | 页面加载前注入脚本清理自动化标记,统一UA与系统参数 |
| 网络层指纹 | 直接用requests库发请求,TLS指纹与浏览器不一致 | 高价值采集场景换用curl_cffi模拟浏览器TLS特征 |
| 会话画像 | 新号/新IP直接执行敏感操作,缺少“养号”过程 | 先让账号经历几天的正常浏览、搜索、退出流程 |
| 请求频率 | 请求间隔均匀且过短 | 用对数正态分布控制间隔,拉长均值,允许长尾 |
| IP信誉 | 机房IP或共享出口IP本身就是高风险 | 使用住宅代理,但这需要严格评估使用是否合规 |
这里单独说一下IP。有些朋友用了拟人输入、指纹也处理好了,账号还是被限制,最后查下来是IP段本身信誉太差。很多平台会把IDC机房地址段直接标记为高风险,你在那上面干什么都容易被误伤。正规的做法是用住宅网络的出口,或者把任务的执行窗口尽量拉长,降低单时间窗口内的请求密度。
5.3 做拟人输入前必须划清的三条边界
这个方向有一个绕不开的合规问题,我得说透。
拟人输入本身是中性技术,它既可以用于正当的软件测试、数据采集、无障碍辅助,也可以被用来做刷单、恶意灌水、规避内容审核等违规操作。我的态度很明确:这篇文章里所有代码的用途边界,是你的自有系统、你获得明确授权的系统、或者法律法规允许采集的公开数据。如果目标是绕过平台规则去刷量、刷评论、批量注册、干扰他人服务,那这不是技术问题,是原则问题。被识别和追责只是早晚的事。
第二条边界是,不要把拟人输入当成攻击手段。绕过一个验证码或者绕过一次频率限制,不代表你可以无限制地对目标系统施压。任何流量冲击和异常访问都可能给对方造成实际损失,这个后果不是一个“技术爱好者”身份能兜住的。
第三条边界是,检测与反检测始终在动态博弈。你今天写的拟人逻辑,明天可能就会被一个新的特征模型识别。不要指望一套代码永久有效,更不要为了追求“完全不被检测”而陷入无底洞。做测试和采集时,把目标设定为“降低对目标系统的干扰”,而不是“碾压对方的检测系统”。
用一套合理的拟人输入组件,最大的收益不是“逃过识别”,而是让自动化程序以更低的冲突完成该做的事——比如测试跑得更稳定、采集任务不被打断、RPA流程不再天天需要人工介入。我一直觉得,做这块的最理想状态是:你的程序在统计数据上和真人无异,但它的用途、目的和边界,仍然清清楚楚地写在你自己的代码注释里。
如果你刚接触这个方向,我只有一个建议:别急着堆代码,先把你需要模拟的场景拆成时间轴、轨迹、事件、环境四层,一层一层做。先把时间轴做了,你会立刻发现被识别的概率下降一大截。这是我踩过很多次坑之后,最值得分享的经验。