去年まで安定して200を返していたスクレイパーが、ある日を境にCloudflare配下で403だけを返し始める——原因の多くはコードではなく impersonate="chrome124" という2年前の指紋を貼り続けていることです。本記事では curl_cffi 0.16.0(2026年8月1日リリース)で追加されたChrome 145/146・Firefox 147のHTTP/3フィンガープリント、curl-cffi update によるプロファイル更新運用、そして0.16のリダイレクト仕様変更 allow_redirects="safe" の差分検証までを、実装コードと法令面の判断とセットで整理します。
impersonate="chrome124"のまま放置したスクレイパーがCloudflareで403に戻る理由
curl_cffi の impersonate は「そのブラウザのTLS ClientHello・HTTP/2 SETTINGSフレーム・ヘッダ順序を再現する」機能であって、「Cloudflareを通す魔法のフラグ」ではありません。ここを取り違えていると、劣化の理由が見えなくなります。
指紋は"正しい"まま陳腐化する
chrome124の指紋は、リリース当時のChrome 124としては完全に正確です。問題は、2026年8月の実トラフィックにChrome 124がほとんど存在しないこと。アンチボット側から見ると、「TLS指紋はChrome 124、User-Agentもそれに整合、しかしそのバージョンは世の中でほぼ絶滅している」という、極めて珍しい組み合わせのクライアントが大量にアクセスしてくることになります。整合していても、レア度そのものがシグナルになります。
Cloudflareのクライアントサイド検出システム「Precursor」は、73,438のゾーンで1日あたり2億600万件の行動評価イベントを測定しています。この規模で母集団分布を持たれると、「古い指紋の集団」は統計的な外れ値として簡単に浮き上がります。DataDomeに至っては85,000以上の顧客固有MLモデルを走らせ、1セッションあたり35以上の行動シグナルを2ミリ秒未満で評価しており、静的な指紋一致だけでは通過条件を満たしません。
典型的な劣化のサイン
- 初回リクエストがいきなり403(JSチャレンジのHTMLすら返らない)→ TLS/HTTPレイヤで弾かれている=指紋が原因である可能性が高い
- 「Checking your browser...」のインタースティシャルHTML(5秒以上のJSチャレンジ)が返る → 指紋は通過、JS実行が必要な段階
- 数百リクエスト目から403 → 指紋ではなくレート・IPレピュテーション側。データセンターIPはJSチャレンジ以前にブロックされることが多く、レジデンシャルプロキシが前提になります
この切り分けを飛ばして impersonate だけを差し替えても直りません。UA偽装レイヤの詳細はCloudflareのUA偽装ブロック対策2026年8月版で整理しているので、あわせて確認してください。
curl_cffi 0.16.0のChrome 145/146・Firefox 147 HTTP/3指紋を選び直す
0.16.0では、Chrome 145 / Chrome 146 / Firefox 147 のHTTP/3フィンガープリントが追加されました。HTTP/3(QUIC)まで含めて再現される点が実運用上は大きく、HTTP/2までしか合わせていないクライアントとは通過率が変わってきます。
ターゲット選定の基準
選び方 | 推奨度 | 理由 |
|---|---|---|
最新の1つ前(chrome145など)を主軸 | ◎ | 実トラフィックの母集団が最も厚い。最新版は普及途上でかえって少数派になることがある |
最新版(chrome146) | ○ | 数週間後には主軸候補。切り替え前提でサブに置く |
Firefox系(firefox147) | ○ | Chrome系だけで弾かれるサイトの代替経路。指紋の傾向が異なる |
2年前の固定値(chrome124) | × | レア度が検出シグナル化。即刻退役させる |
毎回ランダムに切り替え | × | 同一Cookie・同一IPで指紋が揺れると、それ自体が矛盾シグナルになる |
フォールバック梯子として実装する
「1つの指紋に賭ける」のをやめ、セッション単位で固定しつつ、失敗時に系統を切り替える形にします。
import random
import time
from curl_cffi import requests
# セッション単位で固定する。リクエストごとに揺らすと指紋が矛盾する
IMPERSONATE_LADDER = ["chrome145", "chrome146", "firefox147"]
def fetch_with_ladder(url: str, ladder=IMPERSONATE_LADDER, timeout=30):
last_error = None
for target in ladder:
session = requests.Session(impersonate=target)
try:
resp = session.get(url, timeout=timeout)
except Exception as e: # 外部通信は境界なのでここで捕まえる
last_error = e
continue
if resp.status_code == 200:
return target, resp
# 403/503 は指紋以外の要因もあるため、本文でチャレンジ種別を見分ける
if resp.status_code in (403, 503):
body = resp.text[:2000]
if "Checking your browser" in body or "cf-chl" in body:
# 指紋は通過している。ここから先はブラウザ実行の領域
raise RuntimeError(f"JSチャレンジ到達: {target}")
last_error = RuntimeError(f"{resp.status_code} at {target}")
time.sleep(random.uniform(2.0, 5.0))
continue
return target, resp
raise RuntimeError(f"全ターゲットで失敗: {last_error}")
ポイントは、403の中身を読んでから次に進むこと。cf-chl を含むHTMLが返っている時点で指紋レイヤは突破しており、そこで impersonate を変えても意味がありません。その場合はブラウザ実行系(Playwright / Camoufox / SeleniumBase CDP Mode / Nodriver)に処理を渡す設計にします。ブラウザ側への移行判断はSelenium脱却×Playwright移行ガイド26年8月にまとめています。
curl-cffi updateで指紋プロファイルを更新する運用フロー
v0.15.1以降、CLIサブコマンド curl-cffi update でフィンガープリントのプロファイルを更新できるようになりました。これが運用上の意味を持つのは、「ライブラリのバージョンを上げなくても指紋データだけ新しくできる」点です。
更新を仕込む場所
- Dockerイメージのビルド時:
pip install curl_cffiの直後にcurl-cffi updateを実行し、イメージに焼き込む。ランタイムで外部取得が走らないので、本番の挙動が安定する - 週次のCIジョブ:更新後に指紋スモークテストを回し、差分が出たらPRで通知する
- 本番プロセス起動時に毎回実行するのは避ける:外部取得の失敗がそのまま起動失敗になり、しかも「いつ指紋が変わったか」が追跡できなくなる
指紋スモークテストを持つ
「更新したら通るようになった/通らなくなった」を検知できないと、更新は運任せになります。JA3/JA4・HTTP/2 SETTINGS・ALPNを返すエコーサービスに対して、期待するターゲットの指紋が実際に出ているかを確認する薄いテストを用意しておきます。
import json
import subprocess
from curl_cffi import requests
# TLS/HTTP2指紋をJSONで返すエコーエンドポイントを1つ決めて固定で使う
ECHO_URL = "https://tls.peet.ws/api/all"
TARGETS = ["chrome145", "chrome146", "firefox147"]
def snapshot_fingerprints() -> dict:
result = {}
for target in TARGETS:
r = requests.get(ECHO_URL, impersonate=target, timeout=20)
data = r.json()
result[target] = {
"ja4": data.get("tls", {}).get("ja4"),
"akamai": data.get("http2", {}).get("akamai_fingerprint"),
"http_version": data.get("http_version"),
}
return result
def update_and_diff(path="fingerprint_baseline.json"):
with open(path) as f:
before = json.load(f)
subprocess.run(["curl-cffi", "update"], check=True)
after = snapshot_fingerprints()
changed = {k: (before.get(k), v) for k, v in after.items() if before.get(k) != v}
if changed:
print("指紋が変化したターゲット:")
for k, (old, new) in changed.items():
print(f" {k}: {old} -> {new}")
with open(path, "w") as f:
json.dump(after, f, indent=2, ensure_ascii=False)
return changed
ここで http_version を見ているのは、HTTP/3指紋が追加された0.16.0で「意図せずHTTP/2にフォールバックしていないか」を確認するためです。QUICがネットワーク側(プロキシやファイアウォール)でUDP 443を塞がれていると、HTTP/3指紋を指定していても実際にはHTTP/2で出ていきます。指紋を新しくしたのに通過率が上がらないケースの半分近くは、ここが原因です。
0.16のリダイレクト仕様変更 allow_redirects="safe" の差分をどう検証するか
0.16.0で入った実装上いちばん危険な変更が、allow_redirects の扱いです。従来は True / False のbool 2択でしたが、ここに "safe" という第三の値が加わりました。「リダイレクトは追うが、クロスオリジンに飛ぶ際に Authorization や Cookie といった機微ヘッダを引き継がない」という中間的な挙動です。
何が壊れるのか
設定 | リダイレクト追従 | クロスオリジン時の機微ヘッダ | スクレイパーへの影響 |
|---|---|---|---|
| する | 引き継ぐ | 従来どおり。ただし外部ドメインへの認証情報漏洩リスクあり |
| する | 落とす | ログイン後のリダイレクト先で未認証扱いになり、302ループや空ページを踏む |
| しない | — | 自前でLocationを辿る。挙動は完全に自分の管理下 |
やっかいなのは、これが例外ではなく「なんとなく取得できているが中身が違う」形で出ることです。ログインセッションを使うクローラ、認証付きAPI、CDNに302で飛ばされる画像取得などで、静かにデータが欠ける。0.16に上げた直後に「件数は取れているのに内容が薄い」という症状が出たら、まずここを疑ってください。
アップグレード前後の差分ハーネス
推測せず、両バージョンを別venvに入れて同じURL集合を叩き、リダイレクトチェーンと最終ステータスをJSONで突き合わせるのが最短です。
# diff_redirect.py : 0.15系と0.16系の両方の venv で実行して結果を比較する
import json
import sys
from curl_cffi import requests, __version__
URLS = [
"https://example.com/login-required-page",
"https://example.com/asset-redirect",
]
def trace(url: str, mode):
s = requests.Session(impersonate="chrome145")
r = s.get(url, allow_redirects=mode, timeout=30)
return {
"final_url": str(r.url),
"status": r.status_code,
"history": [h.status_code for h in r.history],
"hops": len(r.history),
"length": len(r.content),
# 認証が維持されたかを内容側で判定する(ステータスだけでは分からない)
"authed": "ログアウト" in r.text,
}
if __name__ == "__main__":
out = {"version": __version__, "results": {}}
for url in URLS:
out["results"][url] = {}
for mode in (True, False, "safe"):
try:
out["results"][url][str(mode)] = trace(url, mode)
except TypeError:
# 0.15系では "safe" が未対応。ここで落ちるのが正常
out["results"][url][str(mode)] = {"unsupported": True}
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
比較して hops や authed が食い違ったURLだけが修正対象です。直し方は用途で分けます。
- 認証セッションを維持したい既存クローラ:該当リクエストで
allow_redirects=Trueを明示指定する。デフォルト任せにしない - 外部ドメインに飛ぶ可能性がある取得:
"safe"を明示。認証情報を第三者ドメインに送らないのは、規約・セキュリティ両面で正しい - 挙動を完全に固定したい基幹処理:
Falseにして自前でLocationを辿り、ホップ数上限とドメイン許可リストを自分で持つ
いずれにせよ、0.16に上げるコミットでは全リクエスト箇所に allow_redirects を明示的に書くのが結論です。デフォルト値の変更に将来また振り回されなくなります。
指紋を更新しても越えられない線:規約・法令面のチェックポイント
技術的に通せることと、通してよいことは別です。2026年8月時点で押さえるべき国内の枠組みを整理します。
法令・制度 | スクレイピングでの論点 |
|---|---|
著作権法 第30条の4 | 公開データを情報解析目的で収集すること自体は許容される。ただし創作的コンテンツの再掲載・再配布は侵害になりうる |
不正アクセス禁止法(UCAL) | 認証やアクセス制御の迂回は刑事罰の対象。3年以下の懲役または100万円以下の罰金。指紋偽装でログイン壁を破る行為はここに直結する |
個人情報保護法(APPI) | 個人情報の取得・保存には相応の根拠と管理が必要。ログイン必須情報の取得はリスクが跳ね上がる |
不正競争防止法(UCPA) | 公開データは通常営業秘密に当たらないが、アクセス制限付き・有料データは高リスク |
刑法(業務妨害) | 過度な負荷でサイト機能を妨害すれば業務妨害罪の対象になりうる |
2026年8月20日から動き出す本人情報転送要求制度
個人情報保護委員会は2026年8月13日、本人情報転送要求制度はスクレイピングの全面禁止を意味するものではなく、事前協議を経て段階的にAPI方式へ移行することを重視すると説明しています。公的機関に対する利用者情報のスクレイピングは2026年8月20日から、民間企業は事前協議による方式の取り決めが求められます。第1回・第2回申請で約700件の事前協議が受け付けられており、特に金融機関のアカウントアグリゲーション系は、指紋を更新して延命するのではなく、API移行のロードマップを引くべき領域に入りました。
EU側では2026年施行のEU AI Actにより、AIトレーニングデータの情報源開示、著作権オプトアウトの尊重、ターゲットを絞らない顔画像スクレイピングの禁止が課されます。EUの管轄権はスクレイパーの所在地を問わずEU市民のデータに及ぶため、日本から実行していても対象になり得ます。
実装レベルで守る「過度な負荷をかけない」
import random
import time
from collections import defaultdict
class PoliteThrottle:
"""ホスト単位で最小間隔を守る。business妨害リスクを実装で抑える"""
def __init__(self, min_interval=2.0, jitter=1.0):
self.min_interval = min_interval
self.jitter = jitter
self._last = defaultdict(float)
def wait(self, host: str):
elapsed = time.monotonic() - self._last[host]
sleep_for = self.min_interval + random.uniform(0, self.jitter) - elapsed
if sleep_for > 0:
time.sleep(sleep_for)
self._last[host] = time.monotonic()
def backoff(self, host: str, attempt: int):
# 429/503 は相手が「やめてくれ」と言っている。指数的に引く
time.sleep(min(60, 2 ** attempt) + random.uniform(0, 2))
self._last[host] = time.monotonic()
自前運用の実コスト:指紋更新は「終わらない保守」になる
curl_cffi は無料で強力ですが、無料なのはライブラリだけです。継続運用にかかる負荷を具体的に見ておきます。
指紋以外に積み上がるコスト
- CAPTCHA解決費用(2026年8月時点の相場):reCAPTCHA v2 は1,000回あたり約1ドル、hCaptcha は約1.50ドル、reCAPTCHA v3トークンは約2ドル。ただしCloudflare Turnstileは成功率が約40%と低く、DataDomeはそもそもほとんどのサービスが対応していません。「解けば済む」前提が崩れています
- レジデンシャルプロキシ:データセンターIPはJSチャレンジ以前に落とされるため、実質必須の固定費
- 普遍的バイパスの不在:DataDomeはサイトごとに個別のMLモデルを持つため、あるサイトで通った手法が隣のサイトで通る保証がありません。2024年に有効だった
playwright-stealthは、2026年にはステルスパッチ済みセッションを検出するようモデルが更新され、効果が落ちています - 周辺ライブラリの追従:Selenium 4.47.0(2026年8月10日)でFirefoxのCDPアクセスがブロックされWebDriver BiDiへの移行が進み、Selenium 5.0ではCDP専用APIが削除予定。Scrapy v2.17.0(2026年7月7日)はHTTP/2とSOCKSプロキシに対応。curl_cffi だけ見ていても追いつきません
環境側の変化も効いてくる
Cloudflareは2026年9月15日から、新規ドメインに対して広告表示ページでは「トレーニング」「エージェント」のAIクローラーをデフォルトでブロックし、「検索」は許可する設定を適用します。2026年8月7日にはWeb Integrityチームが継続的な行動信頼に基づくエージェントトラフィックのフレームワークを発表しました。同社CFOのThomas Seifertは、今後5年以内に非人間トラフィックが人間トラフィックの1,000倍に達する可能性を示唆しています。判定の重心が「指紋が本物か」から「振る舞いが信頼に足るか」へ移っていく以上、指紋の鮮度は必要条件ではあっても十分条件ではなくなりつつあると考えるのが妥当です。
まとめ:impersonateは「設定値」ではなく「賞味期限のある依存」
impersonate="chrome124" のまま403に戻るのは、コードのバグではなく指紋の陳腐化です。curl_cffi 0.16.0 でChrome 145/146・Firefox 147のHTTP/3指紋が追加された今、①セッション単位で指紋を固定したフォールバック梯子を組む、②curl-cffi update をイメージビルド時と週次CIに組み込み、JA4とHTTP版数のスナップショット差分を監視する、③0.16へ上げる際は allow_redirects を全箇所で明示し、True / False / "safe" の挙動差を両バージョンの実測で突き合わせる——この3点が実務上の要点です。そして403が返る箇所は、認証壁の内側でないか(不正アクセス禁止法)、負荷が過大でないか(業務妨害)を必ず確認してください。2026年8月20日開始の本人情報転送要求制度のように、技術で越える話ではなくAPI移行で解く話に切り替わっている領域もあります。指紋は依存パッケージと同じく、更新し続ける前提で設計するのが正解です。