做自动化点击或者挂机脚本,最头疼的通常不是流程跑不通,而是动作太假。你用 Selenium 把按钮点了,广告联盟那边却直接判定是机器人在操作。核心原因就两个:鼠标轨迹和停留时长,露了马脚。

我一开始也不信这个邪。Selenium 的 ActionChains 调用 move_by_offset,坐标参数写得清清楚楚,页面上元素也都定位到了。为什么后台的检测率还是高得离谱?后来翻了一堆反作弊的白皮书才反应过来——那些大平台早就不看你“有没有移动鼠标”了,盯的全是轨迹里的细节。

匀速直线的路径、固定的起始坐标、毫秒级精准的时间戳,甚至每次移动的距离都一模一样,这些都是机器的特征。就像你看到一个人走路,每一步都迈恰好 30 厘米,肯定觉得不对劲。网站检测算法也是这个逻辑。它们会监听 document.addEventListener('mousemove') 收集回来的数据,甚至通过 canvas 绘制轨迹来做分析。只要稍有规律,就会被标记。

很多人习惯用 ActionChains.move_by_offset() 或者 move_to_element() 来模拟移动。但这些方法生成的轨迹,怎么说呢,太“干净”了。当你调用 perform() 的时候,它会把整段位移拆分成均匀的小步骤。既没有人类操作时自然的加速度变化,也没有手部自然抖动带来的那 2 到 5 个像素的偏移。

更致命的问题是从 (0,0) 坐标开始移动。你想一下,没人会把鼠标移到屏幕左上角再跑去点目标。这就跟你出门前必须先站在小区门口正中央一样奇怪。

人移动鼠标时的特征其实很明显:刚开始启动慢,中间加速,快到终点的时候再减减速。路径是弯曲的,不是一条直线。手指贴在鼠标上会有轻微的颤抖,导致轨迹出现细小的锯齿。有时候还会突然停顿半秒,然后继续。而传统的模拟要么匀速到底,要么完全没有停顿,就像机器人说话没有语气起伏,一眼就能识破。比方说你要点页面右下角的广告,真实用户可能会从左下角慢慢晃过去,中途还停两次调整一下方向。脚本往往只认一条笔直的线。

让大模型“写”一段鼠标轨迹,比数学曲线更像人

既然传统方案走不通,就得换个路子。我当时想的是,要是让大模型直接“写”一段鼠标轨迹,会不会好点?

不是用数学公式算出来的贝塞尔曲线,而是让模型像人一样去“想象”手指怎么晃的。试了几轮 prompt 调教之后,我发现关键就一句话——告诉它“像个刚睡醒的人那样动鼠标”。我常扔给 GPT 或者本地跑的 Qwen 的 prompt 是这么写的:

"生成一段鼠标从 (320, 480) 到 (780, 620) 的轨迹,包含 3 次随机停顿和 2px 的抖动,总时长 2 秒。每行一个坐标和时间偏移 (ms)"

模型吐出来的东西,你一眼扫过去就觉得“对味”。第一段 0ms 到 300ms 只挪了 20 个像素,像是刚睡醒正找鼠标呢。中间突然加速,坐标跳动变大,还夹着几个 4 到 5 毫秒的原地颤动。最后 200ms 减速收尾,甚至在终点前停了一次,还往回偏了 3 个像素再校正回来。这种轨迹你拿 canvas 画出来,跟真实用户的采样几乎重叠。

原因其实很简单:大模型训练数据里本来就有无数人聊过“我今天鼠标卡了一下”或者“划着划着停了”这种自然语言描述。它输出的随机性不是伪随机,而是语义层面上的“不规整”。

贝塞尔曲线再复杂,它也是个参数方程,加速度、拐点、抖动幅度都能被反推出来。但大模型输出的轨迹,停顿的位置、抖动的幅度、加速的时机,全是基于语言概率的随机抽样。你让同一个 prompt 跑三次,三次出来的轨迹都不一样。有些停顿甚至发生在毫无理由的地方——恰恰是这种“无理由”,才更像人。人本来就会在盯着屏幕发呆的时候,莫名其妙停一下鼠标。

