Puppeteer 检测的真实原理:暴露机器人的 12 个信号
自动化框架继承了真实浏览器的指纹,却在数十处可观测的细节上留下了痕迹。这是防守方用来识别 Puppeteer 与 Playwright 的 12 个 JavaScript、网络与行为信号的实战指南。
自动化框架控制的是真实浏览器,这意味着它们继承了浏览器的指纹。由 Puppeteer 驱动的 Chrome 与人类使用的 Chrome 拥有相同的 user agent、相同的 Chrome 版本和相同的 Chromium 构建。表面上看,二者一模一样。
但自动化框架会以数十种微妙的方式改变浏览器——其中大多数都能从 JavaScript 中观测到。检测并不神秘,它就是一份检查清单。
1. navigator.webdriver 标志
最简单也最著名的信号。当 Chrome 运行在自动化框架之下时,navigator.webdriver 会返回 true。它是 W3C WebDriver 规范的一部分,Chromium 默认会实现它。
任何认真的自动化项目都会在几秒钟内把它修补掉。Puppeteer-extra-stealth 会覆盖它的 getter。Playwright 的使用者会在新文档上注入 Object.defineProperty 脚本。隐藏这个标志轻而易举。
但它能被轻易隐藏这一事实,本身就是一个信号。合法的 Chrome 无需隐藏任何东西。当页面检查 Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'),而描述符看起来与原生浏览器的不一样时——即便标志已被修补,自动化仍然会被识破。
2. chrome 对象异常
合法的 Chrome 会暴露一个结构可观的全局 window.chrome 对象——包含 chrome.runtime、chrome.loadTimes、chrome.csi。无头 Chrome 和较旧的 Puppeteer 配置要么完全没有这个对象,要么只暴露一个精简版本。
隐身插件会重建这个对象,但重建并不完美。可能出现 chrome.runtime.onConnect 存在,却缺少 chrome.runtime.PlatformOs 的情况。函数签名可能返回对象而非 undefined。每一处不匹配都是一个阳性信号。
3. Permissions API 不一致
真实浏览器通过 Permissions API 查询时会返回一致的结果。如果用户没有明确授予或拒绝权限,navigator.permissions.query({name: 'notifications'}) 应该返回 'default'。
无头 Chrome 默认返回 'denied',因为没有 UI 来显示权限提示。自动化框架会修补这一点——但往往修补得不正确。一个常见的破绽:查询 'clipboard-read' 这类不常见的权限时,返回的结果与真实 Chrome 在当前版本中的表现不符。
4. 插件与 MIME 类型数组
在真实浏览器中,navigator.plugins 会返回一个 PluginArray,包含 PDF 查看器和 Chromium PDF 插件之类的条目。在无头模式下,这个数组是空的。
隐身插件会添加伪造的条目,但这些条目经常带有错误的属性——缺少 length、description 字段有误,或者用 instanceof 测试时插件对象表现得不像 Plugin 实例。
5. 语言与区域设置不匹配
navigator.language 和 navigator.languages 应该与 Accept-Language HTTP 头一致。它们还应该与 Intl.DateTimeFormat().resolvedOptions().timeZone 所报告的时区保持一致。
一个声称位于基辅(Europe/Kyiv 时区),却发送 Accept-Language: en-US,en;q=0.9 并报告 navigator.language === 'en-US' 的机器人,在统计上虽有可能却很不寻常。当再叠加上一个来自法兰克福数据中心的 IP 时,真相就一目了然了。
6. WebGL 渲染器字符串
WebGL2RenderingContext.getParameter(WebGLDebugRendererInfo.UNMASKED_RENDERER_WEBGL) 会返回 GPU 的厂商和型号。在真实机器上,它可能是:
ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0)
而运行在容器中的无头 Chrome 常常返回:
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
任何包含 SwiftShader 或 Subzero 的 GPU 字符串都是强烈的机器人信号。
7. 屏幕与视口异常
真实浏览器窗口的 window.outerWidth 和 window.outerHeight 会计入浏览器界面元素——地址栏、标签页、书签栏。外部尺寸与内部尺寸之间的差值在垂直方向上通常为 80~140 像素。
无头浏览器往往 outerHeight === innerHeight,因为没有需要显示的界面元素。以 headless: false 运行的 Puppeteer 能修正这一点,但许多自动化配置仍然以相等的尺寸运行。
8. 字体枚举
真实用户安装了数百种字体,具体因操作系统、语言和已安装的应用而异。装有 Office 的 Windows 11 拥有 500 多种字体。运行 Alpine Linux 的自动化容器只有 30 种。
通过 document.fonts.check() 或离屏 canvas 渲染进行字体枚举会立刻暴露这一点。一个自称是 Windows 11 Chrome、却只暴露 30 种字体的浏览器,几乎可以肯定是自动化。
9. 鼠标移动熵
真实用户移动鼠标时会带有抖动。曲线是非线性的。速度不断变化。两次点击之间,通常会有数百个 mousemove 事件。
自动化框架是合成移动的——要么用直线移动到坐标,要么用缺乏人手微颤的程序化曲线。即便是贝塞尔曲线模拟,其加速度曲线也往往平滑得可疑。
机器人检测系统会收集移动轨迹,并用人类运动模型对其打分。速度过于恒定、曲线过于干净或缺少微事件的轨迹都会被标记。
10. 时序特征
自动化框架执行 JavaScript 的时序特征与用户驱动的浏览器不同。事件之间的 performance.now() 时序,尤其是 pointerdown 与 pointerup 之间的时序,遵循不同的分布。
真实的点击按住时长为 50~150ms,方差很大。Puppeteer 默认的点击按住时长是一个固定值——常为 30ms 或 100ms——几乎没有方差。一百次点击都具有相同的按住时长,就是确凿的自动化证据。
11. TLS 指纹不匹配
这一个完全绕过了 JavaScript。当 Puppeteer 连接时,它使用的是 Chromium 的 TLS 栈——与真实 Chrome 相同。但那些基于 undici 或基于 axios、通过伪造 user agent 假装成 Chrome 的爬虫,用的是 Node.js 的 TLS 栈,其 Client Hello 签名(JA4 哈希)截然不同。
一个声称 User-Agent: Chrome/124.0.6367.60、却呈现出 Node.js TLS 指纹的请求,就是非浏览器客户端的即时确认。
12. CDP 协议泄露
Puppeteer 和 Playwright 通过 Chrome DevTools Protocol 与 Chrome 通信。在某些配置下,这个协议会留下可检测的痕迹——Runtime 对象上多出的属性、被改动的 Error.stack 格式,或只有在 CDP 客户端接入时才会出现的特定 console.debug 输出。
尤其是 Runtime.enable 命令会改变堆栈跟踪的渲染方式。从不触发错误的机器人可以规避这个信号,但任何遇到异常的自动化流程(大多数都会遇到)都会留下指纹。
叠加这些信号
没有任何单一信号能证明自动化。一个使用了不寻常 GPU 驱动的真实用户可能返回一个古怪的 WebGL 字符串。一个注重隐私的用户可能修改过 navigator.plugins。
现代检测的强度来自信号的组合——12 个弱阳性比 1 个强阳性更有力,因为真实用户很少同时触发超过 2~3 个异常。
检测系统会为每个信号赋予权重并计算出一个分数。超过阈值,访客就会被当作自动化处理。而阈值和权重的精确设定恰恰是最难拿捏的部分——过于严格会拦住合法用户;过于宽松则会让老练的机器人溜过去。
信号本身是简单的部分。几乎每个自动化框架都会泄露全部 12 个。真正的工程难点在于判断哪些组合有意义、哪些是误报,以及在自动化框架不断适应的过程中如何更新模型。