拦截采集脚本的正确思路不是找一个「无头浏览器检测点」,而是把浏览器内核特征、网络层指纹、行为特征三层信号加权打分,超过阈值再分级处置。结论:单一特征(尤其 User-Agent)拦不住任何认真写的采集脚本,只有「JS 环境指纹 + TLS/HTTP2 指纹 + 行为验证」三层同时命中,才能把 Puppeteer/Playwright/Selenium 拦在门外,且误杀率能压到千分之一以内。
只查 User-Agent 为什么拦不住无头浏览器?
结论:UA 是最早失效的检测点,Chrome 112 之后的新版 headless 模式 UA 与正常 Chrome 完全一致。旧版 --headless(Chrome 111 及之前)会在 UA 里暴露 HeadlessChrome/120.0.0.0,这种一眼就能拦,但脚本只要手动改 UA 就绕过了。Puppeteer、Playwright 默认也允许覆盖 UA,所以靠字符串匹配的拦截规则在 2023 年后基本等于没写。
真正还有价值的是「UA 与其它指纹自相矛盾」:UA 声称 Chrome 120,但 WebGL renderer 返回 SwiftShader、屏幕是 800×600、没有插件、没有字体渲染差异——这组矛盾才是强信号。
前端 JS 里有哪些能直接判断无头浏览器的特征?
结论:有 6 个低成本、高命中率的检测点,加起来 20 行 JS 就能给出一个初筛分数。以下判断在 --headless 旧模式下命中率接近 100%,在 --headless=new 下仍能命中 3 项以上:
const score = [];
if (navigator.webdriver) score.push('webdriver'); // 1
if (!window.chrome?.runtime) score.push('no_chrome_runtime'); // 2
if (navigator.plugins.length === 0) score.push('no_plugins'); // 3
if (navigator.languages.length === 0) score.push('no_lang'); // 4 旧headless常见
// 5 通知权限:真实 Chrome 返回 'prompt',headless 返回 'denied'
navigator.permissions.query({name:'notifications'})
.then(p => p.state === 'denied' && score.push('perm'));
// 6 WebGL 软件渲染
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const r = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
if (/SwiftShader|llvmpipe|Mesa/i.test(r)) score.push('swiftshader');
WEBGL_debug_renderer_info 是 WebGL 扩展,用来读取显卡厂商和型号字符串。无头环境默认走 SwiftShader(Google 的软件光栅化库),字符串里带这个词基本可以判死。注意要同时校验 window.outerWidth === 0(旧 headless 常见)和 navigator.hardwareConcurrency <= 1,这两项在容器里跑的无头浏览器命中率也高。
网络层指纹怎么区分采集脚本和真实浏览器?
结论:JA3/JA4 和 HTTP/2 指纹是服务端最硬的判据,因为 JS 改不了 TLS 握手报文。Python 的 requests、Go 的 net/http 默认 JA3 哈希与 Chrome 差得很远;即使脚本套了真 Chrome,也必须在 TLS 层完整复刻 cipher suite 顺序、扩展顺序、椭圆曲线列表才能对上。
具体做法:在 Nginx/OpenResty 上用 lua-resty-ja3 或直接读 $ssl_ciphers、$ssl_protocol 拼出 JA3 哈希,与「UA 声称的浏览器版本对应的已知 JA3 列表」比对,不一致就扣分。HTTP/2 层再看 SETTINGS 帧的参数顺序和窗口大小(Chrome 与 python-h2、curl 的默认值都不同)。请求头顺序也是信号:真实 Chrome 的 header 顺序是固定的,早期 Puppeteer 发出的顺序和大小写都对不上。这三个信号都在 TLS/HTTP 层,脚本改不了,只能靠 uTLS 之类的库去仿,成本很高。
无头浏览器能伪装掉哪些特征?该怎么对抗?
结论:puppeteer-extra-plugin-stealth 能一次性修补约 20 项 JS 特征(包括 navigator.webdriver、window.chrome、插件列表、WebGL 字符串),所以在 2024 年之后,纯前端 JS 检测已经被大幅削弱,必须靠行为层和服务端指纹兜底。
还有一类更隐蔽的检测是 CDP(Chrome DevTools Protocol)探测:Puppeteer 和 Playwright 必须调用 Runtime.enable,这会让页面里注入的探针通过 console.debug 触发,并在 Error().stack 中泄露 puppeteer_evaluation_script 或 __playwright_utility_world__ 字样。专门修这类泄露的工具是 patchright(Playwright 分支)和 undetected-chromedriver(Selenium 分支),它们能绕过大部分 CDP 检测——所以你的检测逻辑不能只依赖这一层。
行为特征怎么做才能既拦得住又不误伤?
结论:行为验证要抓「不可能由脚本产生的物理特征」,最直接的一条是 event.isTrusted。用 dispatchEvent 派发的 mousemove、click、keydown,isTrusted 恒为 false,而真实用户操作恒为 true,这一条几乎无法伪造。
再往上是轨迹统计:真实鼠标移动有加速—减速曲线和 1-3 像素的抖动,脚本常见的是直线插值或瞬移;采集脚本的表单填写间隔方差极小,真实用户方差大;访问时间分布上,脚本常集中在 0-6 点。把这些做成滑动窗口特征,和前面的分数一起进模型。
最后是处置分级,避免一刀切:
- 分数 0-30:直接放行
- 30-70:无感验证(Cloudflare Turnstile 不可见模式、reCAPTCHA v3 打分)
- 70-90:弹显式验证码或加延时
- 90 以上:返回 403 并对该 IP/指纹段限速
需要提醒的是,Brave、Firefox 的 privacy.resistFingerprinting、Tor Browser 会主动归一化 UA 和 Canvas 结果,天然带「反指纹」特征,很容易被误判成 bot。所以永远不要用单一特征封禁,一定要走加权评分 + 灰度观察,先记录一周日志、统计误杀率再上线拦截。
总结:UA 已经不可用,能用的三层是——前端 JS 环境特征(webdriver、chrome.runtime、SwiftShader、权限状态)负责初筛,TLS 层的 JA3/JA4 和 HTTP/2 指纹负责在服务端判死,行为层的 isTrusted 与鼠标轨迹负责兜底;单层都会被绕过,三层加权 + 分级处置才是可落地的方案。