数据抓取

为什么你的抓取器还没加载页面就被封了:TLS 与 HTTP/2 指纹

干净的住宅 IP、真实的 User-Agent,却仍被瞬间封掉?反爬在 TLS 与 HTTP/2 这一层就给你的 HTTP 客户端做了指纹——在你的请求还没被读之前。

Chris Collins

Chris Collins

2026年8月10日 · 2 分钟阅读

你什么都做对了。一个干净的住宅 IP、一个真实的 Chrome User-Agent、可信的请求头、合理的速率限制。可一个重度防护的目标仍然瞬间就封了你——有时甚至在它送出真实页面的一个字节之前。你很想怪罪 IP 然后去轮换,但轮换没用——而那正是破绽。你正被在一个大多数抓取器从不去想的层上做指纹:HTTP 客户端本身,在 TLS 握手和 HTTP/2 连接上,在你的请求还没被读之前。

这是身份的第三层,独立于你的 IP,也独立于一个反检测浏览器所管理的浏览器与设备指纹。理解它,正是那个把”在容易的站点上能跑的抓取器”和”在难的站点上也能跑的抓取器”区分开来的东西。

是三层,不是一层

当一个站点判断你是不是一个真实用户时,它看三样彼此独立的东西:

  • 网络层: 你的 IP 地址及其信誉。一个数据中心 IP、或一个被标记的 IP,在这里失败。
  • 客户端层: 你的 HTTP 客户端如何打开连接——它的 TLS 握手和它的 HTTP/2 行为。一个脚本库在这里失败,哪怕从一个完美的 IP。
  • 设备层: 浏览器指纹——canvas、字体、navigator 等等。一个自动化浏览器可能在这里失败。

一个干净的住宅 IP 只解决第一层,别的什么也不解决。如果你的 TLS 握手宣告”我是 Python”,世上没有哪个 IP 能让你看起来像人。这就是为什么最难的目标在页面加载之前就封你:它们在客户端层拒掉这条连接,而 IP 根本没轮到去要紧。

TLS 指纹,或 JA3

每一条 HTTPS 连接都以一次 TLS 握手开始,而最开头的那条消息——ClientHello——泄底得惊人。在其中,客户端以一个特定的顺序,列出它所支持的确切密码套件,连同它的 TLS 扩展、支持的椭圆曲线、签名算法等等。这个组合是做这条连接的软件所特有的,而且异常稳定。

Chrome 产生一个独特的 ClientHello。Firefox 产生另一个。Python 的 requests(用系统的 OpenSSL)产生一个和两者都不像的。Go 的 net/http 产生它自己的。反爬供应商把这些值哈希成一个紧凑的指纹——众所周知的格式是 JA3 及其后继 JA4——并维护着一幅”真实浏览器长什么样”的图景。当你的 ClientHello 哈希出来的东西显然是一个脚本库、而不是一个浏览器时,你在握手时就被标记了——在你送出一行请求、一个请求头、或一个 cookie 之前。

残酷之处,在于它暴露的那个矛盾。你设了 User-Agent: Chrome,但你的 TLS 指纹却说 OpenSSL-经-Python。一个真实的 Chrome 绝不会产生那个组合,所以这个错配不只是一次失败的伪装——它是一个”有东西在假装”的正向信号。你比根本没送 User-Agent 时还要糟。

HTTP/2 指纹

假设你的 TLS 指纹很有说服力。就在它后面还有一层。真实浏览器讲 HTTP/2,而它们怎么讲,也是一个指纹。当一条 HTTP/2 连接打开时,客户端会送出一个带着特定值的 SETTINGS 帧(头表大小、最大并发流数、初始窗口大小),宣告一个特定的窗口更新,并以一种特有的方式排列它的伪头(:method:authority:scheme:path)。浏览器甚至以一种可识别的模式发送流优先级信息。

HTTP 客户端库会以泄底的方式把这些细节搞错,或者干脆退回到 HTTP/1.1——而这在一个现代站点上本身就可疑。所以一个抓取器可能通过了 TLS 检查、却仍在上面一层被抓住,因为它的 HTTP/2 SETTINGS 和请求头顺序与任何真实浏览器都对不上。而在朴素的 HTTP/1.1 上,你请求头的顺序和大小写又是一个指纹:浏览器以一种一致的顺序发送请求头,而大多数库并不复现它。

为什么设置请求头修不好它