而且实测下来,广告联盟的检测模型对“过于平滑”的曲线敏感度极高。但对这种带毛边、带随机停顿的轨迹宽容很多——大概是因为人类训练数据里,干干净净的贝塞尔曲线反而比较罕见。

当然,光有轨迹还不够。页面停留时长也得“演”得像,否则你点完就走,再真的轨迹也白搭。

有了“像人”的轨迹描述,下一步就是把它变成真正的鼠标动作。难点不在于“动”,而在于“怎么动得像”。我最早试过把 prompt 直接丢给 Selenium 的 ActionChains,结果被站点的 event listener 一眼识破——匀速、等间隔、无抖动,全是机器人的标签。后来改用 Playwright,配合 Chrome DevTools Protocol 把 navigator.webdriver 抹掉,再把事件栈打散,才算勉强混过去。

大模型一般返回的是一串坐标与时间偏移。我会在 Python 里先用 json 模块去解析,遇到以“#”开头的注释行就跳过。再用一个小函数做“漂移”:把每个点的 x、y 各加 1 到 3 个像素的随机值,防止路径过于理想。接着按 10 到 50 毫秒的不等间隔拆分,故意引入一点“毛刺”。有时候还会把末尾几个点的时间拉长,模拟“快到目标却犹豫了一下”的人类习惯。

import asyncio, random
from playwright.async_api import async_playwright

def drift(x, y):
    return int(x + random.uniform(-3, 3)), int(y + random.uniform(-3, 3))

async def human_move(page, points):
    async with page.expect_event("framenavigated"):
        for base_ms, (x, y) in points:
            await page.mouse.move(x, y)
            await asyncio.sleep(base_ms / 1000)

Playwright 的 mouse.move 会自带“抬起-落下”的事件序列。为了让 canvas 采样看不出规律,我把轨迹拆成 8 到 15 段,每段之间插入 20 到 60 毫秒的随机 pause,顺便在页面上随便点两下空白,骗过“停留时长”的统计。遇到需要滚动的广告位,就在每次 click 之前先调用 scroll_to_element,再读一次 offsetTop,保证落点不会飘。有些联盟脚本跑在桌面端,我就换成 pyautogui。它会真的把鼠标挪过去,缺点是多屏的时候坐标需要换算,好处是能把“原地抖动”做得更自然。记得调一下 pyautogui.PAUSE = 0.02,再加个 pyautogui.FAILSAFE = True,免得脚本跑飞了还得手动拔线。

最容易露馅的环节,其实是到达终点后立刻干正事。我会在最后一段轨迹后面再加 800 到 1500 毫秒的随机等待,期间偶尔把鼠标小幅左右晃一晃。让 document.hasFocus 和 visibilityState 的变化看起来“有人盯着屏幕”。把这些“边角料”动作串起来,整套刷量流程就像有人在那坐着慢慢点,而不是机械臂扫一遍。

large language model generate random mouse trajectory

页面停留时长不能“掐表走”,得让大模型“读”页面内容

轨迹演得像人了,但时间维度要是死了,同样不行。这就好比一个演员动作到位,可每次出场都掐着秒表走,观众一眼就知道是假的。

广告联盟的反作弊系统没那么傻。它们不光看你鼠标怎么划,还看你“待了多久”。一张图文详情页,你停 8 秒就跳走?那叫误点。你停 32 秒?那叫认真看完了。你每次停留都用同一段时间?那叫定时炸弹。联盟后端一旦发现某个 IP 的停留时长方差小得离谱,直接标记成“定时脚本”,流量清零。

所以我得让大模型去“读”页面的内容,再决定该停多久。传给大模型的 prompt 里会塞一段页面 DOM 的纯文本摘要,加上几个关键的 meta 标签,最后附一个简单的指令:

