爬虫-curl_cffi
这篇讲什么
- curl_cffi 0.17.0 在 Python 3.14.10 上逐条实测:它把 libcurl(curl-impersonate 版)封成一套 requests 风格的接口,
get/post/Session/AsyncSession都能用,卖点是一个impersonate参数就把 TLS 指纹、HTTP/2 帧、请求头顺序一起换成真实浏览器。 - 本篇的分工口径:这台机器的外网链路会掐掉带指纹的握手(五个在线站 × 两种指纹取法取样 20 次,16 次
Timeout、3 次SSLError、1 次侥幸 200),所以抓在线站只能走默认栈,指纹相关的取证全部放在局域网靶站和本机 ClientHello 嗅探里做,每条结论都跟着逐字输出。 - 三件必须先知道的事:
BrowserType枚举有 53 个成员但不含chrome这种别名(别名只在BrowserTypeLiteral的 63 个取值里);Session默认timeout=30而不是 requests 的None;files=这个名字还在但一用就NotImplementedError,上传要走multipart=CurlMime。
装进来有什么
curl_cffi 是「libcurl 的 Python 绑定 + 一层 requests 风格接口」,装的时候只带一个编译好的 curl_cffi 二进制轮子,依赖极少(本机这个虚拟环境里一共只有 5 个包)。先把它自己是什么版本、底层 curl 是什么版本、导出面有多大看清楚:
1 | # -*- coding: utf-8 -*- |
1 | python: 3.14.10 |
逐字输出里有四个要点值得单独记。
- 版本号有两个:
curl_cffi.__version__是这个包的版本(0.17.0),__curl_version__是底层 libcurl 的完整特性串,里面能看到BoringSSL、nghttp2(HTTP/2)、ngtcp2/nghttp3(HTTP/3)、c-ares(异步 DNS)和curl-impersonate/2.3.1。文档里出现的__libcurl_version__在本机不存在,hasattr直接是False,写它会拿到空值而不是报错,属于静默坑。 - 两套导出面不一样:
curl_cffi.requests.__all__是 requests 那一层(Session、AsyncSession、get/post/...、异常、ExtraFingerprints、ProxySpec、WebSocket 相关),顶层curl_cffi.__all__还多出Curl、AsyncCurl、CurlMime、CurlECode这些底层绑定。日常写爬虫只用前者;要设curl_options这种冷门开关才需要后者。 - 能模仿的浏览器数清楚了:
BrowserType是str加Enum的组合类,53 个成员按前缀分布是 chrome 20 个、chrome/_macos 2 个、chrome/_android 2 个、edge 2 个、safari 12 个、safari/_ios 7 个、firefox 7 个、tor 1 个。枚举里没有chrome/firefox/safari/edge这些「只用品牌名」的别名,它们只出现在BrowserTypeLiteral(typing.Literal[...],63 个取值)里——所以写BrowserType.chrome会AttributeError,而写impersonate="chrome"是合法的,因为函数签名走的是 Literal 那条路。 - 顶层没有任何模块级函数:
set_default_curl_timeout、get_async_session_timeout这类在老文档里出现过的名字本机都没有,超时只能是Session或单次请求的参数。
三种用法
官方 README 给的用法有三档,档位之间不是「功能多少」的差别,而是连接和 Cookie 会不会被复用的差别。这一段直接打本机靶站,把「服务端看到的客户端端口」「同一条连接上累计了几个请求」「Cookie 头」三个观测点打出来:
1 | # -*- coding: utf-8 -*- |
1 | == A. 顶层函数式 rq.get(源码里每次 with Session(...) 新建)== |
结果和 requests 的习惯是一致的,但有两个 curl_cffi 特有的细节。
- 顶层的
curl_cffi.requests.get(url)每次都新建一个 Session:靶站三次都看到不同的客户端端口,同连接累计请求数序列是[1, 1, 1],Cookie 头一直是None。写一次性脚本无所谓,批量抓取就亏了——每次都要重新握手。 Session复用连接:三个请求同一个端口,序列变成[1, 2, 3],服务端Set-Cookie之后从第二次请求起就自动带上sid=abc,session.cookies也能读到{'sid': 'abc'}。AsyncSession行为同上,且支持async with;asyncio.gather并发四个请求时,靶站记录的到达序号是[1, 2, 3, 4]——并发不等于同时,真正同时在跑几个由max_clients决定(见「下载与并发」一节)。- Session 关掉之后再用,抛的是
SessionClosed: Session is closed, cannot send request.,不是 requests 那种ValueError。
impersonate 改了什么
impersonate 是本库存在的理由。它改的不只是 User-Agent,还有请求头顺序、HTTP/2 的 SETTINGS 帧、TLS 的 ClientHello。头顺序这一层在本机靶站上就能看清楚,而且能看出「别名」和「具体版本号」的差别:
1 | # -*- coding: utf-8 -*- |
1 | == 不传 impersonate(默认) == |
几个要记住的实测结论。
- 不传
impersonate时靶站只看到 2 个头(Host、Accept-Encoding),连User-Agent都没有——这是 curl 的裸默认,很多站点会因为这个直接拒你。 impersonate="chrome"给的是 17 个头,sec-ch-ua排在最前面,UA 主版本是 Chrome/152(本机 0.17.0 里「chrome」指向的具体版本,会随版本升级变,别把它当常量写进代码)。impersonate="chrome131"只有 16 个头且顺序不同(Host在最前),说明别名和具体版本走的是两套模板,不是「别名 = 最新版」这么简单。safari17_0的 UA 里浏览器主版本在Version/17这一段(脚本只把主版本打出来,OS 段和内核号不打印,因为不同模板写的是同一套内核号,读起来容易误当成版本),safari17_0与safari170头顺序完全相同,两种写法等价。- 自定义
headers={"User-Agent": ...}会覆盖 impersonate 的 UA——想「保留浏览器指纹但换个自己的标识」要清楚这会让指纹自相矛盾。 default_headers=False时只剩 5 个头,等于「只换 TLS 指纹、不发浏览器头」,一般只在专门测服务端时才用。- HTTP 版本这条也在同一个块里量出来了:本机明文靶站上
http_version是2(V1_1),HTTPS 在线站默认是3(V2_0,即 HTTP/2),显式传http_version="v1"会退回 HTTP/1.1;作为对照,标准库urllib永远是11(只会 HTTP/1.1)。CurlHttpVersion枚举的实测取值是NONE=0、V1_0=1、V1_1=2、V2_0=3、V2TLS=4、V2_PRIOR_KNOWLEDGE=5、V3=30、V3ONLY=31。
TLS 指纹怎么看
「TLS 指纹真的变了」这句话不能只靠文档,得自己把 ClientHello 抓下来看。这一段在本机开一个 TLS 服务器,把每个 impersonate 取值握手时的密码套件和扩展类型抄下来,去掉 GREASE(Chrome 每次插入的随机占位扩展)后排序比较:
1 | # -*- coding: utf-8 -*- |
1 | == A. 本机嗅探 ClientHello(每个取值连发 2 次握手)== |
- 默认栈是 27 个密码套件 / 11 个扩展、没有 GREASE;
chrome131变成 15 个套件 / 15 个扩展并带 GREASE;firefox133是 17 个套件;safari17_0是 20 个套件 / 13 个扩展。指纹的 sha256 前 12 位各不相同,同一个取值连发两次握手、去 GREASE 后排序的集合一致——这说明差异是稳定的,随机部分只有 GREASE 和扩展顺序。 - 抖动项写清楚:扩展的原始顺序和包长度每次都变(Chrome 会随机置换扩展),所以只能比「去 GREASE 后的排序集合」,不能比原始顺序,否则输出每次都不一样。
- 文档里那种
ja3="TLS1.3_..."十进制套件串在本机 0.17.0 直接报ImpersonateError: Cipher 0x13 is not found,写成0x1301又报ValueError——十进制 ja3 串这条路是走不通的。akamai=、perk=两种串会被接受(请求返回 200),但本机靶站是 HTTP/1.1,看不到 HTTP/2 帧层面的差异,所以这两条只取证到「参数被接受」,没取证到「指纹生效」。 - 在线观测点
tls.peet.ws只有不传impersonate时才通(能拿到ja4、peetprint、akamai-h2三类哈希);传chrome131/firefox133几乎都是Timeout/SSLError。结论:本机网络会丢弃带指纹的 ClientHello,下一节把 20 次取样的分布量化出来。
在线站的可达性
上面那句「本机网络丢包」不是猜测,是反复取样取出来的。这段把五个在线站在默认栈下的表现逐条打全,而带指纹的那两列只报聚合计数:
1 | # -*- coding: utf-8 -*- |
1 | https://ssr1.scrape.center/page/1 状态 200 字节 41667 |
- 默认栈(不传
impersonate)五个站全部 200,ssr141667 字节、spa1接口 583 字节、quotes.toscrape.com11064 字节、example.com577 字节——curl_cffi 当作普通 HTTP 客户端用完全正常,本篇后面的实战项目就是走这条路抓 ssr1。tls.peet.ws那一行不报字节数,因为它的响应体里回显了本机出口 IP 与 TLS/TCP 信息,长度每次都变。 - 带
impersonate的那两列为什么只能报聚合值:这五个 URL × 两种指纹取法连跑两遍共 20 次取样,16 次Timeout、3 次SSLError、1 次真的拿到 200。异常名和成败本身在跳,逐条打印必然两遍不一致,所以这里只把 20 次的分布写出来。那 1 次 200 也说明这不是「绝对不通」,而是链路对陌生 ClientHello 的丢弃带有随机性。 - 这一条决定了本篇所有示例的分工:要演示指纹,就在局域网里自己嗅(上一节);要抓数据,就用默认栈打在线站(最后一节的实战项目)。
- 写生产代码时这个现象有个正面用法:带指纹失败抛的是
Timeout或SSLError,两个都属于RequestsError家族,except RequestsError就能兜住,然后退回默认栈重试。但别把异常名打印进日志再拿它做判据——它本身就是抖的。
异常体系
异常这一层最容易踩,因为 curl_cffi 的异常名和 requests 不完全一样,而且有几个「不抛异常」的分支。这段把每个失败场景的报错原文都打出来:
1 | # -*- coding: utf-8 -*- |
1 | == 0. 异常类的继承关系 == |
要点逐条对照。
- 基类是
RequestsError,它其实就是curl_cffi.requests.exceptions.RequestException(同一个类的别名),MRO 里带CurlError和OSError,ImpersonateError、HTTPError、Timeout、ConnectionError、TooManyRedirects全是它的子类——捕获RequestsError一网打尽,这点和 requests 的RequestException对应。 - 写错的浏览器名(
firefox_120、chrome999、ie11)报ImpersonateError: Impersonating xxx is not supported,在发请求之前就抛;空串""和edge这种合法别名不会报错。 gopher://这类 curl 认识的 scheme 不抛异常,直接给你一个status_code=0的响应;空字符串 URL 才抛CurlError: ... (27) URL rejected: Malformed input。所以判成功的正确写法是resp.ok或检查status_code,光try/except兜不住「协议写错但没报错」这种情况。- 状态码默认不抛:404 照样返回响应对象,要
raise_for_status()或建 Session 时给raise_for_status=True才抛HTTPError。 json()解析失败抛的是标准库的json.JSONDecodeError,不在RequestsError体系里,except RequestsError接不住它。- 超时是
Timeout: Failed to perform, curl: (28) Operation timed out after ... with 0 bytes received;连不上是ConnectionError: ... curl: (7) Failed to connect to ...;重定向超限是TooManyRedirects: ... curl: (47) Maximum (3) redirects followed。curl 的错误码(28)/(7)/(47)在异常文本里,判类型比读消息方便,但报错文本里的after N ms是抖动的,别把它写进断言。 - 明文端口上用
https://得到的是SSLError: ... (35) ... WRONG_VERSION_NUMBER,这是「端口对但协议错」的典型信号。
参数面:有哪些参数
requests 老手最想知道的是「我熟悉的那些参数还在不在」。这段用 inspect.signature 把 Session.__init__ 和 Session.request 的参数连默认值一起打出来,再拿五十来个常见参数名逐个试探「能不能赋值、能不能当参数传」,把「有但没用」「名字换了」「彻底没了」三类分清楚:
1 | # -*- coding: utf-8 -*- |
1 | == Session.__init__(注意 * 之后全部是关键字参数) == |
清单太长,这里只把结论性的一句句列出来(每一句在上面那份输出里都有对应行)。
Session.__init__的签名是(curl, thread, use_thread_local_curl, curl_share, **kwargs),*之后全部是关键字参数,所以拼错关键字不会被签名拦住,要等真正用到时才炸。Session.request有 37 个具名参数(输出里除self每行一个、带默认值)。取内容的那一批是method、url、params、data、content、json、headers、cookies、files、auth、timeout、stream、multipart、max_recv_speed、content_callback、referer、accept_encoding;另外二十个是 curl 侧的开关:allow_redirects、max_redirects、proxies、proxy、proxy_auth、verify、cert、interface、dns、doh_url、impersonate、ja3、akamai、perk、extra_fp、default_headers、default_encoding、quote、http_version、discard_cookies。- 指纹这一层在参数上是分开的:除了
impersonate给模板名,还能直接给ja3=/akamai=/perk=串或extra_fp=(自定义指纹,见「自定义指纹」一节),default_headers=False表示只换 TLS 指纹、不发浏览器头。 session.get的签名只有(self, url, **kwargs);试探表里session.get与session.request两列的结果逐行一致——get只是转发,真正的参数校验都发生在request里。- 只能在构造 Session 时给、写进请求就
TypeError的九个:base_url、trust_env、retry、curl_options、curl_infos、debug、response_class、raise_for_status、cache。写成session.get(..., base_url=...)是不认的。 - 只能写在单次请求里、写进
Session(...)就TypeError的十一个:stream、json、data、content、files、multipart、max_recv_speed、referer、accept_encoding、content_callback、quote。discard_cookies不在这批里——试探表显示它三处都能给。 - 三个位置全都不认的名字:
multi、hooks、session、file、output。requests 那套hooks=、以及「一步下载成文件」的file=/output=在这里都不存在;file=的报错还会提示Did you mean 'files',而files=本身是废的(下一条)。 files=是「名字还在、一用就炸」的典型:签名里能过,进到实现里抛NotImplementedError: files is not supported, usemultipart.。上传一律走multipart=CurlMime(...),实测见「从 requests 迁移」。proxy与proxies同时给抛TypeError: Cannot specify both 'proxy' and 'proxies',只能二选一;proxy_auth=('user', 'password')是代理侧鉴权,和给站点做 Basic 认证的auth不是一回事。response_class传错类型(比如直接给dict)报的是很有用的一句:TypeError:response_classmust be a subclass ofcurl_cffi.requests.models.Response, not of type<class ‘dict’>`。data=的类型决定 Content-Type:dict→application/x-www-form-urlencoded(体是a=1&b=2),bytes→application/octet-stream,json=→application/json(体{"a":1})。三种都是 7 字节体,差别只在类型头上。- 超时的默认值:
Session构造出来的timeout是 30,Session.request签名里写的是哨兵NOT_SET(不传就沿用 Session 的 30)。requests 的默认是None(永远等),这是迁移时最容易吃的一次亏:慢站点上你会突然开始看到Timeout。 accept_encoding默认'gzip, deflate, br',解压自动做;default_encoding管「响应没声明编码时按什么解」,quote管「URL 要不要百分号编码」(实测见「从 requests 迁移」);max_recv_speed=0是不限速。
代理:两种写法
代理这段有代理日志和靶站日志两侧证据:本机起了一个 HTTP 正向代理(8051),它把收到的请求行与请求头原样记下来再转发给靶站(8050),所以「到底走没走代理」能同时从两侧确认:
1 | # -*- coding: utf-8 -*- |
1 | == 0. 先确认不走代理时靶站看不到 Via == |
proxy="http://127.0.0.1:8051"(单个字符串)和proxies={"http": "...", "https": "..."}(字典)两种写法都生效:靶站看到Via='1.1 local-probe-proxy'与X-Forwarded-For='127.0.0.1';代理侧记到的请求行是绝对形式GET http://127.0.0.1:8050/list HTTP/1.1——这是「走正向代理」的铁证,直连时请求行只会是/list。- 不配代理时靶站的
Via、X-Forwarded-For都是None,代理侧一条都没收到。 proxy_auth=('user', 'password')时代理能看到Proxy-Authorization: Basic dXNlcjpwYXNzd29yZA==(即user:password的 base64)。ProxySpec运行时是dict[str, str]这种参数化泛型,不能拿去isinstance,会抛TypeError: isinstance() argument 2 cannot be a parameterized generic。trust_env=True(默认)会读环境变量:本机NO_PROXY里含127.0.0.1,于是「配了代理但打本机靶站」被静默绕过——靶站Via=None、代理零收。想看代理真生效要Session(trust_env=False)。这条和《爬虫-代理》里ProxyHandler读 Windows 注册表例外列表是同一类坑:绕过逻辑不在你以为的那一层。- socks 四种 scheme(
socks5://、socks5h://、socks4://、socks4a://)本机没有 socks 服务,请求全部失败,但不是「不支持该协议」——说明 libcurl 认这个前缀。异常类型在Timeout与ConnectionError之间摇摆(取决于系统多久判定连不上),所以脚本里只报两个稳定布尔:有没有失败、报错文本里提到的是proxy还是timed out。CurlProxyType枚举实测有HTTP=0、HTTP_1_0=1、HTTPS=2、HTTPS2=3、SOCKS4=4、SOCKS5=5、SOCKS4A=6、SOCKS5_HOSTNAME=7、HTTPS3=8。
Response 对象
响应对象的实例属性一共 28 个(vars(r) 全量打印过),这里挑那些「名字和 requests 一样但行为不一样」的:
1 | # -*- coding: utf-8 -*- |
1 | == 0. Response 的公开成员 == |
headers是自定义的Headers类,不是 dict 子类:大小写不敏感(['Content-Type']和['content-type']都命中);同名多个头会并成一个字符串'one, two';没有getlist(),想拿多个值只能自己 split。cookies是Cookies对象,用法接近 requests,session.cookies会跟着响应更新。- 编码:
encoding与charset都从Content-Type认。靶站声明 GBK 的那个端点直接text='中文内容测试'不乱码,不需要 requests 那套apparent_encoding手工兜底。 - 重定向链:
history是list[Response],本例拿到[(302, '/r/2'), (302, '/r/1')],history[0].is_redirect为True;allow_redirects除了布尔还能传CurlFollow枚举/字符串,实测可选值是ALL=1、OBEYCODE=2、FIRSTONLY=3、SAFE=4。 Response不支持with(没有__enter__),要上下文管理得用Session.stream();r.close()可以手工调。elapsed是timedelta,微秒部分每次都变;local_port每次不同——这两个值只能当现象看,不能进结论,所以脚本里只报整秒或不报。http_version是 int(本机明文靶站是 2,即 HTTP/1.1)。- 流式三条路:
stream=True配iter_content、Session.stream()写盘、content_callback回调,拿到的 2 MiB 内容 sha256 前 12 位都是13177ebaa94c,和靶站原文一致;iter_content按 64 KiB 分块正好 128 块;iter_lines()返回的是 bytes 列表(不是 str)。想要「一步下成文件」的file=/output=/download=参数都不存在,file=还会给出Did you mean 'files'这种误导性提示。
下载与并发
并发度这件事必须让靶站慢下来才量得准。这段起一个睡 0.3 秒的端点,20 个 URL 一批,用「服务端同时在线数」的峰值当证据,每种配置跑两遍:
1 | # -*- coding: utf-8 -*- |
1 | == 1. 2 MiB:stream=True 边收边写盘 == |
AsyncSession的max_clients默认 10,实测峰值在途正好 10;显式max_clients=5峰值 5、=1峰值 1,三档各跑两遍都一样。这是「同时在跑几个」唯一可信的证据——不看耗时看并发。- 每种配置的完成数与靶站命中数都是 20,且 20 个 URL 的完成集合恒为
range(20)(无丢、无重)。 - 同步
Session串行 20 次的峰值在途恒为 1,符合预期。 - 抖动提醒(脚本注释里也写了):不注入慢响应时峰值在途会在 1~2 之间跳,因为请求太快、并发根本没拉开。任何拿「并发度」当证据的实测都要先把响应拖慢,否则测到的是本机调度噪声。
WebSocket
curl_cffi 自带 WebSocket 客户端。本机写了 60 行 RFC6455 回显服务端做端到端验证(握手、掩码、文本/二进制帧、关闭帧都自己实现),所以下面这些 close_code 是真拿到的:
1 | # -*- coding: utf-8 -*- |
1 | == 1. API 面 == |
- 同步与异步两条路都通:
send_str('hello')发 5 字节、recv()收到b'echo:hello'且 flags 含TEXT;send_json({'k': 1})收到'echo:{"k":1}';close()之后closed=True、close_code=1000(正常关闭)。 - 三种写法都可用:
s.ws_connect(url)手工对象、with s.ws_connect(url) as ws:上下文、异步async with s.ws_connect(url) as ws:;异步收到'echo:async-hello',with 写法收到'echo:with-style'。 - 对普通
http://地址做ws_connect不报错,也能拿到 WebSocket 对象——握手是被服务端拒绝时才暴露,写错协议前缀不会有提示,这是容易浪费时间的地方。 - 没有服务在听时抛的是底层
curl_cffi.curl.CurlError(Failed to perform, curl: (7) ...),不是 requests 风格的ConnectionError,所以except RequestsError接不住它:要么连CurlError一起接,要么按异常名判。
自定义指纹
impersonate 只能选模板,想微调就得上 ExtraFingerprints。这个块把它的 16 个字段连默认值全列出来,再逐个看哪些字段真的改变了靶站或嗅探看到的东西:
1 | # -*- coding: utf-8 -*- |
1 | == 1. ExtraFingerprints 是什么 == |
ExtraFingerprints是 dataclass,16 个字段全默认None;ExtraFpDict是TypedDict,键集合与字段完全一致(脚本里断言过),所以字典和 dataclass 两种写法都收(内部就是ExtraFingerprints(**dict))。未知字段直接TypeError: ... got an unexpected keyword argument 'not_a_field'。header_order真能改靶站看到的头顺序:把Accept-Language提到最前它就真在最前;*是通配符代表「其余按原序」;写了不存在的头名会被静默忽略。tls_grease=False让 ClientHello 的 GREASE 位从 1 变 0(嗅探实测);tls_permute_extensions=False让两次握手的扩展原始顺序变得一致(缺省/True 时每次不同)——这条把「哪个字段负责扩展置换」钉死了,也给出唯一能稳定打印扩展顺序的办法。- 没效果或已失效的字段要如实记:
split_cookies=True本机看不到拆头;form_boundary=True直接抛TypeError: initializer for ctype 'void *' must be a cdata pointer(ctypes 层坏了);ExtraFingerprints.per_browser_metadata这个属性不存在;tls_min_version=0x0303这种写法报的是CurlError: Failed to setopt 32 66307, curl: (43) setopt 0x20 got bad ar...,参数类型不对不会给你 Python 层的 TypeError。 get_fingerprint("chrome131")离线可用,内置可离线用的目标是 47 条(NATIVE_IMPERSONATE_TARGETS)。
从 requests 迁移
这一节专门处理「会咬人的差异」。逐条实测,每条都有靶站侧的回执,不是抄 changelog:
1 | # -*- coding: utf-8 -*- |
1 | == 1. 中文 header 值 == |
按「会不会静默改变行为」排序。
- 中文 header 一定炸:头名或头值里带中文,抛的是标准库的
UnicodeEncodeError('ascii' codec can't encode characters、'latin-1' codec can't encode character '\u503c'),不在RequestsError家族里,except RequestException接不住。requests 会容忍(编码方式不同),这条最容易在上线后突然出现。 - URL 编码由
quote控制:params里的中文会自动百分号编码(靶站收到?q=%E7%94%B5%E5%BD%B1),params传 list 能发同名多值(?t=1&t=2),路径里的中文也会编码;quote=False时不做编码,含空格的查询串会被 curl 直接拒成curl: (3)/(27) Bad query。要「原样发出去」就quote=False,但得自己保证 URL 合法。 - 上传的写法完全不同:
multipart=dict抛AttributeError: 'dict' object has no attribute '_form',正路是CurlMime。实测 boundary 是 46 位十六进制(脚本只报形状与长度,不报原值),信封开销 192 字节且与内容长度无关——同一结构把内容从 10 字节换成 20 字节,body 只从 202 变到 212。 - 重定向时的 Cookie:服务端在 302 上
Set-Cookie,第二跳靶站就看到Cookie='from_redirect=yes',即重定向链上的 cookie 会保留。discard_cookies=True只影响会话 jar(请求里照旧带着),实测第二跳仍看到from_redirect=yes; sid=keepme,而 jar 里只剩{'sid': 'keepme'}。 - 环境变量:
HTTP_PROXY会被默认的trust_env=True采用,但本机NO_PROXY含127.0.0.1,所以打本机靶站时被豁免(status=200且没走代理);trust_env=False则完全忽略环境变量。requests 的trust_env语义在这里等价,但绕过名单来自系统环境,排查时先确认NO_PROXY。 - requests 有而 curl_cffi 没有的:
Session.mount/adapters(实测都是False,重试要换RetryStrategy或session.retry)、响应.raw(False,流式用iter_content)、hooks=参数(TypeError)。反过来 curl_cffi 有而 requests 没有的:impersonate、extra_fingerprints、http_version、max_recv_speed、content_callback、quote、referer、discard_cookies、multipart、ws_connect。 allow_redirects除了布尔还接受CurlFollow的四个值(ALL=1、OBEYCODE=2、FIRSTONLY=3、SAFE=4),这是 requests 没有的粒度。
命令行入口
装完包,venv 的 Scripts 目录里除了 cffi-gen-src.exe 还有 curl-cffi.exe,它就是 README 里那条命令。python -m curl_cffi 与直接敲 curl-cffi 等价(包里有 curl_cffi/cli/__main__)。这段把四个子命令各跑一次,并让靶站回签「这一枪到底是不是 curl_cffi 发的」:
1 | # -*- coding: utf-8 -*- |
1 | 可执行文件: curl-cffi.exe 存在: True |
doctor打出 python 版本、libcurl 版本串、平台、api_root=https://api.impersonate.pro/v1、api_key_configured=False、fingerprint_count=47——这五个值就是「本机装好了没有」的判据,遇到指纹不对先跑它。get/post/run都实测可用,且默认就带--impersonate chrome:靶站回签的头顺序是sec-ch-ua打头、UA 是Chrome/152,和库内impersonate="chrome"完全一致;--body只回正文(回响应头会带Date,属于抖动项)。--impersonate none直接Error: Impersonating none is not supported,而且请求根本没发出去(靶站新增请求数为 0)——命令行这一层不做静默降级。list离线可列 47 条内置指纹(表格行数不含表头正好 47)。update要访问api.impersonate.pro,本机没配api_key,未取证,所以这条只写「需要 api_key」,不写它的行为。
实战:抓电影站
把前面几节串起来:先按官方推荐用 impersonate,这台机器上不通就退回默认栈(判定逻辑见「在线站的可达性」),然后抓两页列表 + 三条详情,落 CSV 再回读核对。
1 | # -*- coding: utf-8 -*- |
1 | impersonate=chrome 走不通,退回默认栈 |
这段值得注意的四个地方,都是实测里踩出来的。
- 兜底不是可选项。带
impersonate的取样结果在Timeout、SSLError、偶尔 200 之间跳(上一节那 20 次取样),所以「先按官方推荐试 impersonate → 失败退回默认栈 → 打印最终走了哪条路」必须写成代码的一部分。还有一个细节:兜底那一行只打印「走不通」,不打印异常名——异常名本身就是抖的,打出来这块就没法两遍一致了。 - 列表页其实有评分,但要按前缀匹配。评分写在
<p class="score m-t-md m-b-n-sm">9.5</p>里,按精确串class="score"找命中 0,按class="score[^"]*"找命中 10/页。这类「class 后面还跟着别的类名」的写法在 Element UI 生成的页面里到处都是,class="name"同理(片名在<a ... class="name">里套<h2 class="m-b-sm">,写<h2 class="name">一个都取不到)。 - 详情页的导演/演员不是「导演:xxx」这种文本(
导演:在详情页命中 0)。真实结构是<h2 class="subtitle">导演</h2>→<div class="directors">→<div class="director ...">→<p class="name text-center ...">陈凯歌</p>,演员段则是<p class="el-tooltip name ...">张国荣</p>,而且旁边还有class="... role ..."的「饰:程蝶衣」要靠 class 里的name词边界排除掉。做法是先按class="subtitle">导演</h2>切到下一个 subtitle 为止,再在段内取<p ... class="[^"]*name[^"]*">…</p>。 - 一个自己写出来的 bug:
re.findall(r'href="(/detail/\d+)"', html)会把封面那个<a href="/detail/1" class="">和片名那个<a ... class="name">各算一次,页 1 命中 20 次,zip 到 20 行上就变成['/detail/1', '/detail/1', '/detail/2'],白抓重复详情。改成在切条目时就把 link 一起解析出来,前 3 条才是['/detail/1', '/detail/2', '/detail/3']。 - 输出只留结构指标:每列非空的行数(
released是 19/20,有 1 部片列表页确实不给上映日期,空值如实保留)、详情链接、CSV 回读的行数与列名、首尾各一条片名。字节数、耗时、封面长地址这些会变的全部不打印,CSV 落盘文件名带上os.getpid()以免并发互踩。
与 requests 抓同一页
同一页在线站、两种客户端,把「换库会不会改变服务端返回的内容」这件事钉死。先看 requests 侧的基线:
1 | # -*- coding: utf-8 -*- |
1 | requests 状态 200 |
再用 curl_cffi 抓同一页,取法与打印口径完全一样:
1 | # -*- coding: utf-8 -*- |
1 | curl_cffi 状态 200 |
两份输出除了第一行的库名,其余逐字相同:状态 200、resp.content 41667 字节、Content-Type: text/html; charset=utf-8(所以 encoding 直接是 utf-8,不需要 apparent_encoding)、片名 10 个、前三个是「霸王别姬 / 这个杀手不太冷 / 肖申克的救赎」。
这条结论的用意是:换 TLS/HTTP 指纹不会改变服务端返回的正文。指纹是用来骗过「你是不是浏览器」这一层检测的,不是用来改变渲染结果的。谁要是发现换了 impersonate 之后正文不一样,那多半是命中了不同的站点分支(比如按 UA 返回不同内容),不是 curl_cffi 在改你的 HTML。
顺带把本篇的定位说清楚,免得和系列其它篇混:
- 只要 HTTP 客户端、要浏览器级指纹 → curl_cffi(本篇),它不开浏览器,成本低。
- 要真的执行 JS、点按钮、处理 iframe → Selenium(见《爬虫-Selenium和PhantomJS》)或 DrissionPage(见《爬虫-DrissionPage》)。
- 要「换指纹但仍只发 HTTP 请求」的高并发抓取 → curl_cffi 配
AsyncSession(max_clients=N);verify_ssl=0这类证书开关是整数位标志(SSLVerify枚举),requests 那种verify="/路径/CA.pem"的写法在这里不行,实测见「参数面」一节。
常见坑速查
把上面各节里最容易踩的收成一张表,左列是现象,右列是本篇里对应的小节。
| 现象 | 真正的原因 | 出处 |
|---|---|---|
带 impersonate 就超时 |
本机网络丢弃带指纹的 ClientHello | 在线站的可达性 |
BrowserType.chrome 报 AttributeError |
别名只在 BrowserTypeLiteral(63 个取值)里,枚举(53 个成员)不含别名 |
装进来有什么 |
__libcurl_version__ 拿到空值 |
本机版本里这个名字不存在,特性串在 __curl_version__ |
装进来有什么 |
| 抓几次都是新连接、Cookie 为空 | 顶层 get() 每次新建 Session |
三种用法 |
莫名出现 Timeout |
Session 默认 timeout=30,requests 是 None |
参数面 |
files= 抛 NotImplementedError |
上传改走 multipart=CurlMime(...) |
参数面、从 requests 迁移 |
except RequestsError 接不住 |
抛的是 CurlError/UnicodeEncodeError/JSONDecodeError |
异常体系、WebSocket、从 requests 迁移 |
gopher:// 不报错但拿不到东西 |
scheme 不认识时直接返回 status_code=0 |
异常体系 |
| 配了代理却没走代理 | trust_env=True 读到 NO_PROXY 里的 127.0.0.1 |
代理:两种写法 |
想重试挂 HTTPAdapter |
没有 mount/adapters,只有 RetryStrategy |
从 requests 迁移 |
| 并发度数字乱跳 | 响应太快没拉开并发;要靠慢端点量峰值在途 | 下载与并发 |
headers 里同名头只拿到一个值 |
并成 'one, two',且没有 getlist() |
Response 对象 |
指纹串 ja3= 报 Cipher not found |
十进制 ja3 串这条路本机走不通 | 自定义指纹、TLS 指纹怎么看 |