无头浏览器爬虫怎么防?Headless 识别实战

用 User-Agent 黑名单、robots.txt 甚至简单的频率限制,都拦不住一种爬虫——它跑在无头浏览器里,能执行 JavaScript、能渲染页面,UA 还伪装成正式版 Chrome。本文用 9 个问答讲清 Headless 爬虫的特征、服务端与前端两层识别方法、JS 挑战与指纹打分的落地方式,以及怎么设置处置阶梯避免误伤真实用户。

Q:无头浏览器爬虫是什么?为什么普通手段拦不住?

无头浏览器是指没有图形界面的浏览器内核,常见实现有 Chrome Headless、Firefox Headless,通常由 Puppeteer、Playwright、Selenium 这类框架驱动。

它和传统 HTTP 爬虫(requests、curl、Scrapy)的本质区别在于:传统爬虫只能拿到服务器返回的原始 HTML,无头浏览器能完整执行 JS、调用接口、渲染出最终页面。这带来三个后果:

  • robots.txt 只对守规矩的爬虫有效,恶意爬虫根本不会读;
  • UA 黑名单也会失效,因为它可以把 UA 改成正式版 Chrome;
  • 单看请求频率同样容易误判,因为它会控制速率、随机延时,甚至带上静态资源请求。

Q:无头浏览器有哪些可识别的特征?

  • UA 特征:默认 UA 里带 HeadlessChrome;即使改掉,也可能与 Sec-CH-UA 客户端提示头不一致。
  • JS 环境特征:navigator.webdriver 为 true、插件列表为空、缺少真实字体、WebGL 渲染器显示为软件渲染。
  • 请求头异常:缺少 Accept-Language、Sec-Fetch-*,或头字段顺序与真实浏览器不同。
  • TLS 指纹:JA3/JA4 指纹与真实 Chrome 不一致,用 Python 客户端发起时尤其明显。
  • 行为特征:没有鼠标移动与键盘事件、页面停留极短、不读站内链接只按枚举翻页。

单一特征都可能被绕过,实际判定要多特征打分,而不是命中一个就封。

Q:服务端能先做哪些粗筛?

Nginx 层可以做成本最低的两件事:拦默认 Headless UA,以及拦”声称是 Chrome 却缺少客户端提示头”的请求。

map $http_user_agent $is_headless {
    default 0;
    "~*HeadlessChrome|HeadlessFirefox|PhantomJS|puppeteer|playwright|selenium" 1;
}

map $http_sec_ch_ua $ch_missing {
    default 0;
    ""      1;   # 空值记 1,说明没带客户端提示头
}

server {
    # 默认无头 UA 直接拒绝
    if ($is_headless) { return 403; }

    # 声称是 Chrome 却没有客户端提示头,判定为脚本伪装
    if ($http_user_agent ~* "Chrome/") {
        set $claim 1;
    }
    if ($ch_missing = 1) {
        # 交给后续降速或挑战,不要直接封
        set $suspicious 1;
    }
}

粗筛只负责把最明显的那批拦掉,剩下的交给 JS 挑战与行为分析,形成分层。

Q:JS 挑战怎么实现?

原理很简单:给客户端返回一段必须由浏览器执行的 JS,算出一次性 token 再带回来校验。传统 HTTP 客户端不会执行 JS,拿不到 token。

  • 首次请求命中可疑规则 → 返回挑战页,页面里带一个 challenge_id。
  • JS 执行计算(简单哈希或工作量证明),把结果提交到校验接口。
  • 校验通过 → 下发短期通行 Cookie(例如 30 分钟),并做弱绑定。
  • 校验失败或超时 → 降速或再次挑战。

三个要点必须遵守:挑战必须一次性且有有效期,否则会被重放;绑定要用弱绑定(IP 段加 UA),否则同一出口的多个正常用户会被反复挑战;不要把挑战做成硬性关卡,先降速、再挑战。