这件事之所以如此顽固,是因为它没有一样活在你能设置的那些值里。你可以伪造一个 User-Agent、加一个 Accept-Language、把一个浏览器的请求头列表一字不差地抄过来——却仍被抓住,因为指纹是连接如何被建立的属性,而不是你在请求里放了什么的属性。你 ClientHello 里的密码顺序、你的库所发出的 HTTP/2 SETTINGS、请求头到达线路的先后次序——这些由你的 TLS 和 HTTP 栈决定,而不是由你的代码决定。你没法靠请求头把自己从一个 JA3 错配里”头”出来。

如何在网络层看起来像一个真实浏览器

有两种诚实的方式去呈现一个浏览器级的客户端指纹。

用一个真实浏览器。 Playwright、Puppeteer 或 Selenium 驱动的是真实的 Chromium,所以它们的 TLS 和 HTTP/2 指纹,按定义,就是一个真实浏览器的。对防护最激进的那些目标,这是最稳健的路——代价是每个 worker 跑一个浏览器。

用一个模仿 TLS 的 HTTP 客户端。 当你想保持轻量时,存在一类专门用来模仿一个浏览器的 ClientHello 和 HTTP/2 设置、同时仍是一次简单 HTTP 调用的客户端。像 curl-impersonate 及其 Python 绑定 curl_cffitls-client、以及 Go 的 utls 这样的工具,让你发出一个指纹为 Chrome 或 Firefox 的请求,而无需启动一个。

from curl_cffi import requests
# 呈现一个真实 Chrome 的 TLS + HTTP/2 指纹,而不是 python-requests 的。
r = requests.get(
"https://example.com",
impersonate="chrome",
proxies={"https": "http://customer-USER-country-us:PASS@p.shifter.io:443"},
)
print(r.status_code)

那一个 impersonate 参数,就是”一个说 Chrome 的 ClientHello”和”一个说 OpenSSL 的 ClientHello”之间的差别。与一个干净的住宅 IP 搭配,它一举清掉网络层和客户端层——而那正是难目标在检查的。

让每一层保持一致

贯穿其中的一条线是一致性。你的故事必须在三层之间都对得上:一个 Chrome User-Agent 需要一个 Chrome 的 TLS 指纹、Chrome 的 HTTP/2 设置、以及 Chrome 的请求头顺序,从一个其地理与你所声称的区域设置相匹配的 IP 出口。反爬系统越来越多地给这些信号之间的一致程度打分,而不是孤立地看每一个——所以单独一个矛盾(一个浏览器 UA 配一个库的握手、一个美国人设配一个外国 IP),比任何一层稍有偏差都更响亮。这和那些触发检测的错误背后是同一个连贯性原则:每一层都必须讲同一个故事。

诊断一次客户端指纹封锁

你通常能凭一次封锁的行为,把客户端层的封锁和 IP 层的封锁区分开。如果你被瞬间封掉,在一个以激进防护著称的站点上,而轮换 IP 什么也没改变、但切换到一个真实浏览器却突然管用——那就是一次客户端指纹封锁,不是一个 IP 问题。反过来,如果一个朴素 HTTP 客户端大多数时候都工作良好、只有某些 IP 遭到挑战——那就指回了 IP 信誉。知道是哪一层失败了,会告诉你该拉哪个杠杆,而不是拿轮换 IP 去对付一个与 IP 毫无关系的问题。这和诊断请求为什么超时以及检测一次无声封锁,是同一门”把失败读对”的功夫。

底线

重度防护的站点在三层上评判你,而一个干净的 IP 只回答了其中一层。客户端层——你的 TLS 握手和你的 HTTP/2 行为——会让脚本库在它们的请求还没被读之前就被封,而再多的请求头伪造也修不好它,因为指纹在于连接是如何被建立的。要以高成功率去抓那些难目标,就呈现一个浏览器级的客户端指纹——要么驱动一个真实浏览器、要么用一个模仿 TLS 的客户端——并让它和你的请求头、你 IP 的地理保持一致。

一个干净的住宅 IP仍然是必要的——它是那个让你越过信誉的层——但在最难的站点上,它单凭自己并不充分。把它与网络层上一个真实浏览器的指纹、以及每一个都对齐的信号搭配起来,那个过去在页面还没加载就打过来的封锁,就不再发生。按 GB 计价让你能拿这个组合去对着你自己最难的目标试,而不必有一个按请求的计价表跟你对着干。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始