- 网页爬虫
- 浏览器控制
- 测试
【免费下载链接】puppeteer-extra
💯 Teach puppeteer new tricks through plugins.
导读
navigator.plugins与navigator.mimeTypes是浏览器指纹检测中最常被探测的一组 API:真实用户使用 headful Chrome 时,这两个属性包含 PDF 查看器、Native Client 等内置插件;而 headless 模式下它们却是空数组,这一差异足以让网站轻松识别自动化浏览器。本文以 puppeteer-extra-plugin-stealth 中的navigator.plugins反检测模块为线索,结合仓库源码与测试用例,完整剖析该模块如何从零构建一套"功能完整、行为逼真且不留篡改痕迹"的MimeTypeArray与PluginArray。读完本文,你将掌握该反检测技术的实现原理、底层 JS Proxy 技巧,以及如何在 puppeteer-extra 中单独或整体启用它。
一、背景:Headless 模式为何暴露空插件列表
1.1 问题的本质
根据 navigator.plugins 模块文档 的说明:
In headless mode
navigator.mimeTypesandnavigator.pluginsare empty. This plugin emulates both of these with functional mocks to match regular headful Chrome.
即在 headless 模式下,navigator.mimeTypes和navigator.plugins均为空,而正常的 headful Chrome 中它们包含可枚举的插件与 MIME 类型条目。这组差异是"猫鼠游戏"中极易被捕获的破绽,检测脚本只需检查navigator.plugins.length是否为 0 即可判定浏览器是否为 headless——这一点在仓库的 cat-and-mouse.test.js 中作为检测手段出现:
await test('navigator.plugins empty', _ => { return navigator.plugins.length === 0 })1.2 为什么不能简单造个假数组
仅仅把数据塞进一个普通 JavaScript 数组远远不够。检测方会通过Array.isArray()、JSON.stringify()、Object.getOwnPropertyNames()、Object.keys()、toString()、Symbol.toStringTag、函数toString()输出、原型链、错误堆栈乃至valueOf()等数十种手段交叉验证。因此该模块的目标是:让伪造出来的对象在行为上"无限逼近"原生 headful Chrome 的对象,而不是表面上有几个数据。
二、模块 API 与使用方式
2.1 核心类 API
文档中暴露的核心类为Plugin,它继承自PuppeteerExtraPlugin,构造函数接受一个可选参数opts(默认{}):
| 项目 | 说明 |
|---|---|
| 类名 | Plugin |
| 继承 | PuppeteerExtraPlugin |
| 构造参数 | opts(可选,默认{}) |
| 插件名称 | stealth/evasions/navigator.plugins |
| 挂载时机 | onPageCreated,通过evaluateOnNewDocument注入 |
源码定义位于 index.js:Plugin类通过get name()声明插件标识,并在onPageCreated(page)钩子中利用withUtils(page).evaluateOnNewDocument(...)将注入脚本注册到每个新页面的文档加载前。
2.2 使用方式
该模块是 stealth 插件默认启用的 16 个反检测模块之一(见 stealth 插件源码 中的availableEvasions列表),因此最常见的用法是直接整体启用 stealth:
const puppeteer = require('puppeteer-extra') const StealthPlugin = require('puppeteer-extra-plugin-stealth') puppeteer.use(StealthPlugin()) // 默认启用全部 evasion,含 navigator.plugins也可以绕过主模块,像独立puppeteer-extra插件那样单独引入本模块(参考 stealth readme 的说明):
const puppeteer = require('puppeteer-extra') puppeteer.use( require('puppeteer-extra-plugin-stealth/evasions/navigator.plugins')() )甚至可以在整体启用 stealth 后动态剔除该模块:
const pluginStealth = require('puppeteer-extra-plugin-stealth')() pluginStealth.enabledEvasions.delete('navigator.plugins') puppeteer.use(pluginStealth)三、实现原理:从零构建"可信"的插件体系
整个模块由五个文件协作完成:index.js(主入口)、mimeTypes.js(生成 MimeTypeArray)、plugins.js(生成 PluginArray)、magicArray.js(核心生成器)、functionMocks.js(函数级 Mock)。
3.1 入口流程
onPageCreated的注入脚本按以下顺序执行(对应 index.js):
- 头部检测:若
'plugins' in navigator && navigator.plugins.length为真,说明当前运行在 headful 环境,立即返回,不做任何改动(避免重复污染)。 - 生成数组:用
data.json中的静态数据分别生成mimeTypes与plugins两个数组对象。 - 交叉引用:
Plugin与MimeType互相引用(enabledPlugin属性),逐一建立双向关联。 - 原型替换:通过
patchNavigator在Object.getPrototypeOf(navigator)上定义mimeTypes与plugins的 getter,而不是直接给navigator加属性——这是防止Object.getOwnPropertyNames(navigator)泄露修改痕迹的关键。
const patchNavigator = (name, value) => utils.replaceProperty(Object.getPrototypeOf(navigator), name, { get() { return value } })3.2 统一生成器 magicArray
MimeTypeArray与PluginArray结构高度相似,因此 magicArray.js 用同一个generateMagicArray函数生成两者,通过参数区分:
| 参数 | 生成 MimeTypeArray 时 | 生成 PluginArray 时 |
|---|---|---|
proto | MimeTypeArray.prototype | PluginArray.prototype |
itemProto | MimeType.prototype | Plugin.prototype |
itemMainProp | type | name |
生成过程包含四个层层递进的技术要点:
(1)非枚举属性描述符:defineProp统一用Object.defineProperty设置writable: false, enumerable: false, configurable: true,还原原生对象的属性描述符特征,同时保证JSON.stringify(navigator.mimeTypes)不会输出内部数据。
(2)原生原型造假对象:每个条目通过Object.create(itemProto, descriptor)创建,使navigator.mimeTypes[0].toString()返回'[object MimeType]'、Symbol.toStringTag为'MimeType',从而通过toString/原型链校验。其中Plugin条目还有一个特殊处理:其length属性被硬编码为对应__mimeTypes的长度(navigator.plugins[i].length应等于其关联的 MIME 类型数量),且为非枚举。
(3)Proxy ownKeys 陷阱:原生MimeTypeArray/PluginArray的length属性虽然存在于原型上,但 headful Chrome 执行Object.getOwnPropertyNames(navigator.mimeTypes)时却不会列出它。为复现这种"魔法行为",代码先给length打上configurable: true,再用 Proxy 的ownKeys与getOwnPropertyDescriptor陷阱将其从属性列表中剥除,同时伪造出基于数据的属性名列表(索引 + 类型名,如["0","1","2","3","application/pdf",...])。
(4)函数级重定向:Proxy 的get陷阱将item、namedItem(以及 PluginArray 特有的refresh)调用重定向到自定义 Mock,其余属性正常透传。
3.3 函数级 Mock:复刻原生怪异行为
functionMocks.js 负责item、namedItem、refresh三个方法的仿真:
item():复刻原生行为——参数会被尝试转换为整数并作为索引查找;传入非整数(包括字符串)时返回第一个条目;永远不返回undefined,越界返回null;无参数时抛出与原生一致的TypeError("Failed to execute 'item' ...: 1 argument required, but only 0 present.")。namedItem():按itemMainProp(type或name)精确查找,未命中返回null(同样不是undefined),无参数抛出TypeError。refresh():PluginArray 特有的方法,直接返回undefined,模拟原生"什么都不做"的语义。
这些 Mock 通过utils.createProxy创建,内部调用了 _utils/index.js 提供的stripProxyFromErrors(剥离 Proxy 在错误堆栈中的痕迹)与patchToString(让函数toString()输出function item() { [native code] })。
3.4 MIME 类型与插件的交叉引用
文档明确指出:"mimeTypes and plugins cross-reference each other, so it makes sense to do them at the same time."这正是 index.js 中最精妙的部分:
- 遍历
data.plugins,把每个插件声明关联的 MIME 类型对象按索引挂到插件条目上(plugins[name][index] = mimeTypes[type]),并定义非枚举的命名属性。 - 给每个
MimeType定义enabledPlugin属性,指向对应插件。 - 防循环引用:
enabledPlugin的值为new Proxy(plugins[name], {})——用空 Proxy 包装,既保证访问语义正确(如mimeTypes['application/pdf'].enabledPlugin能取到插件),又避免JSON.stringify(navigator.plugins)因循环引用而崩溃。唯一的例外是application/x-pnacl:它与application/x-nacl共享同一个插件,因此直接复用后者的enabledPlugin(即同一个 Proxy),避免产生两个不同的代理泄露不一致性。
测试 mimeTypes.test.js 甚至专门验证了五层嵌套访问enabledPlugin[0].enabledPlugin[0]...suffixes仍能得到'pdf',证明该设计在深度遍历下也不会暴露循环引用问题。
3.5 数据来源:贴近真实 Chrome 的静态清单
data.json 提供了模拟数据:4 种 MIME 类型(application/pdf、application/x-google-chrome-pdf、application/x-nacl、application/x-pnacl)和 3 个插件(Chrome PDF Plugin、Chrome PDF Viewer、Native Client)。每个插件包含name、filename、description与__mimeTypes(带双下划线前缀的内部字段,在生成对象时被跳过)。测试断言navigator.plugins.length === 3、navigator.mimeTypes.length === 4即基于此数据。
四、为何能通过"不留痕迹"检测
伪造对象不仅要"长得像",还必须"查不出是假的"。测试 index.test.js 验证了关键的一点:启用插件后,Object.getOwnPropertyDescriptor(navigator, 'mimeTypes')与plugins仍为undefined,Object.getOwnPropertyNames(navigator)中也不包含plugins。这得益于两点:
- 属性挂在原型上:通过
Object.getPrototypeOf(navigator)上的 getter 返回伪造对象,navigator自身没有新增任何可枚举自有属性。 - 全面的 Proxy 伪装:
ownKeys陷阱复现了原生属性列表,stripProxyFromErrors清除了错误堆栈中的 Proxy 痕迹,patchToString/redirectToString让所有方法输出[native code],配合utils.preloadCache预先缓存原生Function.toString与Reflect引用,防止检测方通过替换这些基础函数来嗅探内部实现(见 _utils/index.js)。
值得注意的是,所有注入脚本都是通过 withUtils.js 的evaluateOnNewDocument以字符串形式传递(stringifyFns/materializeFns),因为page.evaluate的序列化机制不允许直接传递函数引用——这一约束也决定了整个模块必须采用"函数字符串化 + 页面内 eval 还原"的架构。
五、测试验证:一组可复现的行为快照
该模块的测试用例(index.test.js、mimeTypes.test.js、plugins.test.js)用精确断言固化了伪装对象的行为基线,以下是几组关键断言:
数组级行为(来自 mimeTypes.test.js 与 plugins.test.js):
| 探测手段 | 期望结果 |
|---|---|
Array.isArray(navigator.mimeTypes) | false |
JSON.stringify(navigator.mimeTypes) | {"0":{},"1":{},"2":{},"3":{}} |
Object.getOwnPropertyNames(navigator.mimeTypes) | ["0","1","2","3","application/pdf",...](不含length) |
navigator.mimeTypes.toString() | '[object MimeTypeArray]' |
navigator.mimeTypes.item()(无参) | TypeError: Failed to execute 'item' on 'MimeTypeArray'... |
navigator.mimeTypes.item('madness') | 返回第一项(application/pdf),而非undefined |
'push' in navigator.mimeTypes | false |
条目级行为(来自 mimeTypes.test.js 的stealth: will have convincing mimeType entry):
| 探测手段 | 期望结果 |
|---|---|
navigator.mimeTypes[0].toString() | '[object MimeType]' |
navigator.mimeTypes[0].__proto__[Symbol.toStringTag] | 'MimeType' |
JSON.stringify(navigator.mimeTypes[0]) | '{}' |
Object.getOwnPropertyNames(navigator.mimeTypes[0]) | '[]' |
enabledPlugin五层嵌套访问.suffixes | 'pdf' |
无泄漏验证(来自 index.test.js 的stealth: will not leak modifications):启用插件后navigator自身的属性描述符与属性名列表与原生完全一致。
此外,cat-and-mouse.test.js 中"stealth: will pass Paul Irish"测试会在启用全部 stealth 插件(含 navigator.plugins)后运行 Paul Irish 的 headless 检测脚本,其中就包含navigator.plugins.length === 0这一项,只有当所有检测项全部不命中时测试才通过。
六、局限性与注意事项
- 数据快照的时效性:伪造内容来自 data.json 的静态快照(Chrome PDF Plugin、Chrome PDF Viewer、Native Client),与具体浏览器版本的实测数据可能略有出入;检测方若对比多版本基线仍可能发现差异。
- 跨 iframe 场景:
evaluateOnNewDocument注入不会自动作用于[srcdoc]/[sandbox]属性的 iframe(相关说明见 cat-and-mouse.test.js),这类隔离窗口中的navigator.plugins仍可能为空。 - 无法做到绝对隐身:仓库 stealth readme 也坦言,完全阻止所有 headless 检测方式是不现实的,本模块的目标是把检测成本提升到不划算的程度。
结语
navigator.plugins反检测模块是 puppeteer-extra stealth 系列中技术含量最高的实现之一:它以data.json为数据源,借助Object.create+ 属性描述符 + 双层 JS Proxy(ownKeys/getOwnPropertyDescriptor/get陷阱)+ 函数级[native code]伪装,构建出与 headful Chrome 行为高度一致的MimeTypeArray与PluginArray,并通过原型链替换与错误堆栈清理做到"查无实据"。理解这套实现,不仅能帮你用好 puppeteer-extra 的 stealth 能力,也为自主编写反检测脚本提供了可复用的思路与工具(_utils中大量工具函数均可独立复用)。
- 网页爬虫
- 浏览器控制
- 测试
【免费下载链接】puppeteer-extra
💯 Teach puppeteer new tricks through plugins.
相关推荐
puppeteer-extra无头模式增强:stealth插件如何模拟真实用户行为
puppeteer extra无头模式增强:stealth插件如何模拟真实用户行为 你是否曾遇到过这样的困境:使用Puppeteer的无头模式(Headless
网页爬虫浏览器控制测试puppeteer-extra浏览器指纹伪装技术:webgl.vendor与navigator插件应用
puppeteer extra浏览器指纹伪装技术:webgl.vendor与navigator插件应用 引言:浏览器指纹追踪的隐形威胁 你是否曾疑惑为何刚浏览某
网页爬虫浏览器控制测试puppeteer-extra-plugin-stealth 之 webgl.vendor 逃逸:修复无头模式下 WebGL Vendor/Renderer 被标记为 Google 的指纹问题
puppeteer extra plugin stealth 之 webgl.vendor 逃逸:修复无头模式下 WebGL Vendor/Renderer 被
网页爬虫浏览器控制测试
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考