先说个事:我被 Cloudflare 拦了整整两天
2023 年 11 月,手上的项目要抓一个做海外电商的站点。requests 过去一直好使,headers 里把 User-Agent 和 Accept-Language 填一填就够了,跑了小半年没出过事。
那天开始不行了。返回 403,body 里是 Cloudflare 的挑战页。第一反应是 IP 被封了,换代理,还是 403。又把 UA 换成当时最新的 Chrome 120,照样。折腾到第二天凌晨两点多,我甚至打开编辑器准备上 Playwright 了,脑子里全是「要不干脆整个浏览器得了」。
后来在 GitHub 的 curl_cffi 仓库 issue 里翻到一句话,大意是说 requests 的 TLS 握手特征跟浏览器差得太远,Cloudflare 早就不看 UA 了。看到那句话的时候我第一反应是——那我这两年换 UA 是在干嘛。
把请求换成这样:
from curl_cffi import requests
r = requests.get(
url,
impersonate="chrome120",
proxies={"https": "http://user:pass@host:port"},
timeout=15,
)
就这一改,200 了。前后不到 5 分钟,跟我前一天耗的 8 个小时形成了一种非常讽刺的对比。
TLS 指纹到底是什么东西
HTTP 请求发出去之前,客户端和服务器要先做 TLS 握手。握手第一步是客户端发 ClientHello,这个包里包含这几样:支持的 cipher suites 以及它们的排列顺序、扩展字段列表(SNI、ALPN、supported_groups、signature_algorithms 等等)、椭圆曲线支持列表、TLS 版本号。
JA3 就是把这些字段拼成一个字符串再算 MD5。JA4 是新版,2023 年底之后用的人多起来,做了些改进比如用 ALPN 首字节做区分。不管哪一版,核心思路都是:你手里拿什么牌、以什么顺序打出来,比你说自己是谁更可信。
Python 里 import requests 走的是 urllib3 → ssl 模块 → 系统 OpenSSL。这套组合的 JA3 跟 Chrome 差得远。Chrome 的 cipher suites 里塞了一堆 GREASE 值(随机填充,防止指纹被写死),Python 的 ssl 根本不发 GREASE。
所以 WAF 那边看到的画面是:请求头写着 Chrome 120,TLS 握手看着像个 Python 脚本。这不明摆着吗。
curl_cffi 的做法是把 curl-impersonate 编译进去,这个项目 patch 了 BoringSSL(Chrome 用的那个),能原样复现 Chrome 的 ClientHello。impersonate 参数支持的值我列一下,是我从 curl_cffi 0.6 版本翻源码找的:
chrome99, chrome100, chrome101, chrome104, chrome107, chrome110, chrome116, chrome119, chrome120, chrome123, chrome124, chrome131, chrome133a, chrome136, safari15_3, safari15_5, safari17_0, safari17_2_ios, edge99, edge101, firefox133, firefox135
版本号别乱填,不支持的会直接抛错,不会静默降级到某个默认值。这点比很多库做得好。
并发数:一个被严重高估的参数
接着聊并发。Scrapy 的默认值我记得是:
- CONCURRENT_REQUESTS = 16
- CONCURRENT_REQUESTS_PER_DOMAIN = 8
- CONCURRENT_REQUESTS_PER_IP = 0
- DOWNLOAD_DELAY = 0
- AUTOTHROTTLE_ENABLED = False
很多教程上来就教你把这几个数往大了改,我不建议。
2024 年上半年跑过一个项目,目标是个没有鉴权的 API。用 8 并发(CONCURRENT_REQUESTS_PER_DOMAIN=8,DOWNLOAD_DELAY=0.12),跑了 11 个小时,抓了大概 68 万条,403 的比例是 0.21%。同一套代码,把 per_domain 改成 32、delay 去掉,20 分钟之后开始出 403,30 分钟的时候错误率到 37%。日志里能看到一批请求挤在同一秒打过去。
这里有个容易被忽略的点:不少 WAF 的判断窗口是 60 秒,不是瞬时。所以你「每秒 10 个请求」这个描述是不够的,得看 60 秒内累积了多少。8 并发加 0.12 秒延迟,理论上 8 / 0.12 ≈ 66 QPS,但实际因为网络往返、DNS、连接复用这些开销,实测稳定在 25-30 QPS 之间。32 并发无延迟的时候,我实测冲到过 200+ QPS。
AutoThrottle 是个被低估的东西,打开之后:
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
它的逻辑是根据上一个响应的延迟动态调 DOWNLOAD_DELAY。target_concurrency 设 1.0 意思是平均每个域名只有 1 个并发在跑,完全按响应延迟走。我个人一般设 2.0 到 4.0,具体看站点响应快不快。
固定 sleep 是个机器特征
这个点我想单独说,因为我见过的代码里 90% 都这么写:
time.sleep(1)
均匀分布。人不是这么上网的。人在页面上停留的时间是重尾分布——大部分很短,偶尔有一个特别长的(去泡了杯咖啡)。
要让请求间隔看起来像人,可以用对数正态分布:
import random

