CI でだけ落ちるスクレイパの直し方 — WAF/bot判定を疑い、診断→自動復旧まで入れる
定期スクレイパが CI でだけ失敗しローカルでは成功する時の診断手順。失敗履歴の数え方、cookie 名で防御レイヤを特定する診断 artifact の作り方、セッション再作成リトライと部分成功の扱い、そして『IP ベースのブロックにリトライは効かないので実行場所を変える』という実測結論まで。
約9.1万トークンの節約 (API料金換算で約140円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「CI でだけ落ちるスクレイパの直し方 — WAF/bot判定を疑い、診断→自動復旧まで入れる」は、データ収集カテゴリのAI指示書(MDファイル)です。定期スクレイパが CI でだけ失敗しローカルでは成功する時の診断手順。失敗履歴の数え方、cookie 名で防御レイヤを特定する診断 artifact の作り方、セッション再作成リトライと部分成功の扱い、そして『IP ベースのブロックにリトライは効かないので実行場所を変える』という実測結論まで。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約9.1万トークン(API料金換算で約140円)・96%のトークンを節約できます。
- カテゴリ
- データ収集
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約9.5万トークン
- この巻物使用時
- 約4,200トークン
- 節約量
- 約9.1万トークン (約140円)
- 更新日
- 2026-09-30
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/ci-waf-bot/raw を読み込んで、この指示書どおりに実装して"
中身
CI でだけ落ちるスクレイパの直し方 — WAF/bot判定を疑い、診断→自動復旧まで入れる
定期実行のスクレイパが CI(GitHub Actions 等)でだけ失敗し、手元では同じコードが成功する時の診断手順と、恒久対策のコードパターン。実測で9日間気づかれなかった障害を1セッションで復旧・再発防止まで持っていった型をそのまま書く。
症状のシグネチャ
次が揃ったら、このドキュメントの対象。
- ログイン自体は成功する(post-login のURLが取れる)
- なのに以後どのページへ遷移しても特定の1ページへリダイレクトされ、取得件数が 0 になる
- リトライしても同じページに吸われる
- ローカル実行では同じコードが正常に動く
- 毎回ではなく、日によって成功したり失敗したりする
最初にやること: 「サイトが変わった」と決めつけない
この症状を見ると URL 変更や DOM 変更を疑いたくなるが、WAF/bot対策のチャレンジはログイン後のセッションに後からかかるので見た目がそっくりになる。順序を守る。
手順1: 失敗履歴を数える
gh run list -R <owner>/<repo> --workflow "<ワークフロー名>" -L 20
「いつから」「何回中何回」を出す。ここを飛ばすと誤診する。
- 特定日から継続的に失敗 → 環境要因が確定(コードは変わっていないのに挙動が変わった)
- ランダムに散発 → 同じく環境要因(レート制限・bot判定)
ローカルで1回成功したことを「一過性」の根拠にしてはいけない。 確率的に発生するため、1回の成功は何も証明しない。実際この調査を委譲した調査エージェントは、ローカル1回の成功だけで「一過性。コードに問題なし」と誤結論を出した。履歴の集計が唯一の判定材料。
手順2: 推測で直さず、先に証拠を採る
修正を当てる前に、次回の失敗で真因が一発で分かる診断を仕込む。これが最短経路。
仕込む情報:
| 採取するもの | なぜ要るか |
|---|---|
| ログイン直後の最終URL・title | 期待した画面に着いているか |
| リダイレクト時の HTTP ステータス | 3xx なのか JS 遷移なのか |
レスポンスヘッダ(location / cf-* / x-* / set-cookie の有無) | どの防御レイヤが介入したか |
| cookie 名の一覧(値は出さない) | ここが決め手になる |
| ページの HTML 全文 | チャレンジ画面かどうか |
| フルページスクリーンショット | 人間が1秒で判断できる |
cookie 名が決定打になる。 aws-waf-token があれば AWS WAF、__cf_bm / cf_clearance があれば Cloudflare Bot Management。これで「サイト構造の変更」説は消える。
実装の要点:
- 環境変数(例
SCRAPER_DIAG=1)でのみ有効にし、通常実行を重くしない - ただしリダイレクトを検出した時は、フラグに関わらず必ず保存する(次の失敗で必ず証拠が残る)
- 認証情報は HTML・JSON・スクショのすべてから伏字にする。cookie は名前だけ、値は絶対に出さない
- CI では
if: always()で artifact としてアップロードする
- name: Upload scraper diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: scraper-diag
path: <診断出力ディレクトリ>/
if-no-files-found: warn
retention-days: 14
スクショを撮る前に DOM 内の認証情報を伏字へ置換し、撮り終えたら元に戻す(置換を巻き戻せるようにしておく)。ここを雑にすると artifact に認証情報が残る。
恒久対策は「実行場所を変える」しかない(先に結論)
チャレンジの突破・回避・偽装はしない。 そのうえで、リトライやセッション再作成は恒久対策にならないことを先に書いておく。実測の結果がはっきり出た。
WAF/bot 判定が IP に紐づいている場合、ブラウザコンテキストを破棄して再ログインしても、同じ IP から出る限り毎回同じチャレンジ画面へ吸われる。実測では 39秒・38秒のバックオフを挟んで 3回ログインし直して3回とも全滅した。
| 対策 | IP ベースのブロックに効くか |
|---|---|
| ページ単位のリトライ | ✗ |
| セッション再作成+再ログイン+バックオフ | ✗(実測で確定) |
| User-Agent やフィンガープリントの偽装 | やらない(規約違反・いたちごっこ) |
| 実行場所を変える(セルフホストランナー/社内の常設マシン/許可された固定IP) | ○ |
| 運営に問い合わせて許可を得る | ○(本命。自動化の許諾も同時に取れる) |
だから、CI(クラウドのデータセンターIP)で動かし続ける前提を捨てるのが本筋。手元の PC で毎回成功するなら、そこで動かすのが最も確実で費用もかからない。
ではなぜ下のリトライ実装を入れるのか。一過性のネットワーク断や短期レート制限には実際に効くのと、「何回やっても抜けない」というログが IP 起因だと確定させる証拠になるから。切り分けの道具として入れる。恒久対策と呼ばない。
(1) リトライはページ単位ではなくセッション単位にする
判定を食らったセッションは、同じページを何度叩いても無駄。再試行するならブラウザコンテキストを破棄して再ログインする単位にする。
export async function scrapeWithSessionRecovery({ targets, openSession, scrapeOne, maxRecoveries = 2, sleep, random }) {
const results = {};
let recoveries = 0;
let session = await openSession();
let sessionError;
try {
for (const target of targets) {
for (;;) {
try {
if (!session) throw sessionError ?? new Error("session unavailable");
results[target.key] = { items: await scrapeOne(session, target), ok: true };
break;
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
if ((error instanceof RedirectError || !session) && recoveries < maxRecoveries) {
recoveries += 1;
await session?.close();
session = undefined;
// ばらつかせて一斉再試行を避ける
const delayMs = 30_000 + Math.floor((random ?? Math.random)() * 30_001);
await (sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delayMs);
try {
session = await openSession();
sessionError = undefined;
} catch (e) {
// 再ログイン失敗でも、取得済みの結果は捨てない
sessionError = e instanceof Error ? e : new Error(String(e));
}
continue;
}
results[target.key] = { items: [], ok: false, error: error.message };
break;
}
}
}
} finally {
await session?.close();
}
return results;
}
要点:
- リカバリ回数は全体で上限を持つ(ターゲットごとではない)。判定されたら何度やっても通らないので、無駄に伸ばさない
- バックオフは固定値でなく乱数で散らす
- 再ログインに失敗しても、すでに取れた分は保持する(
finallyで全部捨てる実装にしない)
(2) 部分成功を全損にしない
export function outcome(results) {
const values = Object.values(results);
const ok = values.filter((r) => r.ok).length;
if (values.length > 0 && ok === values.length) return "success";
return ok > 0 ? "partial" : "failed";
}
終了コードの設計:
| 状態 | 終了コード | 理由 |
|---|---|---|
| 全ターゲット成功 | 0 | — |
| 一部だけ失敗 | 0 + 明示的な警告 | 取れた分は反映したい |
| 全ターゲット失敗 | 1 | データが1件も入らない=本当の失敗 |
(3) いちばん大事: 取れなかった分を「消えた」と解釈しない
観測できたレコードだけを upsert する。取得に失敗したターゲットに含まれるはずだったレコードを、DB から削除したりステータスを変えたりしない。
スクレイパ同期では「全件取得して差分を反映」する実装が多いが、その形のまま部分失敗を許すと 取得失敗=全件削除になる。データ欠損は実行失敗よりはるかに危険で、しかも気づきにくい。
// 失敗した/打ち切られたターゲットからの不在は、削除の根拠には決してならない
通知を必ずセットで入れる(ここを省くと全部無駄になる)
この障害は 9日間・定期実行10回中8回 failure だったのに誰も気づかなかった。CI の run 一覧は赤かったが、無人ジョブの一覧を見に行く人はいない。
終了コードは自動復旧の制御用であって、人間への通知経路ではない。
しかも上の (2) で部分失敗を exit 0 に緩めると、検知性は純減する。緩めるなら通知は必須。
通知は重大度で宛先を分ける。全部を全員メンションにすると数日で無視されるようになり、通知が無いのと同じになる。
| ケース | 宛先 | メンション |
|---|---|---|
| 全ターゲット失敗(データが入らない) | チーム用チャンネル + 担当者DM | 担当者 + @here |
| 部分失敗 | チャンネル + 担当者DM | 担当者のみ |
| リトライで復旧して最終的に成功 | 担当者DM のみ | 担当者のみ |
| 平常どおり全成功 | 通知しない | — |
3行目を入れるのがこの表の肝。「毎回ぎりぎり自動復旧している」状態は緑のままなので、これを通知しないと静かに悪化して、いつか復旧しきれなくなった日に初めて気づく。
実装上の注意:
- 通知の失敗で本体の処理を落とさない(送信エラーは握りつぶしてログに残すだけ)
- 通知本文に認証情報・cookie 値・個人のメールアドレスを入れない
- 既存の通知関数を共有している場合、引数を増やす時は省略時に従来どおりの挙動を保つ
チェックリスト
- 失敗履歴を数えた(いつから・何回中何回)。ローカル1回の成功で結論していない
- 診断 artifact を仕込んでから修正に入った。cookie 名で防御レイヤを特定した
- チャレンジの突破・偽装をしていない
- IP ベースのブロックだと分かったら、リトライを増やさず実行場所の変更に切り替えた
- リトライがセッション再作成になっている。バックオフに乱数が入っている(切り分け用であって恒久対策ではないと理解している)
- 再ログイン失敗時も取得済みの結果を捨てない
- 取得失敗したターゲットのレコードを削除・更新していない
- 部分失敗と全失敗で終了コードを分けた
- 3ケース(全失敗/部分失敗/復旧して成功)の通知を入れ、平常時は無音にした
- 通知の失敗が本体を落とさない
- 修正 PR をマージした(作って放置すると直っていないのと同じ)
最後の1行は冗談ではない。この実例では修正 PR が 10日間マージされないまま放置され、その間ずっと障害が続いた。PR を作ることは完了ではない。
よくある質問
+「CI でだけ落ちるスクレイパの直し方 — WAF/bot判定を疑い、診断→自動復旧まで入れる」とは何ですか?
定期スクレイパが CI でだけ失敗しローカルでは成功する時の診断手順。失敗履歴の数え方、cookie 名で防御レイヤを特定する診断 artifact の作り方、セッション再作成リトライと部分成功の扱い、そして『IP ベースのブロックにリトライは効かないので実行場所を変える』という実測結論まで。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約9.5万トークンかかりますが、この巻物を使えば約4,200トークンで済みます。差し引き約9.1万トークン(API料金換算で約140円)・96%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
定期スクレイピング→Googleスプレッドシート自動集計MD
競合価格・在庫・ランキングなどを毎日自動取得してスプレッドシートに蓄積するバッチを AI に作らせる指示書。robots.txt 尊重・レート制限・差分検知のベストプラクティス込み。
Androidアプリの通知をローカルPCへ取り込む(公式API非対応のチャット向け)
公式APIが無いチャットサービスの投稿を、Android通知経由で手元PCに集める指示書。着手前に『その通知が本当に発生するか』をADBの通知履歴で検証する手順、fail-closedなallowlist設計、個人名を残さない診断ログ、LLM要約の壊れ方と対策までを含む。
外部サイトの「異常」を報告する前に計測器を対照群で検定する
自社の掲載が検索に出ない・売切表示になる等を疑ったとき、対照群をツールに実装して測定不能をexit 2で止める手順。UA未設定で空ページを掴む罠、分母が列挙可能かの見方、履歴のない画面で「いつ落ちたか」を測らない原則つき。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア