跳到主要內容
AI 工具與開發

Python curl_cffi 深入解析:從瀏覽器指紋到可維護的網頁資料取得

拆解 curl_cffi、libcurl 與 curl-impersonate 的關係,理解 TLS/HTTP/2 指紋能解決什麼,並以同步、非同步與 API 資料流範例建立合規、可維護的抓取系統。

很多抓取程式的第一版都像這樣:用 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)封裝。它提供兩個層次:

  1. 低階 Curl API:可直接操作 libcurl 的選項與 callback,適合特殊傳輸需求。
  2. 類 requests API: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、不建立合法身分、不取代資料授權,也不保證任何網站都接受請求。

把它放在正確的一層——合規、限速、可觀測的資料取得器——再配合官方資料管道、穩健解析與良好治理,才是比「這次能不能抓到」更有價值的答案。