问题的根源
当你用 Chrome DevTools Protocol(CDP)远程操控浏览器时——不管是用 Puppeteer、Playwright、Pydoll,还是直接连 WebSocket 发 CDP 命令——浏览器会主动暴露一个信号:navigator.webdriver = true。
这是 Chrome 内置的"我不是真人"标记。只要是以 --remote-debugging-port 模式启动的 Chrome,它都会自动设上,不给关。
除了 navigator.webdriver,反爬引擎还会检查:
window.chrome.runtime是否存在(真浏览器有,无头/自动化工具没有)navigator.plugins数组长度(真浏览器有 3-5 个插件,自动化浏览器是 0)navigator.languages格式(真浏览器按顺序排列,自动化工具可能是单语言)- WebGL 渲染器字符串(暴露 VM/容器环境)
navigator.permissions.query的行为差异document.documentElement上的webdriver属性- 鼠标移动轨迹、**浏览器窗口尺寸比例、触屏事件支持等行为学特征
这篇文章不讲行为模拟——那是另一层的话题——只讲如何从浏览器和 CDP 层面消除这些静态指纹。
两层策略
第一层:启动参数(浏览器级)
Chrome 有个编译特性叫 AutomationControlled,当检测到远程调试端口开启时,它会触发 navigator.webdriver = true。关掉它的方式就是启动时加这个参数:
--disable-blink-features=AutomationControlled
加上这个参数后,Chrome 进程级别就不再设置 navigator.webdriver 标记了。这是最基础也是性价比最高的一步。
实际操作中,如果你的 CDP 浏览器是通过 .app 包装器启动的(macOS 上常见),直接改启动脚本:
#!/bin/bash
exec arch -arm64 /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9111 \
--remote-allow-origins=* \
--disable-blink-features=AutomationControlled \
--user-data-dir="$HOME/Library/Application Support/Google/Chrome-CDP"
有一个坑:Chrome 的 SingletonLock 机制。如果 Chrome 已经在运行并占用了这个 --user-data-dir,新启动的进程会附着到正在运行的实例上,完全忽略你给的新参数。所以改完参数后必须完全退出 Chrome 再重新启动(pkill -f "Chrome-CDP" 不够,它只杀了包装脚本,杀不死真正的 Chrome 进程。要用 osascript -e 'quit app "Chrome-CDP"' 或者直接 kill PID)。
第二层:JS 注入(页面级)
启动参数只解决了 navigator.webdriver 一个问题。剩下的指纹需要在页面加载之前注入伪装脚本。
CDP 提供的方法叫 Page.addScriptToEvaluateOnNewDocument。它会在每一个新文档(包括 iframe)创建时、在任何页面 JS 运行之前,执行你提供的脚本。效果等同于 Playwright 的 add_init_script()。
需要伪装的检测点:
| 检测点 | 伪装值 |
|---|---|
navigator.webdriver |
undefined |
window.chrome.runtime |
fake runtime + connect/sendMessage 空函数 |
navigator.plugins |
3 项数组(PDF Viewer, PDF Plugin, Native Client) |
navigator.languages |
['zh-CN', 'zh', 'en', 'en-US'] |
Permissions.query(notifications) |
拦截,返回 state: 'prompt' |
| WebGL renderer | Apple M4 GPU / Apple Inc. |
documentElement[webdriver] |
如果有,删除 |
我们的 Python 实现
我们有两种 CDP 使用场景,分别走了不同的注入路径。
场景 A:裸 WebSocket 直连
部分脚本直接 create_connection() 连 WebSocket 发 CDP 命令。这是最轻量的方式,但也最"裸露"。给这类脚本写了一个通用的 apply_stealth(ws) 函数,放在 ~/.hermes/scripts/cdp_stealth.py。
调用时机:在 Page.enable 之后、Page.navigate 之前。
from websocket import create_connection
from cdp_stealth import apply_stealth
ws = create_connection(ws_url, timeout=15)
ws.settimeout(10)
# 1. 启用 Page 域
ws.send(json.dumps({"id": 1, "method": "Page.enable"}))
while json.loads(ws.recv()).get("id") != 1: pass
# 2. 注入 stealth —— 必须在 navigate 之前
apply_stealth(ws)
# 3. 导航到目标页面
ws.send(json.dumps({
"id": 2, "method": "Page.navigate",
"params": {"url": "https://example.com"}
}))
apply_stealth() 内部发的是:
Page.addScriptToEvaluateOnNewDocument → source=STEALTH_JS
这个命令返回一个 scriptId,之后可以 removeScriptToEvaluateOnNewDocument 卸载。
场景 B:Pydoll 库
twitter_fetch.py 和 weibo_fetch.py 通过 pydoll 库操作浏览器。pydoll 没有暴露 add_init_script 的 API,但底层的 PageCommands.add_script_to_evaluate_on_new_document 命令是可用的。
通过 Tab 对象的 _connection_handler.execute_command() 直接发送:
from pydoll.commands import PageCommands
tab = await browser.connect(ws_address=ws_url)
# 注入 stealth(必须在 go_to 之前)
cmd = PageCommands.add_script_to_evaluate_on_new_document(source=STEALTH_JS)
conn = tab._connection_handler
await conn.execute_command(cmd)
await tab.go_to("https://x.com/home")
这种做法不算优雅(用了内部 _connection_handler),但功能上完整,且不影响 pydoll 的其他生命周期。
我们把这个功能封装到了 cdp_manager.py 的 connect_pydoll() 函数里,所以所有走这个 helper 的调用方(微博、Twitter 抓取脚本)都自动带上了 stealth,不需要每个脚本单独改。
完整 stealth 脚本
下面是实际在用的 JS 注入内容,覆盖了 7 个检测点:
(function() {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
if (!window.chrome) {
window.chrome = { runtime: {
connect: function(){}, sendMessage: function(){},
onMessage: { addListener: function(){} },
onConnect: { addListener: function(){} },
onMessageExternal: { addListener: function(){} },
lastError: undefined,
id: 'nkeimhogjdpnpccoofpliimaahmaaome',
}, loadTimes: function(){}, csi: function(){},
app: { isInstalled: false } };
}
Object.defineProperty(navigator, 'plugins', {
get: function() { return [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
]; },
});
Object.defineProperty(navigator, 'languages', {
get: function() { return ['zh-CN', 'zh', 'en', 'en-US']; },
});
try {
var _oq = window.navigator.permissions.query.bind(window.navigator.permissions);
window.navigator.permissions.query = function(p) {
if (p && p.name === 'notifications')
return Promise.resolve({ state: 'prompt', onchange: null });
return _oq(p);
};
} catch(e) {}
try {
var _gp = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(p) {
if (p === 37446) return 'Apple M4 GPU';
if (p === 37445) return 'Apple Inc.';
return _gp.call(this, p);
};
} catch(e) {}
try {
var _a = document.documentElement.getAttribute('webdriver');
if (_a !== null) document.documentElement.removeAttribute('webdriver');
} catch(e) {}
})()
效果验证
通过 Runtime.evaluate 检查各检测点的返回:
from cdp_stealth import verify_stealth
status = verify_stealth(ws)
# status = {
# "webdriver": None,
# "chrome": True,
# "plugins_len": 3,
# "languages": ["zh-CN", "zh", "en", "en-US"],
# "passed": True
# }
实测结果(Chrome 149 + Apple M4):
🔒 Applying stealth...
Script ID: 1
🔍 Verifying stealth on https://www.bing.com...
✅ Stealth active:
navigator.webdriver = None
window.chrome = True
navigator.plugins.length = 3
navigator.languages = ['zh-CN', 'zh', 'en', 'en-US']
局限性
这套方案能有效绕过多数的静态反爬检测——尤其是针对 WebDriver 检测、Chrome Runtime 校验、Plugin 数组检查这类传统手段。
但它不是万能的。以下几个场景它解决不了:
-
Cloudflare Turnstile / PerimeterX / DataDome — 这些高端的反爬系统不只是检查浏览器属性。它们会分析鼠标移动、点击间隔、视口大小的典型"自动化比例"、甚至 WebGL canvas 指纹。要绕过这些需要更复杂的手段(真人鼠标轨迹录制重放、代理 IP 轮换等)。
-
TLS 指纹 — CDP 本身不修改浏览器的 TLS 握手特征。某些 WAF(如 Cloudflare)会检查 JA3/JA3S 指纹,识别出是 Chromium 的请求。这个层面要用
curl_cffi(模拟 Chrome TLS 指纹)或者转发到真实浏览器。 -
浏览器版本号 —
navigator.userAgent是随 Chrome 版本真实变化的,但navigator.plugins的伪造版本可能和当前 Chrome 的实际插件版本不匹配,过于精确的检测可以察觉。 -
浏览器自动化框架残留 — 如果用了 Playwright/Puppeteer/Selenium,即使我们切了 CDP 裸连,这些框架自带的自动化痕迹也可能被检测到(Playwright 有自己的
playwright-stealth插件专门处理这些)。
最佳实践
综合来看,建议的自动化浏览器伪装策略分三个档次:
日常使用(覆盖 90% 网站):
- CDP 浏览器启动参数:
--disable-blink-features=AutomationControlled - JS 注入:
Page.addScriptToEvaluateOnNewDocument覆盖 7 个检测点 - 配合独立
user-data-dir维护登录态(避免反复验证)
对抗中等反爬(京东、知乎、微博等):
- 在上述基础上,改用 Playwright +
channel='chrome'+playwright-stealth库 - Playwright Stealth 覆盖 20+ 检测点,包括
chrome.runtime的细节模拟 - 使用真人窗口尺寸(1280x720 以上)而非默认的无头尺寸
对抗高等级反爬(Cloudflare、银行、Google 登录):
- 上面所有方案 + 代理 IP 轮换 + 随机 User-Agent + 模拟延迟
- 考虑用
curl_cffi绕过 TLS 指纹检测 - 录制真人操作轨迹回放(Selenium 的 ActionChains 模拟鼠标路径)
- 单独使用干净的 IP 环境,避免被识别为数据中心 IP
我们的 Mac mini M4 方案采用了两层结构(启动参数 + JS 注入),投入产出比最高。实际运行一周后,微博、小红书、抖音、Twitter 的登录态检测均未触发验证码,说明对大部分目标网站已经足够。