⭐ 推荐:社区规则条款 V1.0

如何通过检测无头浏览器拦截采集脚本?

萝卜特棒
萝卜特棒 正式会员萌新站长
发布于 2026-10-16 00:13 ·3 浏览 ·3 回复
内容摘要

拦截采集脚本需将浏览器内核特征、TLS/HTTP2 网络指纹与行为验证三层信号加权评分并分级处置,因 UA 等单一特征已失效,仅三层同时命中才能拦住 Puppeteer 等无头浏览器。

拦截采集脚本的正确思路不是找一个「无头浏览器检测点」,而是把浏览器内核特征、网络层指纹、行为特征三层信号加权打分,超过阈值再分级处置。结论:单一特征(尤其 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 与鼠标轨迹负责兜底;单层都会被绕过,三层加权 + 分级处置才是可落地的方案。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2146.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

域名注册
域名注册 版主萌新站长 👑黑卡会员 1楼 2026-10-16 00:20

前端 JS 那 6 颗子弹只能算初筛,真正扛得住的是 TLS/HTTP2 指纹这一层——采集方随手换个 undetected-chromedriver 或 patchright,你列的 6 个点基本能全抹平,但 TLS ClientHello 里的扩展顺序他改不动。

所以 JS 层权重别给超过 30%。navigator.webdriver 现在一行 --disable-blink-features=AutomationControlled 就没了,WebGL renderer 也能用 --use-gl=angle 换成真实显卡串。这层真正的价值是配合网络层做「自相矛盾」判定:UA 声称 Chrome 却拖着 OpenSSL 指纹,这才是硬信号。

网络层至少要算两个东西。一是 JA3/JA4——TLS ClientHello 里的 cipher suites 顺序、扩展顺序、椭圆曲线、ALPN,Python requests、Go net/http、curl 的指纹跟真 Chrome 差得十万八千里,requests 直接就是 OpenSSL 默认脸。

juming
juming 初级会员萌新站长 #1980 2楼 2026-10-16 00:29
域名注册:前端 JS 那 6 颗子弹只能算初筛,真正扛得住的是 TLS/HTTP2 指纹这一层——采集方随手换个 undetected-chromedriver 或 pa…

TLS 这层权重我赞成给到 40% 以上,但有个前提你得先确认:你的站点到底能不能在握手那一层拿到原始 ClientHello。很多同行的 JA3 方案死在这一步——用了 CDN 或者 nginx 直接 TLS 终结,指纹在 CDN 边缘节点就被换成 CDN 自己的了,后端反代拿到的全是 Cloudflare/又拍/七牛的 JA3,全员同分,规则直接失效。

具体落地上,nginx 官方模块其实读不到 ClientHello 原始字节,得上 OpenResty + lua-resty-ja3,在 ssl_client_hello_by_lua 阶段取,或者在 SLB 前面挂一层 TCP 透传网关。JA3 还有个已知短板:对手用 utls、curl-impersonate 能直接复用 Chrome 的 TLS 栈,JA3 就伪出来了,所以别把它当唯一硬信号。

比 JA3 更难伪的是 HTTP/2 指纹——SETTINGS 帧里 HEADER_TABLE_SIZE、ENABLE_PUSH、MAX_CONCURRENT_STREAMS、INITIAL_WINDOW_SIZE 这组参数加伪头顺序、优先级帧树,Chrome 的取值组合非常固定。Python requests 压根发不出 h2,Go net/http 也不建优先级树,这个比 JA3 抗造得多,建议和 JA3 一起加权。

所以我会这么分:HTTP/2 帧指纹 25%、TLS/JA3 40%、JS 初筛 20%、行为 15%,自相矛盾项(比如 h2 像 Chrome 但 JA3 是 OpenSSL)直接翻倍计分。延伸一句:真要上,先在日志里跑一周只记录不拦截,把你们正常用户的误杀基线摸出来再开阈值,别一上来就封。

chinaz
chinaz 初级会员萌新站长 #1984 3楼 2026-10-16 00:48
juming:TLS 这层权重我赞成给到 40% 以上,但有个前提你得先确认:你的站点到底能不能在握手那一层拿到原始 ClientHello。很多同行的 JA3 方案死在这一…

你这套权重我基本照抄,但有一处得拆开:JA3 不适合当独立打分项,更适合当「一致性校验项」。utls、curl-impersonate 直接复用 Chrome 的 TLS 栈,JA3 单看就是满分,给它 40% 独立权重等于给对手送分。改成「JA3 基础分 + JA3↔h2↔JS 声称版本三者自洽性」两段计分,伪造成本就从「换 TLS 栈」抬到「TLS、h2 帧、JS 环境同时换」,能做到全套的没几家。另外 JA4 可以关注,做了排序归一化,比 JA3 抗伪一档。

ClientHello 那步你说得对,落地就两条路:源站前置 OpenResty 加 lua-resty-ja3,在 ssl_client_hello_by_lua 阶段取;或者四层 TCP 透传,把 TLS 终结权收回源站。代价是丢掉 CDN 的证书卸载和源站隐藏,一般只在风控域名上单开,静态资源域名照旧走 CDN。

行为那 15% 建议改成动态权重:新会话前三五秒根本没行为数据,硬套 15% 会让首屏请求集体漏检,等会话满 N 个请求或 N 秒后再把行为权重提上来。

只记录不拦截那一周,除了误杀率我还建议盯一个指标——正常用户里 Chrome 的 h2 SETTINGS 组合有几种。如果超过十种,说明你们中间链路在改写,这套权重得整盘重调,光看误杀率是看不出来的。

最后补个坑:开了 HTTP/3 的站点,h2 帧规则整条失效,QUIC 的 Initial 包是另一套指纹,别忘了给 h3 来的请求单独分流。