很多抓取程式的第一版都像這樣:用 requests.get() 下載 HTML,再用 Beautiful Soup 解析。它能跑,直到某天同一個 URL 在瀏覽器看得到內容,程式卻收到 403、不同版本的頁面,或一個要求重新驗證的回應。
直覺反應常是換一個 User-Agent。但伺服器看見的從來不只有一行 HTTP header。HTTPS 連線建立時的 TLS ClientHello、HTTP/2 的設定與 header 順序,也會透露「這個客戶端像不像它宣稱的瀏覽器」。curl_cffi 正是在這一層派上用場。
它不是萬用的「反封鎖」按鈕,更不是瀏覽器自動化工具。把它當作一個能讓 HTTP 客戶端網路層行為更接近特定瀏覽器的 Python 函式庫,才能選對任務、避開錯誤期待,也能把資料取得工作做得更長久。
本文討論的前提是:只存取你有權取得的公開資料、自己的系統或獲授權的資料來源;遵守服務條款、
robots.txt、授權範圍與速率限制。遇到登入、付費牆、CAPTCHA 或明確的存取控制,應使用官方 API、匯出功能或向服務方申請,而不是嘗試繞過它們。
一句話理解 curl_cffi
curl_cffi 是 Python 對 libcurl/curl-impersonate 的 CFFI(C Foreign Function Interface)封裝。它提供兩個層次:
- 低階
CurlAPI:可直接操作 libcurl 的選項與 callback,適合特殊傳輸需求。 - 類
requestsAPI:get()、post()、Session、cookie、proxy、timeout 等用法接近 Python 開發者熟悉的介面。
第二層通常已足夠。差異在底下:一般 Python HTTP client 會用自己的 TLS 實作與 HTTP 協定堆疊;curl_cffi 則把請求交給經過瀏覽器模擬調校的 native libcurl。當你指定 impersonate="chrome" 時,它不只替你換 Chrome 的 User-Agent,也會套用相應的 TLS、HTTP/2 與常見 header 行為設定。
from curl_cffi import requests
response = requests.get(
"https://example.org/catalog",
impersonate="chrome",
timeout=20,
)
response.raise_for_status()
print(response.text[:200])
這個 API 外觀很像 requests,但傳輸層不是同一件事。也因此,不能把它看成只要全域搜尋取代 requests 就一定更好的套件;對內部 API、穩定 JSON API 或單純下載檔案,原本的 client 往往更簡單。
為什麼只改 User-Agent 不夠?
把一次 HTTPS 請求縮成四個階段,差異就清楚了:
Python 程式
│ URL、cookie、header、body
▼
HTTP client ── TLS ClientHello ──► 伺服器 / CDN / WAF
│ │
│ ALPN 協商(HTTP/1.1、HTTP/2、HTTP/3)│ 指紋與風險評分
│ │
└──── HTTP request / response ◄────┘
TLS:在 HTTP header 之前已經留下特徵
HTTPS 先要協商加密連線。客戶端送出的 ClientHello 包含可用的 TLS 版本、cipher suites、extensions、支援的曲線、ALPN 等資訊;欄位的組合與順序都會因瀏覽器和函式庫而異。
JA3 是把 ClientHello 的部分欄位正規化後算出的指紋,方便服務端把相似客戶端分群。它很有用,但不是全部:extension 順序、GREASE 值等細節不一定都能由單一 JA3 字串表達。近年的系統也可能使用 JA4 或自家的特徵組合。因此「JA3 一樣」只代表一部分網路層行為相近,絕不代表是一台真正的瀏覽器。
HTTP/2:協定設定本身也是特徵
若 ALPN 協商到 HTTP/2,伺服器還能看到 SETTINGS 參數及其順序、初始 flow-control window、pseudo-header 與一般 header 的順序、priority 等行為。這類特徵常被稱為 HTTP/2 或 Akamai fingerprint。即使你把 User-Agent 寫成 Chrome,若這一層仍是另一個 client 的預設值,兩份身分聲明就互相矛盾。
Header、cookie 與連線狀態仍然重要
網路指紋不是唯一訊號。請求是否有合理的 Accept、Accept-Language、referer,cookie 的生命週期是否連續,IP/帳號的請求量是否符合規範,都是伺服器可用的訊號。curl_cffi 可協助 HTTP 端的一致性,卻不會神奇地替你建立合法 session 或授予存取權。
impersonate 背後實際做了什麼?
可以把它想成一組「瀏覽器設定檔」,而不是一個 header 模板。函式庫內含可用的瀏覽器 target;指定 target 時,底層會將相對應的 TLS 與 HTTP 設定交給 libcurl,並配合一組預設 HTTP header。可用 target 會隨套件版本、平台和上游更新而變化,所以實作時應以本機 curl-cffi list 或官方 target 文件為準;不要把文章中的版本號硬編碼進長期任務。
from curl_cffi import requests
with requests.Session(impersonate="chrome") as session:
session.headers.update({
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.8",
})
response = session.get("https://api.example.org/v1/public-records", timeout=20)
response.raise_for_status()
records = response.json()
這裡的 Session 有兩個價值:重用連線,以及讓 server 發出的 cookie 能持續留在同一個工作流程。若每一頁都重新建立 session,不只成本較高,也容易讓本來合理的流程變得破碎。
如果需要研究非瀏覽器 client 的網路相容性,函式庫也支援自訂 ja3、akamai 與額外 fingerprint 選項。不過這是協定除錯與相容性工程,不是猜一串值就能保證成功;每個欄位都必須與目標環境、header 與協定版本一致,否則反而留下更多矛盾。
哪些資料取得任務適合它?
先看資料在哪裡產生,而不是先選套件。以下三類最常見。
1. 穩定的 HTML 清單與分頁
例如自己的內容站、已允許存取的公開目錄,內容直接存在回應 HTML 裡。工作重點是:固定 session、清楚處理狀態碼、限速、解析失敗可追蹤。
from time import sleep
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from curl_cffi import requests
BASE_URL = "https://example.org"
def fetch_all_articles() -> list[dict[str, str]]:
articles: list[dict[str, str]] = []
with requests.Session(impersonate="chrome") as session:
next_url: str | None = urljoin(BASE_URL, "/articles")
while next_url:
response = session.get(next_url, timeout=20)
response.raise_for_status()
document = BeautifulSoup(response.text, "html.parser")
for card in document.select("article.card"):
link = card.select_one("a")
title = card.select_one("h2")
if link and title:
articles.append({
"title": title.get_text(strip=True),
"url": urljoin(BASE_URL, link["href"]),
})
next_link = document.select_one("a[rel='next']")
next_url = urljoin(BASE_URL, next_link["href"]) if next_link else None
sleep(1) # 依站方規範調整;不要把延遲當成可省略的裝飾
return articles
這份程式沒有試圖「跑得最快」。對多數公開站,減少不必要的請求、快取已抓過的頁面、使用明確的排程與監控,比把併發數推高更可靠,也更尊重資料來源。
2. 由網頁呼叫的公開 JSON API
有些網站 HTML 只有骨架,資料由瀏覽器再向 JSON endpoint 取得。瀏覽器的 DevTools Network 面板能協助辨認真正資料來源;若 endpoint 是公開且授權可用,直接取 JSON 通常比解析前端 DOM 穩定。
from curl_cffi import requests
with requests.Session(impersonate="chrome") as session:
response = session.get(
"https://api.example.org/v1/public-records",
params={"page": 1, "per_page": 50},
timeout=20,
)
response.raise_for_status()
payload = response.json()
for item in payload["data"]:
print(item["id"], item["updated_at"])
在正式工作流裡,還應把原始 JSON、取得時間、schema 版本與來源 URL 一起保存。這讓日後資料欄位變動時,能分辨是解析器壞了,還是來源真的改版。
3. 大量獨立 URL:以有限併發取得
當任務是補齊一批已知且允許下載的詳情頁,AsyncSession 可以避免一個 request 等完才送下一個。但非同步不是「無限平行」的同義詞:併發應由站方公告的限制、你的網路與錯誤率決定。
import asyncio
from curl_cffi import AsyncSession
async def fetch_one(session: AsyncSession, url: str, gate: asyncio.Semaphore) -> tuple[str, int]:
async with gate:
response = await session.get(url, timeout=20)
return url, response.status_code
async def main(urls: list[str]) -> list[tuple[str, int]]:
gate = asyncio.Semaphore(4) # 有授權前提下的保守上限,仍須依來源規範調整
async with AsyncSession(impersonate="chrome") as session:
jobs = [fetch_one(session, url, gate) for url in urls]
return await asyncio.gather(*jobs)
# results = asyncio.run(main(urls))
生產程式應再加上:連線/讀取 timeout、對暫時性 5xx 的有限次退避重試、429 時停止或依 Retry-After 等待、checkpoint 與結構化日誌。不要把 403、登入頁或 challenge 頁當成「再重試幾百次」的理由;那是應回頭確認授權與整合方式的訊號。
它解決不了什麼?
JavaScript 執行與瀏覽器 API
curl_cffi 是 HTTP client,不含 DOM、JavaScript runtime、Canvas、WebGL、Service Worker 或使用者互動。若內容必須等 JavaScript 執行、按鈕觸發或 Web API 回傳才出現,單靠它無法產生該頁面的最終畫面。
合理的處理順序是:先找官方 API/下載檔;若你擁有網站或已取得明確授權,再選擇 Playwright 等瀏覽器自動化來驗證真正使用者流程。若資料源不允許自動化存取,正確方案是申請資料管道,不是提高模擬程度。
現代反濫用系統看的是組合,而非單一指紋
TLS/HTTP/2 模擬能降低「header 說是瀏覽器、握手卻明顯不是」的不一致,卻不會消除 IP 信譽、帳戶狀態、行為模式、裝置訊號或站方自訂規則。對方回傳拒絕並不必然是技術問題,也可能是明確的產品或法律界線。
可行的替代方案是正式 API key、資料授權、站方批次匯出、webhook、RSS/sitemap,或請服務方建立專用 endpoint。這些方案的長期維護成本通常遠低於與防護機制競逐。
指紋會過期,而且「像某瀏覽器」仍要一致
瀏覽器更新會改變協定行為,函式庫的 profile 也需要更新。使用 impersonate="chrome" 這類跟隨可用 profile 的名稱,比在程式中鎖死多年以前的版本更適合作為起點;但更新依然要在 staging 驗證,因為 profile、header 和目標服務的相容性可能改變。
同時,別隨意覆寫一大串 header。若你宣稱一個裝置/語系/瀏覽器組合,卻送出矛盾的 client hints、語言偏好與 request context,效果可能更差。只設定業務真的需要且符合實際情境的欄位。
不是所有 Python 生態系整合都無痛
它雖然模仿 requests,但並非 drop-in replacement。現有程式若依賴 requests 的 adapter、hook、認證外掛或特定 exception 行為,遷移前要有整合測試。此外它包含 native 元件;部署目標的 Python 版本、CPU 架構、wheel 可用性與企業環境的套件政策,都應在 CI 和目標平台驗證。
建立一個不靠運氣的抓取架構
比起把所有邏輯塞在一個迴圈,建議將責任分開:
來源規範 / 官方資料管道
│
▼
取得器(Session、timeout、限速、重試)
│
▼
驗證器(status、Content-Type、schema、內容是否為預期頁)
│
▼
解析器(HTML / JSON → 結構化資料)
│
├── 原始回應與版本化快取
└── 日誌、指標、失敗佇列
curl_cffi 只處在「取得器」這一格。它可以提升特定 HTTPS 來源的傳輸相容性,卻不替你處理 schema 變更、資料重複、版權與個資、排程失敗或資料品質。
實作時,至少回答這些問題:
- 來源是否有更正式且穩定的 API、sitemap、RSS 或下載檔?
- 每個 request 的 timeout、最大重試次數與總併發上限是什麼?
- 回應是預期的 JSON/HTML,還是錯誤頁、登入頁或限流頁?
- 如何以 URL、內容雜湊或更新時間去重,並能從中斷處續跑?
- 原始資料、解析版本與取得時間是否足以重現結果?
- 個資、著作權、刪除請求與資料保留期限怎麼處理?
選擇工具的簡表
| 情境 | 優先選擇 | 原因 |
|---|---|---|
| 有官方 API 或匯出 | 官方管道 | 合約與 schema 較穩定,維護成本最低 |
| 靜態 HTML、一般公開資料 | requests / httpx | 簡單、易測試,通常足夠 |
| 合規來源對一般 client 有傳輸相容性問題 | curl_cffi | 可讓 TLS、HTTP/2 與宣稱的瀏覽器行為更一致 |
| 經授權、必須執行 JavaScript 的流程 | Playwright 等瀏覽器自動化 | 有真正 DOM、JS 與互動能力 |
| 需要長期、規模化資料 | 與來源協議 API/資料 feed | 比技術模擬更可靠、可審計 |
結語
curl_cffi 最值得理解的地方,不是 impersonate="chrome" 這個方便的參數,而是它提醒我們:一次網頁請求有 header 看不到的協定層行為。TLS ClientHello、ALPN 與 HTTP/2 設定都可能影響一個服務如何理解你的 client。
對有授權的資料工作而言,它是很實用的工具:保留 Python 的開發效率,同時在需要時取得較接近瀏覽器的網路層相容性。但它的能力邊界同樣明確:不執行 JavaScript、不建立合法身分、不取代資料授權,也不保證任何網站都接受請求。
把它放在正確的一層——合規、限速、可觀測的資料取得器——再配合官方資料管道、穩健解析與良好治理,才是比「這次能不能抓到」更有價值的答案。