Python curl_cffi 實作:Browser Impersonation、Session、Asyncio 與 WebSocket
將瀏覽器中的 API 請求搬到 Python,通常會先對照 URL、Header、Cookie 與 Request Body。這些內容能由程式直接設定,TLS 握手與 HTTP/2 連線參數卻主要由 HTTP library 決定;即使把 User-Agent 換成 Chrome,底層也可能保留原本的實作特徵。
curl_cffi 把這一層設定帶進 Python。程式可以沿用熟悉的 get()、post() 與 Session 操作,同時透過 impersonate 套用指定瀏覽器的 Transport Profile。實際使用時,仍需要確認套用的是哪個版本、伺服器觀察到什麼,以及 Cookie、Proxy、併發與資源生命週期如何配合。
這些設定的原理可對照 HTTP Client 與瀏覽器指紋:TLS ClientHello、JA3/JA4、HTTP/2 與 HTTP/3。到了程式端,驗證重點是讓 API 參數、實際連線與診斷結果能互相對照。
Quick Start
使用 Python 3.10 以上,在專用目錄建立虛擬環境並安裝固定版本:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'curl_cffi==0.16.3'
將以下程式存成 quick_start.py,以一筆 GET 查看公開診斷端點收到的連線特徵:
from curl_cffi import get, CurlHttpVersion
response = get(
"https://tls.browserleaks.com/json",
impersonate="chrome124",
timeout=15,
)
response.raise_for_status()
data = response.json()
print("HTTP version:", CurlHttpVersion(response.http_version).name)
print("JA3N:", data.get("ja3n_hash"))
python quick_start.py
impersonate="chrome124" 套用固定版本的 Browser Profile,timeout=15 設定這次傳輸的 15 秒上限;raise_for_status() 則在收到 HTTP 4xx/5xx 時拋出錯誤。
若 HTTP 版本輸出 V2_0,代表實際使用 HTTP/2;JA3N 是診斷端點根據 TLS 握手計算的摘要。這筆請求由 Python API 接收參數,再交由底層連線元件完成傳輸。
curl_cffi 架構與實作環境
Python API 與底層連線元件
curl_cffi 透過 CFFI 呼叫 curl-impersonate fork 的 libcurl。Python 處理請求參數與回應物件,底層 C library 負責實際連線及可調整的 TLS、HTTP 行為,呼叫過程不需要啟動命令列 subprocess。
Python 程式
↓
Session/AsyncSession/Low-level Curl API
↓
CFFI
↓
curl-impersonate fork/libcurl
↓
TLS、HTTP/1.1、HTTP/2、HTTP/3 與 socket
同步 Session 適合依序執行請求。AsyncSession 則透過 AsyncCurl 整合 libcurl Multi Interface 與 asyncio event loop,讓多個傳輸共用非同步事件處理;它不是替每一個 Request 開一條 Python thread。套件說明、AsyncCurl API 與 libcurl Multi Interface 分別描述這幾層的責任。
版本固定與安裝
2026-09-08 查核 PyPI 時,最新非預發布版本為 0.16.3,發布日期為 2026-09-02,最低需求為 Python 3.10。實測環境如下;這是測量結果所屬的環境,不代表所有平台的 Wheel 都具有完全相同的底層組成。
| 項目 | 實測值 |
|---|---|
| 作業系統 | Ubuntu 22.04.5 LTS,x86_64 |
| Python | CPython 3.10.12 |
| curl_cffi | 0.16.3 |
| Requests 對照組 | 2.34.2 |
| libcurl | 8.21.0-IMPERSONATE |
| TLS backend | BoringSSL |
| HTTP/2 library | nghttp2 1.63.0 |
| QUIC/HTTP/3 library | ngtcp2 1.20.0、nghttp3 1.15.0 |
| TLS/HTTP/2 固定實驗 Profile | chrome124 |
| HTTP/3 固定實驗 Profile | chrome150 |
沿用 Quick Start 的虛擬環境,再安裝 CLI 與 Requests 對照組所需的套件:
python -m pip install 'curl_cffi[cli]==0.16.3' 'requests==2.34.2'
python -m pip check
[cli] 安裝命令列工具所需的額外依賴。相同固定版本已放在配套的 requirements.txt;需要保存完整環境時,再將 python -m pip freeze 的結果存入自己的 lock/環境紀錄。只固定頂層套件,不等於所有間接依賴都已鎖定。
以下程式列出 Python、套件與真正使用的 libcurl 版本:
import platform
import curl_cffi
from curl_cffi import Curl
print(platform.python_version())
print(curl_cffi.__version__)
curl = Curl()
try:
print(curl.version().decode())
finally:
curl.close()
Curl 在 0.16.3 沒有一般 with Curl() 的 Context Manager 介面,低階 handle 要使用 try/finally 關閉。作業系統的 curl --version 顯示的是另一個執行檔,不能代替上面的檢查。
Browser Target 與 Profile 資料
安裝後先列出本機實際可用的 Target:
curl-cffi list
實測清單包含 chrome124、chrome146、chrome150、firefox147 等內建 Profile,也會列出作業系統資訊與 h3_fingerprints 欄位。chrome124 的 HTTP/3 指紋標記為 false,chrome150 為 true。能協商 HTTP/3,與具有該瀏覽器的 QUIC/HTTP/3 Profile,是兩個不同條件。
impersonate="chrome" 是方便追隨預設 Target 的名稱;impersonate="chrome124" 則指定版本。這裡選擇 chrome124 是為了固定 TLS/HTTP/2 對照條件,不是把舊版 Chrome 當成現行使用者的代表。Profile 清單也不會涵蓋每一次瀏覽器 release。官方 Target 說明 有列出版本選擇與更新方式。
從 0.15.1 起,Profile 資料還能透過 curl-cffi update 更新。因此,可重現環境除了套件版本,還要記錄本機 Target 清單、是否下載過額外 Profile,以及所用 JSON 資料的版本或 checksum。套件未升級,並不保證本機 Profile cache 完全沒變。
Browser Impersonation 與指紋對照
四組 Client 的比較
先把變因限縮成 Client 與 User-Agent,向公開的 BrowserLeaks TLS 診斷端點送出四筆 GET:一般 Requests、只修改 Header 的 Requests、未指定 Profile 的 curl_cffi,以及固定 chrome124 的 curl_cffi。
import json
import requests
from curl_cffi import Session, CurlHttpVersion, CurlOpt
URL = "https://tls.browserleaks.com/json"
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
FIELDS = ("ja3_hash", "ja3n_hash", "ja4", "akamai_hash")
def show(name, response, version):
response.raise_for_status()
data = response.json()
print(json.dumps({
"client": name,
"http_version": version,
"fingerprints": {key: data.get(key) for key in FIELDS},
}, ensure_ascii=False))
for name, headers in (("requests", {}), ("requests_ua", {"User-Agent": UA})):
with requests.Session() as session:
session.trust_env = False
response = session.get(URL, headers=headers, timeout=(5, 15))
version = {10: "1.0", 11: "1.1", 20: "2"}.get(response.raw.version)
show(name, response, version)
for name, profile in (("curl_cffi", None), ("chrome124", "chrome124")):
with Session(curl_options={CurlOpt.PROXY: ""}) as session:
response = session.get(URL, impersonate=profile, timeout=15)
show(name, response, CurlHttpVersion(response.http_version).name)
這個小範例只輸出指紋摘要,不列印端點回傳的完整資料。CurlOpt.PROXY: "" 明確要求 libcurl 直連,方便固定比較條件;需要經過公司 Proxy 的環境,應改成已知設定並記錄其 TLS 工作模式。
2026-09-08,在上述 Ubuntu 環境的觀察結果如下:
| Client | 實際 HTTP 版本 | JA3N Hash | Akamai Hash |
|---|---|---|---|
| Requests | HTTP/1.1 | 62fcc66dfa1611e219a93df2d1bb1b24 |
空值,未建立 HTTP/2 |
| Requests+Chrome User-Agent | HTTP/1.1 | 62fcc66dfa1611e219a93df2d1bb1b24 |
空值,未建立 HTTP/2 |
| curl_cffi,未指定 Profile | HTTP/2 | 4fd7cab6c51893a22b46123125be5bae |
52d84b11737d980aef856699f885ca86 |
| curl_cffi,chrome124 | HTTP/2 | 4c9ce26028c11d7544da00d3f7e4f45c |
52d84b11737d980aef856699f885ca86 |
Requests 的兩組結果顯示,只修改 User-Agent 沒有改變這次量測的 TLS 摘要。未指定 Profile 的 curl_cffi 和 chrome124 則具有不同的 JA3N,但這次 Akamai Hash 相同:切換 Profile 不代表每一層的每一個摘要都一定變動。
chrome124 的重複觀測還出現不同 JA3 Hash,但 JA3N 與 JA4 保持相同;這與 Extension Permutation 的特性一致。這些數值是特定日期、版本與診斷服務的觀察值,不應寫進應用程式當成永久驗收常數。這次也沒有同版真實 Chrome 的封包對照,因此結果能證明 Profile 改變了觀測特徵,不能宣稱完整重現了真實瀏覽器。
預設 Header 與請求情境
套用 Profile 時,default_headers=True 會一併加入該 Target 的預設 Header。Request 的 headers 可以覆寫其中的值;default_headers=False 則停用 Profile 預設 Header,但 libcurl 仍可能產生 Host、Accept 等傳輸所需或自身預設欄位。
from curl_cffi import Session
with Session(impersonate="chrome124", timeout=15) as session:
response = session.get(
"https://httpbin.org/headers",
headers={"Accept": "application/json"},
)
response.raise_for_status()
assert response.json()["headers"]["Accept"] == "application/json"
這裡的 Accept 表達期望 JSON 回應。真正的 Browser Profile 還可能包含 Client Hints 與 Sec-Fetch-* 等欄位;API Fetch、首頁導覽和圖片下載的情境不同,不能只改 Accept 就認定整組 Header 都符合該請求。若自行停用預設 Header,就需要負責整套宣告的一致性。
若操作需要執行 JavaScript、讀取 DOM、使用 Canvas/WebGL,或完成依賴前端狀態的互動流程,就需要實際的瀏覽器執行環境,例如使用 Playwright 操作瀏覽器。這是 Runtime 的需求,無法透過調整 TLS Profile 補足。
HTTP 請求、Response 與 Session
Request Body 與回應解析
API 外觀接近 Requests,但資料型別與資源處理仍要依實測版本確認。下列範例以 httpbin 的 echo 行為示範 Query、Form、JSON 與 Raw Body;可以透過 HTTP_ECHO_BASE 改成提供相同介面的自有服務。
import os
from curl_cffi import Session
BASE = os.environ.get("HTTP_ECHO_BASE", "https://httpbin.org")
with Session(impersonate="chrome124", timeout=15) as session:
query = session.get(f"{BASE}/get", params={"page": 1, "tag": ["tls", "http2"]})
query.raise_for_status()
assert query.json()["args"]["tag"] == ["tls", "http2"]
form = session.post(f"{BASE}/post", data={"name": "sample"})
form.raise_for_status()
assert form.json()["form"]["name"] == "sample"
payload = session.post(f"{BASE}/post", json={"enabled": True, "count": 2})
payload.raise_for_status()
assert payload.json()["json"]["count"] == 2
raw = session.post(
f"{BASE}/post",
content=b"sample body",
headers={"Content-Type": "application/octet-stream"},
)
raw.raise_for_status()
assert raw.json()["data"] == "sample body"
for method in ("PUT", "PATCH", "DELETE"):
response = session.request(method, f"{BASE}/anything", json={"sample": True})
response.raise_for_status()
assert response.json()["method"] == method
params 負責 URL 查詢參數;data=dict 編碼 Form;json 執行 JSON 序列化並設定對應 Content-Type;content 接受原始 bytes 或支援的串流來源。同一筆請求應依實際資料契約選一種 Body 表達方式,避免同時傳入互相衝突的參數。Quick Start 與 0.16.3 參數轉換原始碼 可核對實際處理流程。
Response 常用介面如下:
| 介面 | 用途與限制 |
|---|---|
status_code、raise_for_status() |
取得 HTTP 狀態、將 4xx/5xx 轉成 HTTPError;取得 Response 不等於業務成功 |
headers |
以大小寫不敏感方式讀取回應 Header |
content |
取得緩衝後的 bytes;大型回應需考慮記憶體 |
text、encoding |
解碼文字;需要覆寫 encoding 時,應在首次讀取 text 前設定 |
json() |
解析 JSON;200 回應仍可能含 HTML 或格式錯誤資料 |
url、history |
最終 URL 與重導歷程;history 保留狀態、URL、Header,不保留中間 Body |
cookies |
目前 Response 的 Cookie;跨重導與多次請求的累積狀態應查看 Session Cookie Jar |
elapsed |
0.16.3 為 timedelta,以 elapsed.total_seconds() 取得秒數 |
http_version |
libcurl 列舉值,應用 CurlHttpVersion(...) 轉換,不要直接把整數當 HTTP 版本 |
Basic Authentication 使用 auth=(username, password);真實帳密應從環境設定或秘密管理服務取得,只透過 HTTPS 傳送,也不應將 tuple、Authorization Header 或完整 Response dump 到 log。
Timeout 與 Redirect
curl_cffi 0.16.3 的 timeout 二元組不能直接當成 Requests 的「connect timeout、每次讀取等待時間」。實際轉換如下:
| 設定 | 非 stream=True |
stream=True |
|---|---|---|
timeout=15 |
設定整筆 transfer 的 15 秒上限 | 連線逾時 15 秒,另以低速條件控制持續傳輸 |
timeout=(3, 12) |
Connect 上限 3 秒,整筆 transfer 上限 15 秒 | Connect 上限 3 秒,低於 1 byte/s 持續約 15 秒時中止 |
timeout=None |
停用對應 timeout | 不適合作為無人值守工作的預設 |
二元組的第二個值雖然在原始碼命名為 read_timeout,非串流模式卻會和 connect 值相加,形成總上限;串流模式則使用 LOW_SPEED_LIMIT 與 LOW_SPEED_TIME。有資料緩慢流動的串流可能持續很久,不能把這組設定當成工作總 deadline。0.16.3 實作、libcurl TIMEOUT 與 LOW_SPEED_TIME 說明了兩種限制的差別。
Redirect 預設會跟隨,固定診斷對象時可以關閉:
from curl_cffi import Session
with Session(timeout=15, allow_redirects=False) as session:
response = session.get("https://httpbin.org/redirect/1")
assert response.status_code == 302
response = session.get(
"https://httpbin.org/redirect/2",
allow_redirects=True,
max_redirects=3,
)
response.raise_for_status()
assert [item.status_code for item in response.history] == [302, 302]
允許 Redirect 時,最終目的地可能改變。攜帶帳密、Cookie 或可重送 Body 的程式,還要考慮跨主機跳轉與重新傳送的條件;raise_for_status() 本身不會把 302 視為失敗。
多次操作同一個服務時,Session 可以延續 Cookie 並重用底層連線。Session 預設參數與 Request 覆寫的責任可以分開:Profile、Proxy 與身分維持固定,單次 Request 再提供路徑、Body 與必要 Header。
from curl_cffi import Session
with Session(impersonate="chrome124", timeout=15) as session:
response = session.get("https://httpbin.org/cookies/set/article/demo")
response.raise_for_status()
response = session.get("https://httpbin.org/cookies")
response.raise_for_status()
assert response.json()["cookies"]["article"] == "demo"
session.cookies.set("theme", "dark", domain="httpbin.org", path="/")
assert session.cookies.get("theme", domain="httpbin.org", path="/") == "dark"
session.cookies.delete("theme", domain="httpbin.org", path="/")
Cookie 名稱相同、Domain 或 Path 不同時,可能對應多筆值,因此管理 Cookie 時應保留範圍條件。不同帳號、Cookie 歷史、Proxy 或 Profile 應使用獨立 Session,避免同一連線池和狀態容器混入互不相干的身分。
Keep-Alive 重用的是既有連線;TLS Session Resumption 可能在新連線重用先前協商狀態;HTTP/2 multiplexing 則是在同一條連線上同時處理多個 stream。三者不等價,只有依序執行 session.get(),並不代表已測到並行 multiplexing。
同步 Session 的文件標示 thread-safe,實作預設使用 thread-local Curl handle,但官方仍建議每個 thread 使用獨立 Session。共享 Cookie Jar 與應用程式身分的邏輯一致性,也需要呼叫端自己管理。Asyncio 的 AsyncSession 則應留在建立它的 event loop 內使用。Session 原始碼與介面說明 可核對這些生命週期。
Multipart 檔案上傳
Multipart 使用 CurlMime,而不是 Requests 的 files=。檔案路徑與記憶體資料分別以 local_path、data 指定;同一 part 選擇其中一種來源。
from pathlib import Path
from tempfile import TemporaryDirectory
from curl_cffi import CurlMime, Session
with TemporaryDirectory() as folder:
path = Path(folder) / "sample.txt"
path.write_text("sample file", encoding="utf-8")
multipart = CurlMime()
try:
multipart.addpart(
name="attachment", filename="sample.txt",
content_type="text/plain", local_path=str(path),
)
multipart.addpart(
name="metadata", filename="metadata.json",
content_type="application/json", data=b'{"sample":true}',
)
with Session(timeout=15) as session:
response = session.post(
"https://httpbin.org/post",
data={"label": "article"}, multipart=multipart,
)
response.raise_for_status()
assert response.json()["files"]["attachment"] == "sample file"
finally:
multipart.close()
重複呼叫 addpart() 即可加入多個檔案,是否使用相同欄位名稱取決於 Server 契約。Boundary 交由 libcurl 建立,不要另外寫一個缺少或不符 Boundary 的 Content-Type。大型檔案優先提供路徑,避免先 read() 整個檔案放進 Python 記憶體;檔案在傳輸完成前必須保持可讀。
Proxy、TLS 與 HTTP 版本
Proxy 與 DNS 解析位置
proxy 設定單一代理,proxies 則可依 URL scheme 配置。HTTPS 經 HTTP Proxy 通常使用 CONNECT tunnel;http://proxy:3128 中的 scheme 描述連到 Proxy 的方式,不代表目標站只能使用 HTTP。
import os
from curl_cffi import Session
with Session(proxy=os.environ["HTTP_PROXY_URL"], timeout=15) as session:
response = session.get("https://httpbin.org/get")
response.raise_for_status()
print(response.status_code)
HTTP_PROXY_URL 是使用者提供的測試 Proxy 位址;需要認證時可另傳 proxy_auth=(username, password)。SOCKS 使用 socks5:// 時,由 Client 解析目標 hostname;socks5h:// 交由 Proxy 解析。Proxy 的 DNS 行為、出口 IP 與是否攔截 TLS,都會影響指紋實驗的觀測條件。
trust_env=False 在 0.16.3 不能被當成「libcurl 完全忽略所有環境設定」的保證。需要明確直連時使用獨立 Session,設定 curl_options={CurlOpt.PROXY: ""};這個低階選項會覆蓋高階 Proxy 設定,不應放進同時需要使用 Proxy 的 Session。libcurl Proxy 選項 定義了空字串停用 Proxy 的行為。
CA 驗證與 mTLS
一般 HTTPS 預設驗證 Server 憑證。0.16.3 的預設 CA 來源可能受到 SSL_CERT_FILE、CURL_CA_BUNDLE、REQUESTS_CA_BUNDLE 影響,否則再依 Python 預設 CA 路徑與 certifi 選擇,不能假設每個環境都只使用 Wheel 內同一份 CA。連到自有 CA 簽發的服務時,明確提供信任 CA 檔案;需要 mTLS 時,再提供 Client Certificate 與 Private Key。
import os
from curl_cffi import Session
with Session(
verify=os.environ["TEST_CA_FILE"],
cert=(os.environ["TEST_CLIENT_CERT"], os.environ["TEST_CLIENT_KEY"]),
timeout=15,
) as session:
response = session.get(os.environ["TEST_MTLS_URL"])
response.raise_for_status()
print(response.status_code)
四個環境變數依序代表 CA 信任檔、Client 憑證、Client 私鑰與自有 mTLS 測試 URL。CA 驗證回答「是否信任 Server」,Client 憑證則讓 Server 驗證呼叫端;Profile 不會替代其中任何一項。verify=False 會失去 Server 憑證驗證,不應拿來修補 CA 路徑或 hostname 配置錯誤。
HTTP/1.1、HTTP/2 與 HTTP/3
明確指定 HTTP 版本後,仍要讀取 response.http_version,確認最後協商的結果。V2_0 與 V3 具有 fallback 語意;V3ONLY 則要求 HTTP/3,不能降回 TCP 上的 HTTP。libcurl HTTP_VERSION 也提醒,既有連線重用可能影響請求結果,因此不同 protocol 實驗適合建立獨立 Session。
http_version 設定 |
新連線的要求 |
|---|---|
CurlHttpVersion.V1_1 |
使用 HTTP/1.1 |
CurlHttpVersion.V2_0 |
嘗試 HTTP/2,未協商成功可退回 HTTP/1.1 |
CurlHttpVersion.V3 |
嘗試 HTTP/3,允許退回較早版本 |
CurlHttpVersion.V3ONLY |
只嘗試 HTTP/3,失敗時不降級 |
from curl_cffi import Session, CurlHttpVersion, CurlOpt
with Session(curl_options={CurlOpt.PROXY: ""}, timeout=12) as session:
response = session.get(
"https://fp.impersonate.pro/api/http3",
impersonate="chrome150",
http_version=CurlHttpVersion.V3ONLY,
)
response.raise_for_status()
assert response.http_version == CurlHttpVersion.V3
data = response.json()
print({"status": response.status_code, "version": "HTTP/3", "fields": list(data)})
2026-09-08 的實測回傳 HTTP 200,實際版本為 V3,JSON 最上層包含 info、protocol、quic、http3 與 tls。這證明該環境能以指定 Client 完成 HTTP/3 診斷請求;要認定 QUIC Transport Parameters、HTTP/3 SETTINGS 與真實 Chrome 相同,仍需要逐欄對照。
若 UDP 出口、Proxy 或 Server 不支援 HTTP/3,V3ONLY 可能失敗。一般 HTTP CONNECT Proxy 不能自動承載 QUIC;套件提供的 UDP Proxy 能力也需要 Proxy 端配合,不能只把 HTTP Proxy URL 填入就假設成立。
DoH、來源介面與傳輸參數
高階 Request 還提供以下控制。這些設定必須和本機網路及 Server 契約配合,不能只靠參數名稱推定實際封包。
| 參數 | 用途 |
|---|---|
doh_url |
使用指定 DNS-over-HTTPS resolver;仍要考慮 resolver 位址解析與 Proxy 的 DNS 模式 |
interface |
綁定網路介面或本機來源位址;該介面/IP 必須存在且有可用路由 |
quote |
控制 URL quoting;False 停用額外 quote 行為,呼叫端需提供有效 URL |
accept_encoding |
宣告支援的壓縮格式,配合套件的解壓能力 |
max_recv_speed |
接收速率限制,單位為 bytes/s;不是同站所有 Client 的全域速率限制 |
referer |
設定 Referer,需符合實際請求情境 |
curl_options |
Session 層補充高階 API 未提供的 libcurl options,須避免和既有參數衝突 |
interface="127.0.0.1" 只適合連到本機可達的測試服務,不能用它建立一般外網連線。切換來源 IP、Proxy 或 Profile 時,重新建立對應 Session,也能避免把先前的 connection pool 狀態帶入新實驗。
Asyncio、Streaming 與 WebSocket
AsyncSession 與有界併發
當請求彼此獨立,可以使用 AsyncSession 同時等待多個網路回應。併發設計需要同時限制 Python Task 與底層傳輸;只設定 max_clients,不會阻止程式先建立數十萬個等待中的 Task。
以下以小批次處理 URL,每一批最多三筆,批內結果保留輸入順序:
import asyncio
from curl_cffi import AsyncSession
from curl_cffi.requests.exceptions import RequestException
async def fetch_many(urls, concurrency=3):
if concurrency < 1:
raise ValueError("concurrency must be positive")
results = []
async with AsyncSession(
impersonate="chrome124", max_clients=concurrency, timeout=10
) as session:
async def fetch(index, url):
try:
response = await session.get(url)
response.raise_for_status()
return {"index": index, "status": response.status_code, "error": None}
except RequestException as error:
return {"index": index, "status": None, "error": type(error).__name__}
for start in range(0, len(urls), concurrency):
batch = urls[start:start + concurrency]
results.extend(await asyncio.gather(*(
fetch(start + offset, url) for offset, url in enumerate(batch)
)))
return results
這個函式適合有限且回應不大的 URL 清單;它仍會保存完整輸入與結果,並由普通 get() 緩衝 Body。大量工作可以改成有容量限制的 asyncio.Queue 與固定數量 worker,再由單一 writer 持續輸出。Semaphore 適合限制共享資源,但若外層一次建立全部 Task,仍沒有控制 Task 本身的記憶體。
max_clients 表示可同時借用的 Curl handle 數量,不能直接當成 TCP 連線數或 HTTP/2 stream 數。整批工作需要總 deadline 時,可在 Python 3.10 使用 asyncio.wait_for(fetch_many(...), timeout=...)。取消應向外傳遞,讓 async with 執行清理,不能將 CancelledError 當一般可重試的網路錯誤吞掉。
Streaming Download 與記憶體上限
stream=True 或 session.stream() 可以逐塊消費回應:
from curl_cffi import Session, CurlOpt
with Session(curl_options={CurlOpt.TIMEOUT_MS: 15_000}) as session:
with session.stream("GET", "https://httpbin.org/bytes/4096", timeout=10) as response:
response.raise_for_status()
received = 0
for chunk in response.iter_content():
received += len(chunk)
assert received == 4096
TIMEOUT_MS 額外設定整筆 transfer 上限,補足串流模式的低速 timeout。Context Manager 也確保提早停止迭代時關閉 Response。
但迭代介面本身不保證固定記憶體。0.16.3 使用內部無界 queue 將 libcurl callback 轉成 chunk iterator;Consumer 比網路慢時,資料仍可能堆積。WebSocket 的 queue-size 參數不能直接套用到 HTTP streaming。官方 Streaming 說明 明確記錄了這個限制。
對下載寫檔,可以直接使用同步 content_callback,在收到資料時檢查大小並寫入暫存檔:
from pathlib import Path
from tempfile import NamedTemporaryFile
from curl_cffi import Session
class DownloadTooLarge(Exception):
pass
def download(url: str, output: Path, max_bytes: int) -> int:
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
partial = None
received = 0
try:
with NamedTemporaryFile(
mode="wb", dir=output.parent, prefix=output.name + ".", delete=False
) as destination:
partial = Path(destination.name)
def on_chunk(chunk: bytes) -> int:
nonlocal received
if received + len(chunk) > max_bytes:
raise DownloadTooLarge("response exceeds size limit")
written = destination.write(chunk)
if written != len(chunk):
raise OSError("incomplete file write")
received += written
return written
with Session(timeout=30, allow_redirects=False) as session:
response = session.get(url, content_callback=on_chunk)
response.raise_for_status()
if not 200 <= response.status_code < 300:
raise ValueError("unexpected HTTP status")
partial.replace(output)
return received
finally:
if partial is not None:
partial.unlink(missing_ok=True)
output 是最終檔案路徑,父目錄需要先存在;max_bytes 是允許寫入的回應 Body 上限。暫存檔與目的檔位於同一目錄,只有成功接收並確認狀態後才原子替換;中途錯誤會刪除暫存檔,既有目的檔保持原狀。這提供一般檔案可見性的原子更新,不等於已完成斷電耐久性所需的 fsync 流程。
使用不同暫存檔可避免過程互相覆寫,但兩個工作若仍指定相同最終路徑,最後完成者會取代先前檔案。需要禁止覆寫或協調多工時,應在應用程式另外定義輸出所有權。
0.16.3 會將 callback 的 Python exception 傳回呼叫端。不要靠 return 0 中止下載:這個版本對不符長度的普通回傳值可能只發 warning,仍向 libcurl 回報資料已處理。需要以回傳值中止時,應使用該版本的 CURL_WRITEFUNC_ERROR 常數。Write callback 實作 可核對這個差異。
content_callback 即使用在 AsyncSession 也仍是同步 callback,不應直接放入 async def,也不適合執行耗時工作阻塞 event loop。一般小型診斷 Body 可直接在 callback 中累積到明確上限;大量非同步磁碟處理需要另外設計有界緩衝及 producer 暫停策略。
Streaming Upload 與 Body 重送
0.16.3 的 content 已能接受 binary file、同步 bytes iterable;AsyncSession 還能接受 async bytes iterable。高階上傳不必為了逐塊供應資料就一律改寫成 Low-level API。
import asyncio
import os
from curl_cffi import AsyncSession
async def upload():
async def chunks():
yield b"first\n"
await asyncio.sleep(0)
yield b"second\n"
base = os.environ.get("HTTP_ECHO_BASE", "https://httpbin.org")
async with AsyncSession(timeout=15) as session:
response = await session.post(
f"{base}/post",
content=chunks(),
headers={"Content-Type": "application/octet-stream"},
)
response.raise_for_status()
assert response.json()["data"] == "first\nsecond\n"
asyncio.run(upload())
已知長度時可以提供正確的 Content-Length,未知長度則交由 libcurl 使用所協商版本的串流 framing;HTTP/2、HTTP/3 不使用 HTTP/1.1 的 chunked transfer encoding。
重試或 307/308 Redirect 可能需要重送 Body。可 seek 的 binary file 能回到原始位置,一次性 generator 則未必可重播;0.16.3 對不能回捲的來源會拋出 UnrewindableBodyError。即使來源可以重播,也仍要確認重送請求是否會產生重複副作用。
Async WebSocket 生命週期
WebSocket 先建立 HTTP handshake,再進入雙向訊息傳輸。連線建立的 Profile、Cookie、Proxy 與認證設定,和連線成立後的訊息順序、queue 與重連狀態,是不同層次的責任。
import asyncio
import os
from curl_cffi import AsyncSession, CurlWsFlag
async def exchange(url):
async with AsyncSession(impersonate="chrome150") as session:
async with session.ws_connect(
url,
timeout=10,
recv_queue_size=32,
send_queue_size=32,
max_message_size=1024 * 1024,
) as ws:
await ws.send("hello", flags=CurlWsFlag.TEXT, timeout=5)
assert await ws.recv_str(timeout=5) == "hello"
await ws.send_bytes(b"sample")
payload, flags = await ws.recv(timeout=5)
assert payload == b"sample"
assert flags & CurlWsFlag.BINARY
await ws.send_json({"sample": True})
assert await ws.recv_json(timeout=5) == {"sample": True}
asyncio.run(asyncio.wait_for(exchange(os.environ["TEST_WS_URL"]), timeout=20))
TEST_WS_URL 指向自有或授權的 echo WebSocket。配套驗證使用 loopback ws:// 服務,因此驗證的是 API、訊息與生命週期;它不構成 WSS TLS 指紋已和真實瀏覽器對齊的證據。
AsyncSession.ws_connect() 回傳可用於 async with、也可 await 的物件。若使用 ws = await session.ws_connect(...),就要在 finally 裡 await ws.close()。上述結構將兩層關閉責任交給 Context Manager。官方 WebSocket 文件 與 0.16.3 實作 可核對詳細介面。
| 設定或方法 | 實際語意 |
|---|---|
ws_connect(timeout=...) |
控制建立連線階段,不等於每筆訊息的接收時限 |
send_str()/send_bytes()/send_json() |
明確選擇訊息表示方式;一般 send() 預設是 binary,即使 payload 是 Python str |
send(..., timeout=...) |
限制等待 send queue 空間的時間;回傳不代表 Server 已處理 |
flush(timeout=...) |
等背景 writer 將資料交給 socket;業務確認仍需要 Server ACK |
recv_str(timeout=...) 等 |
限制等待下一則訊息的時間 |
recv_queue_size、send_queue_size |
queue 容量以訊息數計算,不是 bytes |
max_message_size |
限制完整 incoming message,無法代替 outgoing payload 大小限制 |
block_on_recv_queue_full=True |
接收 queue 滿時等待 Consumer,形成 backpressure |
async for message in ws |
逐則取得 bytes;需要明確 timeout 時使用 recv 方法 |
接收 queue 滿時設為不等待,會導致錯誤,而不是默默捨棄訊息。對外送資料也應限制單則 bytes,避免少量超大訊息占滿記憶體。
ping() 將 Ping 放入傳送流程,不代表已等到 Pong;Pong 也不是一般 recv() 的應用訊息。Close frame 負責結束協定會話,Context Manager 再完成資源清理。重新連線後,訂閱、最後事件 ID、去重與補資料都需要應用程式恢復,不能把 transport retry 當成業務狀態已回復。
自訂 Fingerprint 與 Low-level API
內建 Target 與可編輯資料
一般需求優先選定內建 Target,避免逐項重建 TLS 與 HTTP 設定。需要編輯完整 Profile 時,要區分 libcurl 內建名稱,和儲存在本機快取中的完整 Fingerprint 資料。
0.16.3 的 get_fingerprint("chrome146") 對內建 Target 可以回傳 metadata 與預設欄位,但不會將 libcurl 裡的完整 Profile 反解出來。實測取得的 tls_ciphers、headers 與 http2_settings 為空,因此不能把它轉存後宣稱已匯出完整 Chrome Profile。
對已取得、可信任且內容完整的同版本 JSON,可使用標準 dataclass 序列化:
import json
import os
from dataclasses import asdict
from pathlib import Path
from curl_cffi import Fingerprint
source = Path(os.environ["FINGERPRINT_SOURCE"])
target = Path(os.environ["FINGERPRINT_OUTPUT"])
fingerprint = Fingerprint(**json.loads(source.read_text(encoding="utf-8")))
fingerprint.headers["Accept-Language"] = "zh-TW,zh;q=0.9"
with target.open("x", encoding="utf-8") as destination:
json.dump(asdict(fingerprint), destination, ensure_ascii=False, indent=2)
FINGERPRINT_SOURCE 指向自行管理的 Profile JSON,FINGERPRINT_OUTPUT 是尚未存在的輸出檔;後續可將載入的物件傳入 session.get(..., impersonate=fingerprint)。Fingerprint 沒有 save()/load() 方法,dataclass 也不會代替完整的資料驗證。這段序列化流程已用自有測試資料驗證,沒有下載或驗證商業 Profile。
Profile JSON 不應混入真實 Cookie、Authorization 或其他帳密。跨套件版本載入時,要重新確認 schema、預設值與底層支援能力。Fingerprint 管理文件 與 0.16.3 fingerprints.py 說明了資料來源與快取機制。
JA3、Akamai、Extra Fingerprint 與 HTTP/3
當目標是重現自有程式或授權測試中的已知封包,可以直接提供各層設定:
| 參數 | 輸入資料 |
|---|---|
ja3 |
五組欄位的原始 JA3 字串,不能填入 MD5 Hash |
akamai |
SETTINGS、WINDOW_UPDATE、PRIORITY、pseudo-header order 的原始字串 |
extra_fp |
JA3/Akamai 未涵蓋的額外 TLS 或 HTTP 設定 |
perk |
HTTP/3 SETTINGS、pseudo-header order、QUIC Transport Parameters 三段原始資料 |
perk 以 | 分隔三段資料,並不等於啟用 HTTP/3;仍要設定 protocol 並確認實際結果。JA4 也不是可直接填入某個 JA4 Hash 就重建所有封包的設定介面。
這些參數應來自可追溯的封包與明確 schema,避免隨機拼湊不相容的 Cipher、Extension 與 Header。多個 Profile 設定來源同時使用時,也要確認覆寫順序與最終結果。官方自訂 Fingerprint 說明 提供原始字串格式與額外控制項。
Curl Handle、Callback 與傳輸資訊
需要特定 CURLOPT、callback 或 timing 時,可以使用 Low-level Curl。下列程式分開接收 Body 與 Header,並在關閉 handle 前讀取狀態、HTTP 版本與耗時:
from io import BytesIO
from curl_cffi import Curl, CurlInfo, CurlOpt, CurlHttpVersion
body = BytesIO()
headers = BytesIO()
curl = Curl()
try:
curl.setopt(CurlOpt.URL, "https://httpbin.org/get")
curl.setopt(CurlOpt.HTTPHEADER, [b"Accept: application/json"])
curl.setopt(CurlOpt.WRITEDATA, body)
curl.setopt(CurlOpt.HEADERFUNCTION, headers.write)
curl.setopt(CurlOpt.TIMEOUT_MS, 15_000)
curl.perform()
print({
"status": curl.getinfo(CurlInfo.RESPONSE_CODE),
"http_version": CurlHttpVersion(curl.getinfo(CurlInfo.HTTP_VERSION)).name,
"dns_seconds": curl.getinfo(CurlInfo.NAMELOOKUP_TIME),
"connect_seconds": curl.getinfo(CurlInfo.CONNECT_TIME),
"tls_seconds": curl.getinfo(CurlInfo.APPCONNECT_TIME),
"total_seconds": curl.getinfo(CurlInfo.TOTAL_TIME),
})
finally:
curl.close()
這些 timing 多為從傳輸起點計算的累積時間,不能直接當成互不重疊的階段耗時。連線重用也可能讓部分階段為零。需要最終 URL 或遠端 IP 時,可查看 EFFECTIVE_URL、PRIMARY_IP,但輸出前要評估其中的 query、內網位址與其他敏感資訊。
高階 Session 可設定 curl_infos=[CurlInfo.TOTAL_TIME, ...],讓套件在 reset 前把指定資訊保存到 response.infos。比起事後讀取可能已被重用的 response.curl,這種做法更符合高階 handle 的生命週期。
低階上傳亦已支援 Python read callback:
from io import BytesIO
from curl_cffi import Curl, CurlInfo, CurlOpt
payload = b"upload via READFUNCTION"
source = BytesIO(payload)
body = BytesIO()
curl = Curl()
try:
curl.setopt(CurlOpt.URL, "https://httpbin.org/put")
curl.setopt(CurlOpt.UPLOAD, 1)
curl.setopt(CurlOpt.INFILESIZE_LARGE, len(payload))
curl.setopt(CurlOpt.READFUNCTION, source.read)
curl.setopt(CurlOpt.WRITEDATA, body)
curl.setopt(CurlOpt.TIMEOUT_MS, 15_000)
curl.perform()
assert curl.getinfo(CurlInfo.RESPONSE_CODE) == 200
finally:
curl.close()
Read callback 接收允許讀取的最大長度,回傳不超過該長度的 bytes,空 bytes 代表 EOF。來源物件與 callback 在傳輸結束前都必須存活;若需要重導或 retry,還要自行處理可回捲條件。0.16.3 Curl 實作 是 Python callback 契約的直接依據。
CLI 與 Scrapy 整合
CLI 適合人工診斷與確認本機 Target:
curl-cffi list --json
curl-cffi get https://tls.browserleaks.com/json --impersonate chrome124
CLI GET 可能印出完整診斷回應,適合在受控終端查看,不應直接將 stdout 當成正式環境的安全 log。curl-cffi update 會向 Profile 服務取得資料並改變本機 cache;重現既有實驗時,應先保存當前版本與 Profile 紀錄。
Scrapy 可透過社群 Download Handler 整合 curl_cffi,例如 scrapy-curl-cffi。整合時需要明確分配 Retry、Cookie、Proxy 與 concurrency 的責任,避免 Scrapy 與 Client 同時重試造成請求倍增,也要核對 adapter 的 TLS 預設:該專案 README 將 verify 預設列為 False,採用前應明確啟用並測試憑證驗證,不能沿用原生 Session 的假設。
Scrapy 用來去重的 Request Fingerprint,與網路層的 TLS Fingerprint 也有不同用途。Scrapy Request Fingerprints 定義的是請求識別與去重規則。
錯誤處理與 Diagnostic Client
錯誤分類與重試條件
HTTP 請求至少經過連線、協定、狀態碼與資料解析四個階段,錯誤處理應保留這些差異:
| 類型 | 辨識方式 | 處理方向 |
|---|---|---|
| DNS、Connect、傳輸 Timeout | RequestException 子類別或 libcurl code | 依錯誤與剩餘預算判斷是否重試 |
| 憑證、hostname、Proxy 認證 | 對應 TLS/Proxy error | 修正信任或設定,不自動降級驗證 |
| HTTP 429、502、503、504 | Response 狀態 | 視操作是否可重送、Retry-After 與剩餘時間決定 |
| HTTP 400、401、403、404 | Response 狀態 | 通常需要修正資料、權限或路徑,不能盲目重送 |
| JSON/資料契約錯誤 | ValueError、欄位型別或必要欄位檢查 |
與網路錯誤分開,保留可排查原因 |
| 輸出檔案失敗 | OSError |
中止工作,以非零 exit code 表達失敗 |
| 工作取消 | CancelledError |
清理資源並向外傳遞 |
response.json() 可能使用標準函式庫或其他 JSON backend;捕捉 ValueError 比只假設它一定丟出套件自訂的 JSONDecodeError 更穩妥。對外紀錄應保留錯誤分類、狀態與可重試性,避免直接列印可能包含 URL、Header 或內部路徑的 exception 字串。
0.16.3 提供 Session(retry=...) 與 RetryStrategy,但內建策略不是完整的業務重試政策:它在 RequestException 後重試,不能代替 HTTP status、Retry-After、方法冪等性及整體 deadline 的判斷。POST 在回應前斷線,也可能已經在 Server 完成寫入;重送前需要 idempotency key 或其他服務端保證。0.16.3 Retry 實作 可核對其作用範圍。
完整 Diagnostic Client
配套的 diagnostic.py 將前面的控制整合成可以直接執行的 CLI:有限數量的 GET、固定 Profile、有界併發、Body 上限、重試預算,以及逐筆 JSON Lines 輸出。
git clone https://github.com/hsunAlfred/http-client-fingerprint-examples.git
cd http-client-fingerprint-examples/python-curl-cffi
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python diagnostic.py \
--url https://tls.browserleaks.com/json \
--profile chrome124 \
--concurrency 2 \
--output fingerprint-results.jsonl
參數、輸出欄位與 exit code 的完整契約見 範例 README。每筆輸入以 index 對應結果,不把完整 URL 寫入輸出;Profile、HTTP 版本、狀態、耗時與錯誤分類則保留,方便比較同一組測量。
Diagnostic Body 只接受明確的指紋欄位白名單,無效 JSON、非 object 或沒有可用指紋的回應會被分類為失敗。原始 Response、Cookie、Token、User-Agent、IP 或 Server 回顯的任意欄位,不會直接整包寫入 JSONL。若切換診斷服務,需依該服務的 schema 增加明確的 adapter,不能把「收到 HTTP 200」視為已取得有效指紋。
Retry 僅用於可重送的 GET 與選定的暫時性錯誤,並受次數及時間預算限制。Retry-After 超過剩餘時間時,工作停止並輸出失敗,不會把等待時間截短後提早重送。這個 Client 控制的是自身的併發與重試,沒有提供跨程序的每站速率協調;正式服務仍需依 API 限額設定排程與 rate limit。
輸出檔使用排他建立,避免覆寫既有結果。部分請求失敗時仍保留逐筆結果,程序以非零 exit code 提醒呼叫端;檔案寫入失敗則中止,不會只在記憶體中計數後宣稱整批完成。
實測與驗收範圍
範例使用公開 Diagnostic Endpoint、自有 loopback HTTP/WebSocket 與短期測試憑證,沒有對第三方業務網站測試防護繞過。配套檢查可以在安裝測試依賴後重跑:
python -m pip check
python -m unittest -v test_diagnostic.py
TLS/HTTP/2 的公開診斷結果與 HTTP/3 實際協商已記錄於前面的表格;作者另在文章專案驗證 HTTP Body、Session、Cookie、Redirect、Multipart、Timeout、mTLS、串流與 WebSocket。這些直接讀取正文的校驗工具由文章專案維護;公開範例的獨立測試與驗證範圍見 Python README。
Proxy 的環境設定行為已用自有 HTTP Proxy 驗證;外部 SOCKS/UDP Proxy、DoH resolver、商業 Profile 下載與 Scrapy Download Handler 的端到端整合,沒有在這組環境實測。這次也沒有執行 Wireshark TLS 解密,或取得同版真實瀏覽器的完整封包對照。
結論
curl_cffi 把 TLS 與 HTTP protocol 的 Profile 控制整合進 Python HTTP API,但可重現的結果仍取決於固定版本、Profile 資料、網路條件與 Server 觀察。Session 管理連線與狀態,AsyncSession 管理非同步傳輸;Streaming、WebSocket 與 Retry 則各自需要大小、時間、併發及生命週期限制。
將這些條件記錄清楚,才能判斷一個差異來自程式設定、底層元件或網路路徑。Transport Profile 相符只支持相應範圍的連線判讀;JavaScript、DOM、瀏覽器歷史與應用程式授權,仍需要各自的執行環境與驗證流程。