什么是TLS指纹识别?JA3/JA4是怎么算出来的?
TLS指纹识别的核心是ClientHello包里的字段组合。同一个客户端库产生的组合几乎不变,因此可以用来识别请求方到底是浏览器还是脚本。
TLS握手第一步,客户端会发一个ClientHello数据包,里面包含五组字段:
- TLS版本
- 支持的加密套件列表:Cipher Suites
- 扩展列表:Extensions
- 支持的椭圆曲线:Elliptic Curves
- 椭圆曲线格式:EC Point Formats
JA3把这五组字段按顺序拼接后取MD5,得到一个32字符的指纹字符串。JA4在JA3基础上做了升级,加入了ALPN、SNI、扩展排序稳定性等因素,抗混淆能力更强。
三种客户端的典型指纹表现:
| 客户端 | JA3示例特征 | 识别难度 |
|---|---|---|
| Chrome 120 | 加密套件顺序稳定、GREASE值出现 | 与真实用户流量混在一起 |
| Firefox 121 | 与Chrome显著不同的套件顺序 | 与Chrome可区分 |
| Python requests | OpenSSL默认套件列表、无GREASE | 一眼识别为脚本 |
| Go net/http | Go原生TLS栈,另一套指纹 | 一眼识别为脚本 |
对目标站来说,只要维护一份"浏览器指纹白名单"或"脚本指纹黑名单",就能在TLS握手阶段就做出判断,根本不用等到应用层。APP大数据分析、跨境选品这类要长期采集的业务,指纹校验命中率越来越高。
为什么requests+代理搞不定?根本差异在哪?
代理只换出口IP、加密流量转发,完全不参与TLS握手;TLS握手是客户端与目标之间的端到端过程,代理无法改变ClientHello的内容。
一次带代理的HTTPS请求实际流程:
[Python requests] --CONNECT--> [代理] --TCP透传--> [目标]
| |
+----------- TLS 握手(端到端加密)--------------+
| |
+----------- HTTP 请求(在 TLS 隧道内)-----------+代理只在CONNECT阶段起作用,一旦隧道建立,客户端的TLS握手数据完全透传给目标。所以:
- 换IP → 目标看到的源IP变了,但ClientHello没变
- 换代理协议如HTTP或SOCKS5 → 只影响客户端到代理这段,不影响TLS指纹
- 换User-Agent → 只在HTTP层生效,改不了TLS层
requests的TLS指纹由底层OpenSSL版本决定,全球用requests的开发者跑出来的指纹极其集中。目标站只需拉一份OpenSSL默认指纹列表进黑名单,就能拦下绝大多数requests流量。这不是IP质量能解决的问题。
curl_cffi是如何解决指纹一致性的?
curl_cffi的技术路径是"底层调用魔改版curl + Python绑定"。所谓魔改版curl,指的是curl-impersonate项目。
三层结构关系:
| 层 | 组件 | 作用 |
|---|---|---|
| C库 | curl-impersonate | 编译时把BoringSSL/nss等浏览器同款TLS栈静态链接进来,输出的ClientHello与浏览器逐字节一致 |
| 绑定 | cffi | Python调用C库的桥 |
| Python API | curl_cffi.requests | 提供与requests几乎相同的Session、get、post接口 |
支持的impersonate目标,截至当前主流版本:
| impersonate值 | 对应浏览器 |
|---|---|
| chrome99 / chrome100 / chrome101 / chrome104 / chrome107 / chrome110 / chrome116 / chrome119 / chrome120 / chrome123 / chrome124 / chrome131 | Chrome各版本 |
| chrome99_android | Chrome Android |
| edge99 / edge101 | Edge |
| safari15_3 / safari15_5 / safari17_0 / safari17_2_ios | Safari桌面与iOS |
| firefox133 | Firefox |
选impersonate版本的策略:目标站验证严格就用最新版如chrome131;要与真实用户流量分布对齐就取当前主流市占率最高的Chrome版本;要低调采集选chrome104这种"次新版",避免过于集中的最新版指纹。
安装与最小可运行示例怎么写?
安装一行搞定,用法几乎无缝迁移自requests。
pip install curl_cffi
# 或使用镜像加速
pip install curl_cffi -i https://pypi.tuna.tsinghua.edu.cn/simple最小GET示例:
from curl_cffi import requests
r = requests.get("https://tls.browserleaks.com/json", impersonate="chrome131")
print(r.json())
# 输出的 ja3_hash 应与 Chrome 一致POST示例:
from curl_cffi import requests
r = requests.post(
"https://example.com/api/query",
json={"keyword": "跨境选品", "page": 1},
headers={"Accept": "application/json"},
impersonate="chrome131",
timeout=30,
)Session复用,推荐生产用法:
from curl_cffi import requests
session = requests.Session(impersonate="chrome131")
session.headers.update({
"Accept-Language": "zh-CN,zh;q=0.9",
})
# 复用同一 TLS 连接与 cookie jar
r1 = session.get("https://example.com/login")
r2 = session.post("https://example.com/api/list", json={"page": 1})关键点:
impersonate可以在Session级设置一次,也可以在单次请求覆盖- 不显式指定
impersonate时,curl_cffi会用默认Chrome指纹,但版本可能不稳定,生产环境务必显式指定 - 请求头会自动补齐到浏览器同款顺序,无需手动指定
sec-ch-ua、sec-fetch-*等
如何搭配代理使用?
curl_cffi的proxies参数格式与requests完全相同。
from curl_cffi import requests
proxies = {
"http": "http://user:pass@proxy.example.com:8080",
"https": "http://user:pass@proxy.example.com:8080",
}
r = requests.get(
"https://example.com",
proxies=proxies,
impersonate="chrome131",
verify=True,
timeout=30,
)proxies = {
"http": "socks5h://user:pass@proxy.example.com:1080",
"https": "socks5h://user:pass@proxy.example.com:1080",
}注意socks5h前缀让DNS也走代理,避免DNS泄漏;如果只写socks5则本地解析DNS,走代理只做传输。
Session级代理配置:
session = requests.Session(
impersonate="chrome131",
proxies=proxies,
)生产实践的一个关键点:代理侧要开长连接支持。curl_cffi默认启用HTTP keep-alive,如果代理侧强制短连接,指纹优势会被削弱,因为每次新TLS握手都增加开销和被采集概率。
并发抓取怎么做?异步支持如何?
curl_cffi从0.6版本开始提供AsyncSession,基于asyncio。
异步并发抓取模板:
import asyncio
from curl_cffi.requests import AsyncSession
async def fetch(session, url):
r = await session.get(url, timeout=30)
return r.status_code, len(r.content)
async def main(urls):
async with AsyncSession(impersonate="chrome131") as session:
tasks = [fetch(session, u) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
urls = ["https://example.com/page/" + str(i) for i in range(100)]
results = asyncio.run(main(urls))并发数控制,推荐用Semaphore而不是直接gather几千个task:
async def bounded_fetch(sem, session, url):
async with sem:
return await fetch(session, url)
async def main(urls, concurrency=20):
sem = asyncio.Semaphore(concurrency)
async with AsyncSession(impersonate="chrome131") as session:
tasks = [bounded_fetch(sem, session, u) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)同步版本用线程池也可以:
from concurrent.futures import ThreadPoolExecutor
from curl_cffi import requests
def fetch(url):
return requests.get(url, impersonate="chrome131", timeout=30).status_code
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(fetch, urls))性能对比参考,数值为同规模抓取任务的相对量级:
| 方案 | 并发100请求耗时相对量级 | 内存占用相对量级 |
|---|---|---|
| requests + 线程池 | 1.0(基准) | 1.0 |
| curl_cffi 同步 + 线程池 | 0.9-1.1 | 1.1-1.3 |
| curl_cffi 异步 | 0.3-0.5 | 0.6-0.8 |
生产环境批量抓取推荐curl_cffi异步方案。
遇到"impersonate失效"该如何排查?
impersonate失效的信号:请求返回403、challenge页面、或跳转到验证码。这时不要盲目换IP,先按四步定位。
第一步:验证指纹是否真的被复刻
r = requests.get("https://tls.browserleaks.com/json", impersonate="chrome131")
print(r.json()["ja3_hash"])
# 与 https://check.ja3.zone/ 上真实 Chrome 131 的 ja3_hash 对比第二步:检查impersonate版本是否落后
浏览器每几个月更新一次TLS栈,curl_cffi需要跟进。看当前版本支持的最新Chrome是否覆盖近半年发布:
pip show curl_cffi | grep Version
# 到 https://github.com/lexiforest/curl-impersonate/releases 对照如果落后半年以上,升级curl_cffi版本或换到更新的impersonate值。
第三步:检查HTTP层是否有泄漏
TLS指纹对齐了但HTTP header顺序、值不对,同样会被识别。用抓包工具对比curl_cffi发出的请求与真实Chrome DevTools里的请求,重点看:
- header出现顺序:Accept-Language、Sec-Fetch-*等
- User-Agent版本号是否匹配:不能用chrome131指纹配chrome100的UA
- HTTP/2的伪header顺序:
:method、:path、:scheme、:authority
第四步:判断是否触发了目标的行为分析
如果TLS和HTTP都对齐但仍然403,问题往往在业务层:
| 信号 | 可能原因 |
|---|---|
| 首次请求200,第二次开始403 | Cookie/会话缺失,被行为分析识别为新客户端 |
| 慢慢从可用到不可用 | IP池被目标标记,需要更换代理源 |
| 只在特定时段403 | 目标在特定时段收紧访问频率控制 |
| 随机403 | 目标做了概率抽样,无法完全避免 |
信号级别的应对:加Cookie持久化、降低同IP的请求密度、拉长请求间隔加随机抖动、必要时切换到不同的impersonate版本增加流量多样性。
FAQ
Q:curl_cffi和requests同时装有冲突吗?
没有冲突。两者是独立的包,可以同时安装并在同一项目里混用。实际项目中常见的用法是:内部服务/API调用用requests,因为更成熟、生态好;对外抓取用curl_cffi,因为指纹一致。注意from curl_cffi import requests这一行会在当前作用域内覆盖标准requests,避免在同一模块内混用两个同名对象。
Q:为什么有些站点用了chrome131还是被识别?
三种可能:一是目标做了JA4指纹而非JA3,JA4对扩展顺序更敏感,chrome131的JA4指纹与真实Chrome可能有微小偏差;二是目标同时校验HTTP/2 SETTINGS帧的字段顺序,也就是AKAMAI H2指纹,这层curl-impersonate也覆盖但需要目标机制单元覆盖到;三是目标做了客户端主动探测比如JS challenge,这已经超出TLS指纹范畴,需要额外的浏览器执行环境如Playwright。
Q:curl_cffi支持HTTP/2和HTTP/3吗?
HTTP/2完整支持,且impersonate同时会复刻HTTP/2 SETTINGS帧的字段值和优先级树,这是与requests+h2库最大的区别之一。HTTP/3基于QUIC,从0.7版本开始试验性支持,稳定性还在完善,生产环境建议先用HTTP/2。
Q:impersonate选择chrome104和chrome131在实测中差别大吗?
差别在两个维度。识别率:越新的Chrome版本,目标黑名单命中概率越低,因为新版才被大规模部署到用户;性能:新版Chrome往往用更多加密扩展,握手包更大、耗时略高。生产实践中,抓取要求高的场景选最新版;要与真实用户流量分布对齐、避免过于集中,选chrome116-chrome124之间的主流版本;轻量抓取选chrome104-chrome110。
Q:curl_cffi在Windows上能用吗?
能。curl_cffi在Windows、Linux、macOS三个平台都提供预编译wheel,pip安装无需C编译器。Windows上偶尔遇到DLL load failed报错,通常是VC++运行时缺失,安装Microsoft Visual C++ Redistributable最新版能解决。