你是一个浏览行为生成器。根据以下页面内容,输出该页面“合理的阅读停留秒数”。
规则:
- 如果内容超过 800 字或带有“article”“blog”类名,基础时长 30~60 秒
- 如果包含 video 标签或时长 meta 小于 60 秒,基础时长 10~20 秒
- 如果是商品详情(有 price、add_to_cart 等字段),基础时长 15~35 秒
- 所有输出必须带 20% 的随机浮动

大模型返回的是一段 JSON,比如 {"base_seconds": 42, "reason": "长图文+表格"}。我用这个 base_seconds 做锚点,再套一层正态分布随机,生成最终的停留毫秒数。这样同一个页面反复刷,每次停留都差个几秒十几秒,方差跟真人阅读习惯差不多。

光停在那不操作,同样会露馅。visibilityState 和 requestAnimationFrame 的回调能够检测到页面到底有没有交互。所以我在停留期间,每隔 3 到 7 秒就触发一次 mouse.move,坐标在视口内随机偏移 10 到 50 像素,偶尔再调用一次 window.scrollBy(0, random.randint(50, 200))。

在 Playwright 里实现起来很简单:起一个 asyncio.create_task,循环执行 sleep、mouse.move 和 scroll,直到主协程把停留倒计时跑完。每次 scroll 的 deltaY 也随机,不取固定值,避免滚动出现“每 5 秒正好 150px”这种工整数列。

async def idle_activity(page, duration_ms):
    end = time.time() + duration_ms / 1000
    while time.time() < end:
        x = random.randint(100, 800)
        y = random.randint(100, 600)
        await page.mouse.move(x, y, steps=random.randint(3, 7))
        await page.evaluate("window.scrollBy(0, arguments[0])", random.randint(50, 200))
        await asyncio.sleep(random.uniform(3, 7))

这段代码跑在后台,不影响主轨迹的 click 流程。而且 scroll 事件本身会触发页面的懒加载图片,把那些“滚动 50% 才展示”的广告位也刷上曝光,一举两得。

最后还有一个小坑:某些网站会检测“页面是否获得焦点”。你模拟停留期间如果切到别的窗口,document.hidden 会变成 true。所以我得确保浏览器窗口一直置顶,或者在使用 Electron 无头模式时,把窗口的 focused 状态设置为永久 true——这倒是 Playwright 的 browserContext.grantPermissions 管不到的,得直接改 Chrome DevTools Protocol 的 Page.bringToFront。

convert prompt to executable mouse movement commands

反检测不能只盯着轨迹和停留,指纹和时段更关键

轨迹和停留时长都搞定之后,是不是觉得接下来就能睡后收入了?想多了。广告联盟那套反作弊早就不只看你“动没动”——它们会在浏览器指纹、IP 质量、访问时段这三个维度上做交叉验证。任何一个维度出问题,之前折腾的全白搭。

先说指纹。Playwright 默认启动的 Chromium 会在 这个属性上留下 true 的标记。虽然官方提供了 --disable-blink-features=AutomationControlled 参数,但实测下来,某些站点(比如 Google 系的广告)会额外检测 的长度以及顺序,看是否与正常 Chrome 一致。我写过一段 CDP 注入,在 里把这些属性覆盖掉:

await page.addInitScript(() => {
    Object.defineProperty(navigator, 'webdriver', { get: () => false });
    Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
    Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
    const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
    HTMLCanvasElement.prototype.toDataURL = function(...args) {
        const base = originalToDataURL.apply(this, args);
        return base.replace(/=+$/, '') + '&r=' + Math.random();
    };
});

注入脚本的执行时机必须早于页面任何 JS 的加载,否则会被覆盖回去。这个坑我踩过:一开始用的 page.on('load'),结果页面已经跑了检测脚本,navigator 的属性已经被冻结,改不掉了。

