自动操控浏览器时如何避免被反爬探测

问题的根源

当你用 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.pyweibo_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.pyconnect_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 数组检查这类传统手段。

但它不是万能的。以下几个场景它解决不了:

  1. Cloudflare Turnstile / PerimeterX / DataDome — 这些高端的反爬系统不只是检查浏览器属性。它们会分析鼠标移动、点击间隔、视口大小的典型"自动化比例"、甚至 WebGL canvas 指纹。要绕过这些需要更复杂的手段(真人鼠标轨迹录制重放、代理 IP 轮换等)。

  2. TLS 指纹 — CDP 本身不修改浏览器的 TLS 握手特征。某些 WAF(如 Cloudflare)会检查 JA3/JA3S 指纹,识别出是 Chromium 的请求。这个层面要用 curl_cffi(模拟 Chrome TLS 指纹)或者转发到真实浏览器。

  3. 浏览器版本号navigator.userAgent 是随 Chrome 版本真实变化的,但 navigator.plugins 的伪造版本可能和当前 Chrome 的实际插件版本不匹配,过于精确的检测可以察觉。

  4. 浏览器自动化框架残留 — 如果用了 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 的登录态检测均未触发验证码,说明对大部分目标网站已经足够。