Q:指纹采集怎么做才不误伤?

前端采集一批”真实浏览器才有、脚本很难完全伪造”的数值,组成特征向量:

  • Canvas 与 WebGL 指纹:显卡与驱动差异会让渲染结果不同。
  • 字体与插件列表:无头环境通常为空或极少。
  • 屏幕与窗口参数:分辨率、颜色深度、时区、语言。
  • 一致性校验:系统语言是中文但 IP 归属海外、时区与 IP 时区相差数小时,这类矛盾是强信号。

关键原则:指纹只用来打分,不直接封禁。分数低先降速,更低才挑战,只有明确恶意行为才封 IP。

Q:行为层怎么区分无头爬虫和真实用户?

  • 交互事件:真实用户有鼠标移动、滚动、输入事件,无头默认一个都没有。
  • 访问路径:爬虫倾向按 ID 递增或参数枚举,抓的是站内链接里根本不存在的路径。
  • 会话深度与停留:单会话大量页面、每页停留只有几百毫秒。
  • Referer 链:真实用户有站内跳转来源,爬虫常直接命中目标 URL。

日志侧最有效的一招是集合差:只拉 HTML、从不加载 CSS/JS/图片的 IP,就是重点怀疑对象。

# 找出只拉 HTML、从不拉静态资源的 IP
comm -23 <(grep -oE "^[0-9.]+" access.log | sort -u) \
         <(grep -E "\.(css|js|png|jpg|webp|woff2)" access.log | grep -oE "^[0-9.]+" | sort -u)

Q:CDN 或 WAF 层能不能直接拦?

能,而且这是最省事的路径。主流 CDN 与 WAF 都提供 Bot 管理与 JS 挑战能力:

  • 边缘 JS 挑战:在节点上完成校验,恶意流量在边缘被消化,不消耗源站资源。
  • Bot 评分:厂商维护了浏览器指纹与 IP 信誉库,比自建更全。
  • 速率与行为规则:可以按路径、按会话维度做限速。

注意两点:一是回源段同样要限并发,别以为流量在边缘过了就不管了;二是自建方案适合有技术团队、需要精细画像的场景,纯防护目的优先选边缘方案。

Q:怎么设置处置阶梯才不会误伤?

四级阶梯,逐级加重:

  • 记录观察:只打日志、打标签,先统计一周看误伤率。
  • 降速:命中可疑特征只降速(例如 1 r/s),不影响正常访问体验。
  • 挑战:降速后仍持续异常的,下发 JS 挑战或验证码。
  • 封禁:明确恶意行为(枚举、镜像、高并发抓取)才封 IP,且必须带 timeout 自动解封。
# ipset 带超时的封禁,到期自动解封,避免永久误封
ipset create crawler_block hash:ip timeout 3600
iptables -I INPUT -m set --match-set crawler_block src -j DROP

白名单要按路径级放行(搜索引擎蜘蛛、合作方 API、企业出口 IP),而不是全站放行;规则变更要走灰度,先观察后全量。

Q:还有什么补充建议?

  • robots.txt 还是要写:它挡不住恶意爬虫,但能划清”未经许可抓取”的边界,对后续维权有帮助。
  • 数据接口加签名与频控:页面能伪装,数据接口被高频调用就是明证,接口层限流别省。
  • 部署蜜罐链接:页面里埋一个用户看不到的链接,正常用户不会访问,爬虫会——命中即自动封禁。
  • 日志留存与取证:保留原始访问日志,必要时用于平台投诉或法律途径。
  • 别指望一招制敌:无头浏览器在持续演化,防护要”粗筛 + 挑战 + 行为分析”三层叠加,并定期回看误伤数据。

一句话总结:无头浏览器爬虫的优势是像人,弱点也是像人——它模仿得了渲染,模仿不了鼠标轨迹、指纹一致性与会话行为;识别靠多特征打分,处置靠先降速、后挑战、最后封禁。