IP 轮换这块,没什么黑科技。买一批住宅代理池,每次请求前通过 Playwright 的 browser.newContext({ proxy: { server: proxyUrl } }) 创建新上下文。关键点在于切换频率——别每点一次就换 IP,那比固定 IP 还假。正常人访问 5 个页面才会换一次出口 IP,而且切换的间隔应该分布在 2 到 10 分钟之间,别写死成整分钟。

最容易被忽视的是访问时段。很多刷量脚本全天 24 小时均匀打点,后台一看访问时间——凌晨 3 点到 5 点的密度和下午 3 点一样,直接标记异常。我写了一个时段权重表,按真实用户活跃度来分配点击量:早 8 点到 10 点占 20%,午 12 点到 14 点占 15%,晚 19 点到 23 点占 45%,其余时段加起来一共 20%。每小时的点击数也做了二次随机,比如晚高峰里最多不超过 15 次每小时,低谷期就控制在 3 到 5 次每小时。

反作弊系统的更新速度比我们想象中快得多。去年一套指纹注入脚本还能跑三个月,今年某些平台已经能检测 WebGL 渲染器的轻微差异,来判断是否是无头浏览器。我现在的做法是:每两周跑一次指纹检测页面(比如 pixelscan.net),看看自己的自动化实例在哪些维度暴露了人工痕迹,然后针对性地打补丁。

刷量这行,技术只是门槛。真正拉开差距的,是对这些细节的敏感度。你少修一个 ,整个账号池就可能被连坐封禁。收尾提醒一句:别把所有鸡蛋放在一个浏览器上下文里,每个账号独立 ,销毁后连 localStorage 都不留——指纹一旦被标记,连坐比你想象中来得快。

实测效果:骗过小联盟容易,大平台没戏

整套东西搭完其实心里挺没底的。大模型画鼠标轨迹听着玄乎,真扔上去跑,鬼知道能不能躲过那些监控探针。我挑了个日均 PV 三十万出头的小联盟,反作弊不算严,三个账号挂上去跑了整整三天。

结果有点意外。总共模拟了 2400 多次点击,平台后台显示的通过率是 92%。剩下的 8% 基本是页面还没完全加载就触发了点击——时间窗口没卡好。换回以前那套纯贝塞尔曲线加随机抖动的方案,同一批账号在同一站点,检测率直接掉了将近四成。你能明显感觉到,反作弊系统对那些“太规律”的轨迹已经形成了一套固化规则:加速度曲线平滑得像数学公式,停顿点永远在元素正中央。这些特征在服务端的机器学习模型里,就是明牌。

大模型生成轨迹的好处在于,它不会刻意去“拟合”人类行为,而是从行为数据里学到了那种不完美的节奏感。比如鼠标经过一个按钮时,有时候会突然减速再加速,有时候会滑过头再折回来——这些细节靠手写随机函数几乎不可能模拟到位。我翻过几个开源的轨迹生成库,它们无一例外都在“抖动幅度”上用了死参数。而大模型输出的每一步偏移量,都带着上下文的相关性,看起来更像一个活人在犹豫要不要点那个地方。

但我还是想泼盆冷水。这招拿去对付 Google Adsense 那种级别的反作弊,基本等于白送人头。我之前有个日 IP 五万的资讯站,挂上 Adsense 跑了不到半天,账号直接被标了“无效流量”,连警告信都没捞着,封得干干净净。大型广告平台手里握着的维度,比你能想象的要多得多——浏览器指纹、WebGL 渲染差异、CPU 时间戳偏差、甚至字体渲染时那种子像素级别的偏移。这些东西组合在一起,你让大模型生成的那套鼠标轨迹,充其量就是一层窗户纸,人家一捅就破。

所以这篇东西你看完,图个乐子就好。技术本身是中性的,但用在什么地方,心里得有个数。刷量这事,水面下的规则比水面上的技术多得多,你永远不知道反作弊系统明天会加什么检测维度。别把自己的账号当耗材。