你已经做对了一切。一个干净的住宅IP,一个真实的Chrome User-Agent,可信的请求头,合理的速率限制。而一个防护严密的目标网站仍然瞬间拦截了你,有时甚至在它发送实际页面的第一个字节之前就已经拦截。你很容易归咎于IP并进行轮换,但轮换并不能解决问题,而这正是一个信号。你正在一个大多数抓取者从未考虑过的层面上被指纹识别:HTTP客户端本身,在TLS握手和HTTP/2连接阶段,在你的请求甚至还没有被读取之前。
这是身份识别的第三层,与你的IP不同,也与antidetect browser(反检测浏览器)所管理的浏览器和设备指纹不同。理解这一层是区分一个只能在简单网站上运作的抓取器和一个能在困难网站上运作的抓取器的关键。
三个层面,而非一个
当一个网站判断你是否为真实用户时,它会审视三个独立的方面:
- 网络层: 你的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,产生的ClientHello与两者都不相似。Go的net/http产生它自己的一种。反机器人厂商将这些值哈希成一个紧凑的指纹,广为人知的格式是JA3及其后继者JA4,并维护一份真实浏览器应该是什么样子的画像。当你的ClientHello哈希值明显显示出这是一个脚本库而非浏览器时,你在握手阶段就会被标记,甚至在你发送请求行、请求头或cookie之前。
残酷之处在于它暴露的矛盾。你设置了User-Agent: Chrome,但你的TLS指纹却说是通过Python调用的OpenSSL。真正的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指纹从定义上就是真实浏览器的指纹。对于防护最为严密的目标,这是最稳健的方案,代价是每个工作进程都要运行一个浏览器。
使用TLS伪装HTTP客户端。 如果你想保持轻量化,有一类客户端专门用于模拟浏览器的ClientHello和HTTP/2设置,同时仍然是一次简单的HTTP调用。像curl-impersonate及其Python绑定curl_cffi、tls-client,以及Go的utls,可以让你发送一个指纹识别为Chrome或Firefox的请求,而无需真的启动一个浏览器。
from curl_cffi import requests
# Presents a real Chrome TLS + HTTP/2 fingerprint, not 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参数,就是ClientHello宣称自己是Chrome还是OpenSSL之间的区别。将它与一个干净的住宅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计费的定价让你可以针对自己最难对付的目标测试这种组合,而不必与按请求计费的方式相抗衡。