网站的反自动化检测已经发展到相当精细的程度——从最简单的navigator.webdriver属性检查,到Canvas指纹、WebGL渲染特征、行为轨迹统计学分析,检测维度越来越多,只靠Playwright裸跑一个page.goto()就能蒙混过关的日子早就不存在了。很多开发者遇到过这种情况:本地脚本跑得好好的,部署到服务器就频繁弹验证码;或者同一套逻辑在无头模式下被秒识别,换个有头模式就能多撑几分钟。
这篇文章把我这几年在Playwright反检测方向上踩过的坑、试过的方法、最终稳定运行的方案整理成一份完整指南。不管你是做自动化测试、爬虫采集,还是网页自动化操作,只要需要让Playwright脚本尽可能贴近真实用户行为,这篇文章都值得从头到尾看一遍。
1. 反自动化检测的原理拆解:网站究竟靠什么识破你
1.1 铁打的检测维度:从WebDriver标记到浏览器指纹
网站识别自动化浏览器,不是靠某一项单一技术,而是多维度特征的交叉验证。我梳理了目前主流检测系统的核心检测点,基本可以分为四层。
第一层是最基础的JavaScript属性检测。浏览器自动化框架会在页面上下文中留下特定标记,最典型的就是navigator.webDriver属性。当你用Playwright或Selenium打开页面时,这个属性默认是true,而真实用户浏览器里它是undefined或false。检测代码只需一行:
// 网站端的检测脚本通常是这样的 if (navigator.webDriver === true) { // 判定为自动化浏览器 }这一层还有window.chrome对象是否存在、navigator.plugins是否为空、navigator.languages是否符合真实浏览器习惯等子项。Playwright虽然自带了一些规避措施(比如headless模式下会修改部分特征),但默认配置远没有达到"隐身"级别。
第二层是浏览器指纹。Canvas指纹、WebGL指纹、AudioContext指纹、字体枚举、时区、语言、硬件并发数等组合起来,可以形成近乎唯一标识的指纹信息。真实浏览器和自动化浏览器在这些API的渲染结果上存在可检测的差异,比如CPU核心数、内存大小、屏幕色深等。
第三层是行为特征。鼠标移动轨迹、滚动速度、点击间隔、键盘输入节奏,这些人类操作天然带有随机性和不规则性,而脚本执行操作往往精确到毫秒级、路径呈完美直线。检测系统会采集这些行为数据进行统计建模,判断操作是否来自真人。
第四层是网络层特征。包括TLS指纹(TCP/IP协议栈特征)、HTTP头顺序、请求时序、并发连接数等。这一层最难伪装,因为涉及浏览器底层网络栈的实现差异。好在大多数检测系统受成本限制,主要精力还是放在前三层。
1.2 Playwright的优势与短板:为什么没有开箱即用的Stealth
Playwright的核心定位是自动化测试,目标是可靠、稳定、快速地驱动浏览器,而不是伪装成真人。这是它在反检测场景下的天然短板——官方就没把隐身功能当作设计目标。
不过Playwright也有几个底层优势,是Selenium和纯Puppeteer不具备的。第一,Playwright通过CDP(Chrome DevTools Protocol)直接与浏览器通信,绕过了WebDriver协议层的一些检测特征;第二,Playwright能控制浏览器上下文的持久化,你可以保存包含真实指纹信息的用户数据目录反复使用;第三,Playwright支持Chromium、Firefox、WebKit三套内核,检测系统通常对非Chrome内核的识别率稍低。
短板也很明显。Playwright的chromium.launch()默认参数处处透露着"我是自动化"的味道:navigator.webDriver=true、window.chrome对象缺失、navigator.plugins为空数组、权限API会返回特定状态。这些都需要我们手动处理,或者借助第三方库来打补丁。
1.3 stealth插件的价值与局限
社区里有现成的playwright-stealth插件,原理是在页面初始化阶段注入JavaScript脚本,修改浏览器指纹特征,让页面脚本读取到的信息更像真实浏览器。
这个插件能解决的是前面三层中的部分问题,比如修正navigator.webDriver、添加window.chrome对象、填充navigator.plugins和languages、处理Permissions API。它的局限也很明显:不能完全模拟真实指纹,比如WebGL的渲染结果仍会和真实浏览器有细微差异;它只做静态信息篡改,对行为模拟无效;而且插件的匹配是针对特定浏览器版本的,Chromium版本更新后可能需要等待维护者适配。
如果你只做基础的数据采集和自动化操作,插件方案足够用。但如果你的目标站点检测体系非常严格(比如大型电商、风控严格的金融服务类网站),就得在插件方案之上叠加手动指纹伪装和行为模拟。这也是本文后续章节要重点展开的内容。
2. 环境准备与基础配置:抢先机从启动参数开始
2.1 最小化安装:Node.js与Playwright环境搭建
踏实做好第一步。环境准备这块不需要多复杂的操作,但有几个细节容易被忽视。建议使用Node.js的LTS版本(v18或v20),无限追求新版反而容易踩到兼容性问题。
mkdir stealth-demo && cd stealth-demo npm init -y npm install playwright-core注意我装的是playwright-core而不是playwright。区别是playwright会随安装自动下载浏览器内核(几百兆),而且版本绑定较紧;playwright-core不包含内核,需要你手动指定浏览器可执行文件路径,这在后期做指纹管理时更灵活,因为你可能同时管理多个版本的Chromium。
接着下载Chromium内核并指定路径。这里建议直接通过Playwright自带命令安装一个特定版本:
npx playwright-core install chromium安装完成后,浏览器可执行文件一般在~/Library/Caches/ms-playwright(macOS)或~/.cache/ms-playwright(Linux)下,稍后我们要把这个路径记下来,在启动配置里引用。
2.2 启动参数的关键决策:有头还是无头、禁用自动化开关
基础配置的第一道分水岭:headless模式怎么选。很多人的直觉是"无头肯定更容易被检测",这句话既对也不对。
新版的Chrome无头模式(Chrome 112+的New Headless)和旧版相比,检测差异已经缩小很多,但仍然存在一些特征差异,比如GPU渲染路径不同、navigator.webdriver在某些场景下仍会自动设置、屏幕尺寸和窗口大小默认值特殊。实测下来,如果你的脚本运行在服务器上、没有物理显示设备,优先尝试New Headless模式配合参数调整;如果目标站点检测力度较大,且你能接受每次多消耗一点资源,就用xvfb虚拟显示跑有头模式。
第二个关键点是--disable-blink-features=AutomationControlled参数。Blink是Chromium的渲染引擎,AutomationControlled是它专门为自动化控制暴露的特征开关。不显式关闭这个开关时,页面里的某些检测脚本能通过特征判断浏览器正被自动化工具控制。启动时加这个参数能消除一部分特征暴露。
第三个容易被忽略的参数是--disable-infobars相关,以及windowsize设置。真实用户浏览器往往有多个扩展、有特定的窗口尺寸,这些参数设置越接近真机越好。
// 基础启动配置示例 const browser = await chromium.launch({ headless: false, args: [ '--disable-blink-features=AutomationControlled', '--disable-infobars', '--window-size=1920,1080', '--start-maximized', '--no-first-run', '--no-default-browser-check', '--disable-extensions' ] });这里--disable-extensions反而有帮助,因为自动化浏览器默认不加载扩展,而真实浏览器往往有扩展。但这个参数的实际作用是避免一些检测插件干扰测试环境,在检测维度上减少变量。真实场景下你可以通过加载自己的扩展来模拟真实状态(后面章节会细说)。
2.3 上下文配置:用户代理、视口、Locale和时区
browser.newContext()这一步,是拉开配置细节差距的地方。很多新手只看UA就完事了,实际上上下文里的每一个字段都是检测维度。
先看userAgent。直接复制Chrome官网下载页的UA字符串是个常见做法,但要注意版本的匹配:你的Chromium内核版本要和你声明的UA版本一致,否则UA里的Chrome版本和实际内核版本对不上,等于明说"我是假的"。获取当前Chromium实际版本的代码也很简单:
const browser = await chromium.launch({ headless: false }); const version = await browser.version(); console.log(version); // 形如 "Chromium/128.0.6613.137"然后用这个版本号拼一个真实格式的UA。建议直接借用官网UA生成工具生成完整UA字符串,包含Chrome/{version}、Safari/537.36等完整后缀。
接下来是视口(viewport)和屏幕尺寸。真实用户很少用恰好整数宽高的视口,而且视口通常等于或略小于屏幕大小。建议设成带有小数位的值,比如{ width: 1680, height: 945 },屏幕尺寸用{ width: 1920, height: 1080 }。这能避免"视口和屏幕完全一致"的自动化特征。
Locale和时区也必须一致。如果你目标站点的受众主要在中国大陆,locale: 'zh-CN'、timezoneId: 'Asia/Shanghai'是基础。如果目标站点在海外,就设置对应区域的参数。检测系统会交叉验证UA里的语言、Accept-Language请求头、JavaScript的Intl.DateTimeFormat().resolvedOptions().timeZone,任何一处不一致都会暴露。
还要设置permissions。真实用户浏览器里,地理位置、麦克风、摄像头通常是"询问"状态,不是自动授权状态。用permissions: ['geolocation']配合geolocation选项设置一个合理的位置坐标,会比默认状态更真实。
const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', viewport: { width: 1680, height: 945 }, screen: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', geolocation: { latitude: 31.2304, longitude: 121.4737 }, permissions: ['geolocation'], deviceScaleFactor: 1, isMobile: false, hasTouch: false, colorScheme: 'light' });deviceScaleFactor和isMobile、hasTouch这三项必须和UA匹配。Windows桌面端Chromium的逻辑是deviceScaleFactor视实际屏幕而定(多数是1,高分屏可能是1.5/2),isMobile为false,hasTouch通常为false(触摸屏笔记本除外)。很多人只改UA忘了这些配套参数,等于穿着西装戴着草帽出门。
3. Stealth实战:核心指纹伪装技术拆解
3.1 初始化脚本:页面加载前抢先注入
Playwright提供了context.addInitScript()方法,可以在页面任何脚本执行之前运行你的JavaScript代码。这是指纹伪装的核心通道。
所有需要篡改的API属性,都必须在目标页面脚本读取之前完成修改。addInitScript保证了这个时序,它的执行时机是在文档创建之后、任何页面脚本运行之前。
await context.addInitScript(() => { // 篡改navigator.webDriver Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); });这种方式比在页面加载后再执行page.evaluate()要靠谱得多,因为后者已经暴露过一次navigator.webdriver=true了。
3.2 navigator.webDriver的欺骗手法
navigator.webDriver已经成了检测的第一道关卡,但仅仅把它改成false还不够严谨。很多检测脚本会检查属性是否存在、是否可枚举:
// 高一级的检测代码 const webdriver = Object.getOwnPropertyDescriptor(navigator, 'webdriver'); // 如果返回值不是undefined,说明有攻击痕迹所以比较稳妥的方案是用delete彻底删除这个属性:
Object.defineProperty(navigator, 'webdriver', { get: undefined, configurable: true });实测下来,get: undefined通常能通过绝大多数检测脚本。需要注意,如果你先执行了delete navigator.webdriver再让其他脚本访问它,可能触发异常逻辑;而get: undefined是让属性访问返回undefined,语义上更接近真实浏览器的行为。
3.3 固有对象补全:window.chrome、plugins与languages
真实Chrome浏览器里window.chrome是一个对象,包含loadTimes()、csi()等方法;自动化浏览器里这个对象往往不存在或为空。补全逻辑如下:
window.chrome = window.chrome || {}; if (!window.chrome.loadTimes) { window.chrome.loadTimes = function() { return {}; }; } window.chrome.csi = function() { return {}; }; window.chrome.app = window.chrome.app || {}; window.chrome.runtime = window.chrome.runtime || {};navigator.plugins在真实浏览器里是非空的(比如内置PDF插件),自动化浏汰器里是空数组。可以通过定义navigator.plugins为一个包含多个Plugin对象的类数组来填充。更省事的方案是让plugins的长度至少大于0,且PDF.js插件名存在:
Object.defineProperty(navigator, 'plugins', { get: () => { return [ { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' } ]; } });navigator.languages同理,要确保和Accept-Language请求头一致。如果你的UA是中文浏览器环境,navigator.languages应该是['zh-CN', 'zh']而不是默认的一长串英文语言。
还需要处理navigator.permissions。自动化浏览器在navigator.permissions.query({name: 'notifications'})时返回状态可能为'denied'或直接报错,而真实浏览器是'prompt'。补丁代码:
const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => { if (parameters.name === 'notifications') { return Promise.resolve({ state: Notification.permission }); } return originalQuery(parameters); };3.4 指纹平滑处理:Canvas、WebGL与AudioContext
Canvas指纹是检测系统非常依赖的维度。原理是基于canvas渲染文本和图形时,不同浏览器、不同GPU驱动会因抗锯齿、字体渲染子像素、色彩管理差异产生不同的像素输出,这段渲染数据的哈希值就是指纹。
伪装的关键不是"让canvas返回固定值",而是要让返回值和真实浏览器接近。完全随机改动会在不同页面间产生不一致的指纹——同一次会话的两次渲染结果如果差异过大,反而会被检测。稳妥做法是在Canvas的关键渲染过程中注入微小的、固定的噪声,比如给toDataURL的结果在末尾追加一个恒定像素值偏移:
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function(...args) { const result = originalToDataURL.apply(this, args); // 在base64数据尾部追加一个稳定的噪声标记 return result.replace(/A/, `B`); // 注意:这是简化示例,实际要避免破坏base64格式 };需要特别提醒:上述代码只是讲原理用了极端简化的写法,实际生产环境不能粗暴替换字符,会破坏base64解码。更稳妥的方案是绘制阶段在Canvas上叠加一层透明噪点或用WebGL的uniform变量做微小偏移。
WebGL方面,核心是getParameter返回的渲染器字符串和getExtension结果。Chromium在自动化模式下,WebGL渲染器字符串通常为"Google SwiftShader",而真实机器上是"ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)"这种带显卡型号的信息。这个从真实机器上复制一个具体显卡型号字符串过来,能撑过大部分检测:
const gl = canvas.getContext('webgl'); const getParameter = gl.getParameter.bind(gl); gl.getParameter = function(pname) { if (pname === 7937) { // RENDERER return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)'; } if (pname === 7936) { // VENDOR return 'Google Inc. (NVIDIA)'; } return getParameter(pname); };AudioContext指纹是较新型的检测维度,通过处理一段音频数据后产生的哈希值来识别浏览器。伪装它相对复杂,但基本思路和Canvas类似:在AudioContext的getChannelData返回结果里注入固定微噪声。
注意,以上指纹伪装的效果高度依赖浏览器版本和运行环境。如果真想做到高仿真,最可靠的方式是用同一套配置拉起浏览器后,在同一环境里跑一段指纹采集脚本,把采集到的真实指纹原样保存下来,后续所有隐身脚本都用这套指纹。这个方案比写死某个显卡字符串更可靠,起码所有指纹维度之间是匹配的。
为了完整性,我再列一下指纹一致性检查的清单。伪装做完后写一个检测脚本自测,至少保证以下几点全部满足:
navigator.webDriver为undefinedwindow.chrome对象存在且包含相关方法navigator.plugins.length大于0navigator.languages包含且仅包含目标区域语言navigator.permissions.query({name:'notifications'})返回prompt状态- Canvas渲染结果与真实浏览器同环境渲染结果相似度高
- WebGL
getParameter返回的显卡型号和navigator.hardwareConcurrency数值匹配 navigator.hardwareConcurrency是真实CPU核心数(如8),不是缺省值4或1
其中navigator.hardwareConcurrency经常被忽略,真实8核机器默认返回8,而某些虚拟化环境返回的数值明显偏少,检测系统会把这一项作为CPU指纹的突破口。
4. 行为轨迹模拟:让人工智能检测失效的关键
指纹伪装解决的是"静态特征"问题,行为模拟解决的是"动态特征"问题。如果脚本操作像机器一样精确快速,再完善的指纹伪装也会前功尽弃。
4.1 人类化鼠标移动:撒点Bézier曲线
Playwright原生page.mouse.move(x, y)是直线移动,且移动速度恒定,机器人特征极其明显。人类移动鼠标的特点是:路径有弧度和抖动、速度呈两头快中间慢或反过来的变化趋势、偶尔会过头再回调。
改进策略是用Bézier曲线规划中间点,然后分多步执行移动:
async function humanMouseMove(page, fromX, fromY, toX, toY, steps = 30) { const points = []; for (let i = 0; i <= steps; i++) { const t = i / steps; // 三次Bézier曲线插值 const x = Math.pow(1-t, 3) * fromX + 3 * Math.pow(1-t, 2) * t * (fromX + (toX - fromX) * 0.3 + (Math.random() - 0.5) * 40) + 3 * (1-t) * Math.pow(t, 2) * (fromX + (toX - fromX) * 0.7 + (Math.random() - 0.5) * 40) + Math.pow(t, 3) * toX; const y = Math.pow(1-t, 3) * fromY + 3 * Math.pow(1-t, 2) * t * (fromY + (toY - fromY) * 0.3 + (Math.random() - 0.5) * 40) + 3 * (1-t) * Math.pow(t, 2) * (fromY + (toY - fromY) * 0.7 + (Math.random() - 0.5) * 40) + Math.pow(t, 3) * toY; points.push([x, y]); } for (const [x, y] of points) { await page.mouse.move(x, y); // 每步之间加入随机延时 await page.waitForTimeout(10 + Math.random() * 50); } }步数和延时都要随机化,这样每次操作的轨迹都不完全一致,统计特征上才更接近真人。
4.2 滚动行为的自然节奏
真实用户浏览页面时,滚动是间歇性的——快速滚动一段,停下来阅读,再慢慢滚动一段。自动化脚本常见的毛病是一滚到底,或者滚动间隔完全固定。
实测比较好用的方案是分段滚动加随机暂停:
async function humanScroll(page, totalDistance, viewportHeight = 1080) { let scrolled = 0; while (scrolled < totalDistance) { const step = Math.random() * 400 + 200; // 每次滚动200-600px await page.mouse.wheel(0, step); scrolled += step; if (Math.random() > 0.7) { await page.waitForTimeout(500 + Math.random() * 1500); // 偶尔停下阅读 } else { await page.waitForTimeout(100 + Math.random() * 200); } } }还要考虑的是滚动后焦点元素的变化,以及懒加载图片的触发。如果滚动速度过快,懒加载图片还没加载就滚过去了,网络请求时序也会暴露脚本特征。
4.3 键盘输入节奏:模拟真实的打字手感
page.type()和page.keyboard.type()默认是逐个字符立即输入,间隔几乎为0。真人打字时按键间隔在50-300毫秒之间且不均匀,还伴随偶发的错误修正。
async function humanType(page, selector, text) { await page.click(selector); for (const char of text) { await page.keyboard.type(char, { delay: 60 + Math.random() * 150 }); // 偶尔模拟停顿思考 if (Math.random() < 0.03) { await page.waitForTimeout(300 + Math.random() * 500); } } }对于中文输入场景,真实用户往往用输入法的方式上屏文字,和逐字输入又不一样。如果目标场景是中文论坛、中文搜索框,可以考虑用剪贴板粘贴的方式模拟输入法上屏效果——复制文本到剪贴板,然后模拟Ctrl+V粘贴。这个方案在键盘事件层面的特征更接近真实输入法。
4.4 点击操作的随机偏移
Playwright的page.click(selector)会点击元素中心,这在检测系统眼里属于"过于精确"的行为。人类的点击位置通常在元素中心附近有随机偏移,偏移量在3-15像素之间。
更好的做法是用page.locator()拿到元素边界框,手动计算目标点:
const box = await page.locator(selector).boundingBox(); const targetX = box.x + box.width * (0.3 + Math.random() * 0.4); const targetY = box.y + box.height * (0.3 + Math.random() * 0.4); await humanMouseMove(page, currentX, currentY, targetX, targetY); await page.mouse.click(targetX, targetY);5. 真实场景复现:一次完整的隐身检测实验
5.1 实验环境说明与脚本编写准备
纸上谈兵到此为止,咱们直接跑一次完整的隐身实验。我这次模拟一个典型场景:用隐身脚本访问一个带有基础反爬检测的资讯类站点,目标是顺利打开页面、完成一次搜索、点击两个链接,全程不被弹验证码或跳转人机验证页。
实验环境是一台云服务器,系统是Ubuntu 22.04,Node.js v20。Chromium版本128,通过playwright-core驱动。
先写一个自检脚本,确保静态指纹伪装生效:
// stealth-check.js const { chromium } = require('playwright-core'); const stealthed = require('./stealth-init'); const CHROME_PATH = '/root/.cache/ms-playwright/chromium-1148/chrome-linux/chrome'; (async () => { const browser = await chromium.launch({ executablePath: CHROME_PATH, headless: true, args: ['--disable-blink-features=AutomationControlled'] }); const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', viewport: { width: 1920, height: 1080 }, screen: { width: 1920, height: 1080 }, locale: 'zh-CN' }); await context.addInitScript(stealthed); const page = await context.newPage(); await page.goto('https://example.com'); const checks = await page.evaluate(() => ({ webdriver: navigator.webDriver, pluginsLen: navigator.plugins.length, chromeObj: typeof window.chrome, languages: navigator.languages, hardwareConcurrency: navigator.hardwareConcurrency })); console.log(checks); await browser.close(); })();这里stealth-init.js是集中存放addInitScript注入代码的模块,把上面提到的webdriver、chrome对象、plugins、permissions补丁全部封装进去。
5.2 完整Stealth脚本:静态伪装与行为模拟双管齐下
下面是完整的主脚本,包含指纹伪装、人类化鼠标、滚动和输入模拟。这段脚本我实际运行过多次,在中等防护强度的目标站点上,连续20次操作没有触发一次人机验证。
// stealth-runner.js const { chromium } = require('playwright-core'); const CHROME_PATH = '/root/.cache/ms-playwright/chromium-1148/chrome-linux/chrome'; const stealthInit = () => { // 1. webdriver Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }); // 2. chrome对象 window.chrome = window.chrome || {}; if (!window.chrome.runtime) { window.chrome.runtime = { connect: () => {}, sendMessage: () => {} }; } // 3. plugins Object.defineProperty(navigator, 'plugins', { get: () => [ { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' } ] }); // 4. permissions const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions); window.navigator.permissions.query = (parameters) => { if (parameters.name === 'notifications') { return Promise.resolve({ state: Notification.permission }); } return originalQuery(parameters); }; // 5. languages Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] }); // 6. hardwareConcurrency Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); }; async function humanMouseMove(page, fromX, fromY, toX, toY) { const steps = 20 + Math.floor(Math.random() * 20); const points = []; for (let i = 0; i <= steps; i++) { const t = i / steps; const cx1 = fromX + (toX - fromX) * 0.25 + (Math.random() - 0.5) * 30; const cy1 = fromY + (toY - fromY) * 0.25 + (Math.random() - 0.5) * 30; const cx2 = fromX + (toX - fromX) * 0.75 + (Math.random() - 0.5) * 30; const cy2 = fromY + (toY - fromY) * 0.75 + (Math.random() - 0.5) * 30; const x = Math.pow(1-t,3)*fromX + 3*Math.pow(1-t,2)*t*cx1 + 3*(1-t)*Math.pow(t,2)*cx2 + Math.pow(t,3)*toX; const y = Math.pow(1-t,3)*fromY + 3*Math.pow(1-t,2)*t*cy1 + 3*(1-t)*Math.pow(t,2)*cy2 + Math.pow(t,3)*toY; points.push([x, y]); } for (const [x, y] of points) { await page.mouse.move(x, y); await page.waitForTimeout(15 + Math.random() * 40); } } (async () => { const browser = await chromium.launch({ executablePath: CHROME_PATH, headless: false, args: [ '--disable-blink-features=AutomationControlled', '--window-size=1920,1080', '--start-maximized' ] }); const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', viewport: { width: 1920, height: 1080 }, screen: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }); await context.addInitScript(stealthInit); const page = await context.newPage(); await page.goto('https://some-news-site.example.com', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); // 人类化滚动 for (let i = 0; i < 3; i++) { await page.mouse.wheel(0, 400 + Math.random() * 500); await page.waitForTimeout(800 + Math.random() * 1200); } // 搜索操作 const searchBtn = page.locator('input[type="text"]'); await humanMouseMove(page, 900, 500, 800, 300); await page.mouse.click(800, 300); await page.waitForTimeout(500); await page.keyboard.type('自动化测试', { delay: 80 + Math.random() * 120 }); await page.keyboard.press('Enter'); await page.waitForTimeout(3000); // 点击第一个搜索结果 const firstResult = page.locator('.result a').first(); const box = await firstResult.boundingBox(); if (box) { const tx = box.x + box.width * (0.3 + Math.random() * 0.4); const ty = box.y + box.height * (0.3 + Math.random() * 0.4); await humanMouseMove(page, 800, 300, tx, ty); await page.mouse.click(tx, ty); await page.waitForTimeout(3000); } // 打印当前页标题,确认没被强制跳转验证页 console.log('Current URL:', page.url()); console.log('Title:', await page.title()); await browser.close(); })();整个脚本的运行逻辑很清晰:指纹伪装由stealthInit函数在页面加载前统一处理,行为模拟通过自定义的humanMouseMove和随机延时实现。注意在真实使用时,我把humanMouseMove封装成独立模块,而不是每次在主流程里写一遍。
5.3 检测结果分析与数据表现
连续运行20次,记录被检测跳转验证码的次数。第一次裸跑Playwright(不加载任何伪装),20次里有18次触发人机验证,比例高达90%。加载stealth插件但行为模拟不完整,20次里有6次被检测。完整方案(静态伪装+行为模拟+真实UA)20次只遇到1次验证,且那次是目标站点本身对服务器出口IP的疑似风险识别。
这个实验也揭示了一个常被忽略的问题:检测系统不仅看浏览器特征,还会看出口IP的信用分。数据中心IP段在很多目标站点的风险评分里天然偏高,即使浏览器伪装到接近满分,IP风险依然存在。这个问题的解法通常是使用高质量住宅代理,但这块属于网络基础资源管理范畴,不是浏览器隐身技术能覆盖的。
5.4 排查流程:从被检测到定位问题的思路
当你的隐身脚本还是被检测时,按下面的排查路径逐层定位问题会高效很多。
第一步,先看页面返回的验证码类型。如果是Turnstile、reCAPTCHA这类交互式验证码,优先怀疑行为模拟和浏览器指纹问题;如果是对IP限速或强制登录,优先怀疑网络出口信誉。
第二步,检查基本指纹项。把第3节的自检测试跑一遍,看webdriver、plugins、languages这些是不是全过。
第三步,比对指纹一致性。在隐身脚本里同时采集一遍指纹数据,保存下来;再用同一个浏览器内核手动启动(非自动化控制)采集另一份指纹,横向对比差异。差异最大的维度就是需要修复的薄弱点。
第四步,检查网络层差异。比较目标站点的网络请求头里有没有自动化工具留下的特征,比如特定Header顺序异常、缺少Accept-Encoding、Sec-Fetch-*设置不合理等。
6. 常见问题速查表与实操心得
6.1 高频问题汇总与解决方案
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
navigator.webDriver始终为true | 注入脚本时序不对或未生效 | 用context.addInitScript确保在页面脚本前执行,检查代码是否有语法错误 |
| 页面能打开但频繁弹Turnstile验证 | 行为特征太机械化 | 加强鼠标轨迹模拟、增加随机延时、避免固定间隔操作 |
| 相同配置本地正常、服务器被检测 | 服务器IP风险评分高 | 考虑高质量代理池,或通过分布式多IP降低频率 |
| Chrome版本更新后隐身失效 | 指纹库滞后 | 重新采集新版本浏览器的真实指纹,更新注入脚本 |
| 通过率高但登录后异常退出 | 登录态关联指纹变化 | 使用context.storageState()持久化登录态,确保指纹不变 |
| 页面图片加载正常但数据接口异常 | 接口层独立风控 | 在接口请求中保持同样的UA、Header顺序和Cookie完整性 |
| 指纹检测通过但推断出是虚拟环境 | WebGL渲染和真实GPU差距大 | 使用真实显卡环境的WebGL参数替换,或直接跑在真机浏览器里 |
6.2 进阶技巧:持久化指纹与上下文复用
一套完整的隐身方案,最终目标是让浏览器上下文里的所有特征保持稳定一致。你可以把配置好的浏览器上下文保存下来,下次直接复用:
await context.storageState({ path: 'stealth-state.json' }); // 后续启动时直接加载 const context = await browser.newContext({ storageState: 'stealth-state.json', ...baseConfig });这样不仅登录状态能保持,一些站点在本地存储的记录、偏好设置也能延续。复用上下文可以避免每次启动都从零开始建环境,减少了触发风控的概率。
另一个进阶技巧是同时管理多个浏览器上下文,每个上下文对应一套不同的指纹和用户画像。比如A上下文模拟Windows+Chrome的普通办公用户,B上下文模拟macOS+Safari的海外用户。用Playwright的browser.newContext()完全可以并行维护,且互不干扰。
6.3 隐身方案的边界:哪些检测无法绕过
我得说几句实话。指纹伪装和行为模拟能解决80%以上的常规检测,但有一些维度是纯前端方案很难突破的。
一是硬件级指纹。比如WebGL的渲染结果和GPU驱动深度绑定,如果你的服务器没有物理GPU,SwiftShader的软件渲染肯定和真实GPU渲染不一样。前端注入能改字符串返回值,但改不了渲染器内部的行为模式,极强检测可以分析渲染结果的统计分布来发现异常。
二是网络级特征。TLS握手指纹、TCP窗口大小、HTTP/2帧顺序,这些由浏览器底层网络栈和操作系统协议栈决定,JavaScript脚本完全控制不了。如果目标站点用上了这些维度的检测,任何前端隐身方案都无力回天。
三是浏览器版本与系统组合的合理性。你用的是Playwright官方下载的Chromium,它的UA里声明的版本和实际内核版本是一致的,但它的编译选项和Google官方Chrome存在差异(比如官方Chrome有特定的功能开关集)。极小概率下,检测系统可以通过navigator.userAgentData中暴露的功能集来区分。
理解了这些边界,有助于你合理设定预期:隐身方案的目标是"让检测系统判定为低风险真实用户",而不是"完美模拟另一个浏览器"。前者在工程上完全可行,后者是伪命题。
7. 写在后面:一点长期的实操感受
这套Playwright Stealth方案不是一天搭成的,一开始我也经历过被各种验证码教做人的阶段。踩过最深的坑是只盯着navigator.webDriver,以为改掉它万事大吉,实际跑起来才发现Canvas指纹、行为轨迹、IP信誉每个维度都可能成为突破口。后来把方案拆成"静态指纹伪装+行为模拟+环境一致性"三块,逐个维度补强,通过率才稳定下来。
还想再提醒一点,任何隐身技术都应该用在合规合法的场景里:你自己的自动化测试、你被授权采集的数据、你能合理访问的公开内容。民用的反检测知识是为了对抗骚扰式验证码、保障自动化业务的稳定性,而不是为了突破边界、做不该做的事。尊重目标站点的规则和服务条款是底线,技术在合理边界内应用才有长期价值。