def human_delay(mu=0.4, sigma=0.8):
return random.lognormvariate(mu, sigma)
mu=0.4、sigma=0.8 的时候,中位数是 e^0.4 ≈ 1.49 秒,90 分位数大概 4.5 秒,偶尔蹦出个 15 秒的。这个形状比 random.uniform(0.5, 2.0) 自然得多。
当然这招只对「同一 IP 高频访问」的场景有用。你要是有 100 个 IP 轮着来,间隔分布就不是主要矛盾了。
几个顺手能省事的地方
连接复用。requests 里如果每次 requests.get() 而不是用 Session,每次都重新做 TCP + TLS 握手。一次 HTTPS 握手的开销在正常网络下大概 100-200ms,跨洋能到 400ms。用 Session 之后这部分省下来:
s = requests.Session()
s.mount("https://", requests.adapters.HTTPAdapter(
pool_connections=20,
pool_maxsize=20,
max_retries=3,
))
默认的 pool_maxsize 是 10。如果你的并发线程超过 10,超出的连接会被丢弃重建。这个坑我踩过,当时看日志以为是服务端在限流,查了三个小时。
httpx 的 HTTP/2。httpx.Client(http2=True) 能开 HTTP/2,某些 CDN 后面的站点表现比 HTTP/1.1 好。但它同样没有 TLS 指纹伪装,遇到 Cloudflare 该拦还是拦。HTTP/2 和 TLS 指纹是两码事,别混着理解。
DNS。Scrapy 有自己的 DNS 缓存,默认 DNSCACHE_ENABLED=True、DNSCACHE_SIZE=10000、DNS_TIMEOUT=60。如果你用 requests,DNS 解析走系统的,Linux 上默认 glibc resolver 没缓存,每次都要查一遍。数据量上来之后这块能省不少时间。
我的观点:爬虫的指标应该是「活多久」
行业里聊爬虫基本都在聊怎么快——并发多少、QPS 多少、一天能抓多少万条。
我现在的看法是反过来的。一个能稳定跑 30 天、错误率 0.5% 的爬虫,价值远高于一个跑 30 分钟被抓、然后你要花两天修 IP 池的爬虫。后者的隐性成本从来没人算进去。
具体点说,我给自己定了这几条线:
- 对同一个域,QPS 不超过 5,除非我确认对方没上 WAF
- TLS 指纹优先于 IP,IP 优先于 UA
- 每个项目上线前先跑 2 小时的低速压测,看错误率的曲线形状,不看绝对数字
- 代理池不要一上来就上,先试试不用行不行
最后一条可能有点反直觉。多数人的第一反应是被封就换 IP,实际上先检查指纹和请求头。这两样对了,很多时候单 IP 也能跑很久。