自分が出している電子書籍の検索順位を、1日1回だけ記録する小さな観測スクリプトを個人で運用しています。requests で検索結果を1ページ取ってきて、順位と件数をCSVに追記するだけのものです。
ある日、「順位が落ちた」というデータが出ました。実際には落ちていませんでした。 落ちて見えた原因は測定側にありました。
原因は一言でいうとこれです。
bot 遮断ページが HTTP 200 で返ってくるため、「取れなかった」が「無かった」として記録されていた。
遮断ページには商品要素が1件も入っていません。パーサから見ると「正常に取得できて、ヒット0件」と区別がつきません。ステータスコードは 200 なので raise_for_status() も通ります。例外も出ず、ログにも異常が出ず、静かにデータだけが壊れます。
この記事は、その検出をどう作り直したか、どう検証したか、そしてリトライと間隔をどう設計したかを、実測値付きで書いたものです。特定サイトの遮断を回避する方法の記事ではありません(この点は最後にもう一度書きます)。
この記事で扱うこと
- 判定文字列を「推測」ではなく「実物を数えて」決める手順
- 「遮断された」と「本当に無かった」をデータ上で別の値にする設計
- リトライ間隔とセッションの実測(20秒では 0/8、45〜60秒+セッション再作成で 5/6)
- 間隔設計 — ただし「間隔を空ければ遮断されない」とは実測から言えなかった話
-
Content-Encodingを信用しすぎて本文の文字化けを「0件」と読む潜在バグ - 追記専用CSVに列を足したくなったときの逃がし方
- 実行時間の上限を見積りで切ると、削られるのは「最後に足した項目」だという話
環境は Python 3 / requests / BeautifulSoup です。
1. なぜステータスコードで弾けないのか
遮断ページの実物は、gzip で 2,857 バイトでした。中身は Akamai Bot Manager のインタースティシャル(JS チャレンジ)で、<title> は だけ。商品要素は 0 件です。
一方、こちらのコードはこう書いてありました。bot 判定はしていたのです。CAPTCHA ページの文字列を3つ見ていました。
# 直す前(=静かにデータを壊していたコード)
def get(self, url, label):
...
resp = self.s.get(url, timeout=r_cfg["timeout_sec"])
text = resp.text
blocked = ("api-services-support@amazon.com" in text
or "Enter the characters you see below" in text
or "ロボットではないことの確認" in text)
if resp.status_code == 200 and not blocked:
return resp.status_code, text # ← 遮断ページはここを通り抜けていた
この3つは CAPTCHA(画像認証)ページの文字列です。JS チャレンジのインタースティシャルには1つも入っていません。 「bot 判定は入れてある」という気持ちだけがあって、実際に返ってきているページには一致していませんでした。
素通りしたページは、そのまま次に渡されます。
items = [it for it in soup.select("div.s-result-item[data-asin]") if it.get("data-asin")]
organic = [it for it in items if not _is_sponsored(it)]
return {
...
# 縮退レスポンス(相手側の間引き)。「掲載されていない」と混同しない。
"degraded": (len(organic) == 0) or (hits is None and not fallback),
}
degraded は「遮断」と「本当に0件」を分けるつもりで置いたフラグでしたが、遮断を検出できていないので、両方がここに落ちていました。 コメントの「相手側の間引き」という理解自体が間違っていて、実体は「こちらが弾かれていた」でした。この読み違えが誤報の入口でした。
items が空になる理由は、少なくとも3つあります。
| 空になる理由 | データとして意味すること | 取るべき対応 |
|---|---|---|
| bot 遮断された | 不明。0件ではない | 待って引き直す/その日の値は使わない |
| 本当に1件も無い | 0件(正しい観測値) | そのまま記録してよい |
| 相手のHTML構造が変わり、セレクタが効かない | 不明。セレクタを直す必要がある | HTMLを開いて調べる |
この3つを1つの値に潰した瞬間、そのCSVは「観測値」ではなくなります。 直すべきはパーサではなく、この3つを分ける仕組みでした。
2. 判定文字列は「推測」ではなく「実物を全数検査」して決める
ここが一番言いたいところです。
ネットで拾った判定文字列を貼って終わりにすると、次は逆向きの誤報が始まります(正常ページを遮断と誤判定して、取れているデータを捨てる)。手元に実物があるなら、数えてから決めるべきです。
手元には、その日に取得して保存してあった 実HTML 71件がありました。全数を目視+機械で仕分けます。
| 件数 | |
|---|---|
| 遮断ページ(Akamai インタースティシャル) | 14件 |
| 正常ページ(商品ページ・検索結果・サジェストJSON) | 57件 |
遮断14件が全件(例外ゼロ)で含んでいた文字列は4つでした。
bm-verify
/_sec/verify?provider=interstitial
akam-logo
triggerInterstitialChallenge
この4つで 71件を判定し直した結果が以下です。
遮断を遮断と判定 TP = 14 (見逃し 0)
正常を正常と判定 TN = 57
誤検出 FP = 0 []
見逃し MISS = 0
そして、直す前のコードが使っていた bot 判定3文字列では、遮断14件のうち検出できたのは 0件でした。 完全に素通りしていた、ということです。ログに何も出なかったのはこのためでした。
ここで測っておくべきなのは「検出できたか」だけではありません。「正常ページを誤って遮断と判定していないか」も同じ回数だけ測る必要があります。 遮断ページ1件だけを見て文字列を決めると、その文字列がたまたま正常ページにも入っていたときに気づけません。遮断N件・正常M件の両方を数える、が最低条件です。
判定コード
判定文字列はスクリプトに埋め込まず、設定ファイルに置きます。理由は後述しますが、遮断の形は相手の都合で変わるので、変わったときに触るのがコードであってはいけないからです。
# 既定値。実運用では config.json の block_markers で差し替え可能にしてある
DEFAULT_BLOCK_MARKERS = {
"akamai_interstitial": [
"bm-verify",
"/_sec/verify?provider=interstitial",
"akam-logo",
"triggerInterstitialChallenge",
],
"captcha": [
"api-services-support@amazon.com",
"Enter the characters you see below",
"ロボットではないことの確認",
],
}
# 「たまたま1語だけ一致した正常ページ」を遮断と誤判定しないための最低ヒット数
DEFAULT_BLOCK_MIN_HITS = {"akamai_interstitial": 2, "captcha": 1}
def detect_block(text: str, cfg) -> str | None:
"""遮断ページなら理由文字列を返す。正常なら None。
★HTTP 200 で返ってくるので、ステータスコードでは弾けない。中身で判定するしかない。"""
if not text:
return None
markers = cfg.get("block_markers") or DEFAULT_BLOCK_MARKERS
min_hits = cfg.get("block_marker_min_hits") or DEFAULT_BLOCK_MIN_HITS
for kind, pats in markers.items():
hit = [p for p in pats if p in text]
if len(hit) >= int(min_hits.get(kind, 1)):
return f"{kind}({','.join(hit[:3])})"
return None
直す前の3文字列は捨てていません。 captcha という別の種類として残してあります。**間違っていたのは「その3つを見ていたこと」ではなく、「その3つだけで足りると思っていたこと」**なので、消す理由がありません。遮断の種類が増えたら、キーを増やすだけで済みます。
min_hits を 2 にしているのは保険です。4つのうち1つでも一致したら遮断、にすると誤検出の危険が上がります。 実測では遮断14件が4つ全部を含んでいたので、2 は十分に緩い閾値です。
戻り値を bool ではなく理由文字列にしているのも意図的で、ログとCSVに akamai_interstitial(bm-verify,/_sec/verify?provider=interstitial,akam-logo) の形でそのまま残ります。後から「何を根拠に遮断と判定したのか」を確かめられます。
3. 「取れなかった」と「無かった」をデータ上で別の値にする
検出できるようになったら、次は記録です。ここを空欄で済ませると、結局あとで読む人が誤読します。
観測結果の値を、こう分けました。
FOUND_BLOCKED = "blocked_bot_check" # 遮断された。掲載の有無は不明。0件ではない
FOUND_BLOCKED_P2 = "blocked_bot_check_p2" # 1ページ目には無く、2ページ目が遮断された
FOUND_EMPTY = "unknown_empty_page" # 遮断ではないのに0件。中身を見に行く価値がある
FOUND_FETCH_FAILED = "unknown_fetch_failed" # 通信そのものが失敗
FOUND_BUDGET = "unknown_budget_exhausted" # 実行の上限に当たって測れなかった
# これに加えて "no"(実際に1ページ目を見て、載っていなかった)と 順位の数値
大事なのは no と blocked_bot_check を絶対に混ぜないことです。
-
no… 実際に見て無かった。これだけが「落ちた」の候補になる -
blocked_bot_check… 遮断。落ちた証拠にはならない -
unknown_empty_page… 遮断でもないのに空。バグかHTML構造変化の疑い。調べる対象
取得側は、呼び出し元が混同できないようにエラーの種類を返します。
def get(self, url, label, allow_block_retry=True):
"""戻り値 (status_code, text, err)。
err は None(成功)か {"kind": blocked/http/network/encoding/budget, "detail": str}。
★呼び出し側は err["kind"] == "blocked" を「掲載なし」と混同してはいけない。"""
そして遮断されたHTMLは、HTML保存をオフにしていても必ず保存します。
reason = detect_block(text, self.cfg)
if reason:
self.stats["blocked"] += 1
self.block_kinds[reason.split("(")[0]] = self.block_kinds.get(reason.split("(")[0], 0) + 1
self._snap(label + "_BLOCKED", text) # ★遮断HTMLは常に残す(後で必ず要る)
これは経験的に効きました。遮断の形が変わったとき、判定文字列を作り直す材料が「保存してある実物」しかないからです。実際、今回の判定文字列も、たまたま保存してあった71件が無ければ推測で決めるしかありませんでした。
保存はgzipのまま、日付フォルダごとに保持期間(既定30日)を過ぎたら実行時に消す形にしています。1日ぶんで68ファイル・約12MB、30日で最大およそ360MBでした。
4. リトライ間隔とセッションの実測
ここは推測が入りやすいところなので、実際に何回復旧したかを数えました。
| リトライのやり方 | 復旧した回数 |
|---|---|
| 20秒待って、同じセッションで引き直す | 0 / 8 |
| 45〜60秒待って、セッションを作り直してから引き直す | 5 / 6 |
20秒はまったく効いていませんでした。「短く何度も引き直す」は、遮断に対しては悪手です(相手に負荷をかけながら1件も復旧しない、という最悪の組み合わせになります)。
そこで、遮断のときだけ通常のリトライ経路から分けました。
def _blocked_backoff(self):
r = self.cfg["request"]
wait = float(r.get("blocked_backoff_sec", 50)) + random.uniform(
0, float(r.get("blocked_backoff_jitter_sec", 10)))
log(f" [遮断] {wait:.0f}秒待ってセッションを作り直します")
time.sleep(wait)
if r.get("blocked_reset_session", True):
self.reset_session() # Cookie を捨てて、ヘッダから作り直す
取得ループ側は、通信エラー用の予算(retry)と遮断用の予算(blocked_retry)を別々に持ちます。
net_budget = int(r.get("retry", 1))
blk_budget = int(r.get("blocked_retry", 2)) if allow_block_retry else 0
net_used = blk_used = 0
while True:
...
reason = detect_block(text, self.cfg)
if reason:
self._snap(label + "_BLOCKED", text)
if blk_used < blk_budget:
blk_used += 1
self._blocked_backoff() # 長い待ち+セッション再作成
continue
self.stats["gave_up_blocked"] += 1
break # 諦める。空欄ではなく blocked として残す
...
self.stats["ok"] += 1
if blk_used:
self.stats["recovered_after_block"] += 1
log(f" [遮断から復旧] {label}")
設定値(実測に合わせたもの):
{
"request": {
"retry": 1,
"retry_backoff_sec": 20,
"blocked_retry": 2,
"blocked_backoff_sec": 50,
"blocked_backoff_jitter_sec": 10,
"blocked_reset_session": true
}
}
セッションを作り直すのが効いているという点は、待ち時間と分けて考える価値があります。同じ Cookie ジャーのまま待っても復旧率は上がりませんでした。
5. 間隔設計 — 「空ければ遮断されない」とは、実測からは言えなかった
ここは正直に書きます。当初は「語と語の間隔を空ければ遮断されない」と考えて設計しました。実測はそれを支持しませんでした。
| 試したこと | 結果 |
|---|---|
| 0.4〜0.8秒間隔で 15リクエスト連続 | 遮断 0件 |
bot 丸出しの UA(python-requests/2.34.2 / curl/8.4.0 / 空UA)で各1回 |
遮断 0件 |
| その直後、10〜16秒間隔の通常実行(50リクエスト) | 遮断 3件(6%) |
間隔を詰めたときは遮断されず、間隔を空けたときに遮断されました。
つまり、少なくともこの相手の遮断条件は単純なリクエスト間隔では説明できません。IP・時間帯・累積アクセス量・セッションの持ち方など、こちらから観測できない要因が入っています。
なのでこの記事は「◯秒空ければ大丈夫」という話をしません。 代わりに、間隔設計はこう位置づけました。
待ち時間は「遮断されないための呪文」ではなく、「相手のサーバに負荷をかけないための礼儀」として設計する。
遮断されるかどうかは相手が決めることで、こちらが制御できるのは自分がどれだけ叩くかだけ。
その前提で置いた値が「語間 10〜16秒のランダム待ち」で、これは1日1回・13語で50リクエストという規模に対するものです。規模が違えば当然変わります。
そして遮断されたら、こちらが引き下がる。blocked_retry: 2 で打ち切って、その語は blocked_bot_check として「測れなかった」と記録して終わりにします。無限に引き直さないことが、間隔以上に重要でした。
さらに、遮断率そのものを毎回データに残すようにしました。
run_at, date, asin, requests_total, ok_count, blocked_count, blocked_rate,
recovered_after_block, gave_up_blocked, http_error_count, network_error_count,
encoding_error_count, keywords_total, keywords_blocked, blocked_words,
block_kinds, accept_encoding_sent, min_delay_sec, max_delay_sec,
blocked_backoff_sec, run_status
実際の1行目(通常実行、50リクエスト):
| 列 | 値 |
|---|---|
requests_total |
50 |
blocked_count |
3 |
blocked_rate |
0.0600 |
recovered_after_block |
2 |
gave_up_blocked |
0 |
keywords_blocked |
0(=遮断で失った語はゼロ) |
block_kinds |
{"akamai_interstitial": 3} |
遮断3回はすべて復旧し、13語すべてを計測できました。 直す前のコードなら、この3回は空欄になり「3件の露出が消えた」と読まれていたはずです。誤報の正体はここでした。
遮断率が閾値(既定 0.2)を超えた実行には、notes 列に日本語で警告を1行残します。その日のデータを後から読む人が、数字だけを見て誤読しないためです。
6. おまけの落とし穴:Content-Encoding を信用しすぎると、文字化けを「0件」と読む
同じ改修中に見つかった、これも「静かにデータを壊す」タイプのバグです。
リクエストヘッダに Accept-Encoding: gzip, deflate, br と書いてありました。ところがこの環境に brotli が入っていません。相手が br で返してきたら resp.text は壊れます。壊れた本文をパースすれば、当然ヒット0件です。例外は出ません。
対処は2つ考えられます。
- (a) brotli を入れる
- (b) 展開できる方式しか名乗らない
(b) を選びました。 依存を増やさずに済み、かつ「brotli を入れ忘れた環境で同じ事故が起きる」という再発経路を塞げるからです。
def _decodable_encodings() -> list[str]:
"""こちらが確実に展開できる圧縮方式だけを名乗る。"""
encs = ["gzip", "deflate"]
for mod in ("brotli", "brotlicffi"):
try:
__import__(mod)
encs.append("br")
break
except ImportError:
pass
try:
__import__("zstandard")
encs.append("zstd")
except ImportError:
pass
return encs
brotli を入れれば自動的に br を名乗るようになります。「入っていない環境で安全に動く」が既定になりました。
さらに、受け取り側にも保険を入れています。検証中に、間に入るプロキシが Content-Encoding を消して X-Content-Encoding-Over-Network という別名のヘッダに書き換える実例に当たったためです。ヘッダ名を1つだけ見る実装では取りこぼします。
def _encoding_error(self, resp, text=None) -> str | None:
names: list[str] = []
# (1) 名前の違う複数のヘッダを全部見る
for h in ("Content-Encoding", "X-Content-Encoding-Over-Network"):
names += [e.strip().lower() for e in (resp.headers.get(h) or "").split(",") if e.strip()]
try:
names += [e.strip().lower()
for e in (resp.raw.headers.get("Content-Encoding") or "").split(",") if e.strip()]
except Exception:
pass
for part in names:
if part in ("identity", "none"):
continue
if part not in DECODABLE_ENCODINGS:
return (f"Content-Encoding={part} を展開できません"
f"(送ったAccept-Encoding={self.s.headers.get('Accept-Encoding')})")
# (2) ヘッダを取りこぼしても拾えるように、本文そのものの健全性を見る
if text:
bad = text.count("�")
if bad >= 3 and bad / len(text) > 0.02:
return (f"本文が文字化けしています(U+FFFD {bad}文字/{len(text)}文字)。"
f"圧縮の展開に失敗した可能性があります")
return None
2段目の U+FFFD(置換文字)率チェックは雑に見えますが、「ヘッダが嘘をつく経路が現実に存在する」以上、本文側にも目を持たせるべきでした。引っかかったら「取得成功・0件」ではなく**取得失敗(encoding)**として残します。壊れたデータを記録するくらいなら、記録しないほうがましです。
7. 記録側で踏んだ落とし穴、2つ
7-1. 追記専用のCSVに列を足すと、それは「ヘッダ行の書き換え」になる
このスクリプトには「既存の行を1バイトも書き換えない」という約束があります(書き込み後に、元ファイルの先頭バイト列のハッシュが一致するかを毎回検証しています)。
遮断率を記録したくなったとき、素直に考えると daily.csv に列を足したくなります。しかし224列のCSVに列を1つ足すと、1行目=ヘッダ行を書き換えることになります。 これは上の約束と正面から衝突します。
やったのは、列ではなく行に逃がすことでした。
- 数値は 新規ファイル
fetch_health.csvに1実行1行で追記 - 人間向けの要約は、既存の列である
notesに日本語1行で入れる
(例:遮断3/41件(7%) 復旧2件 遮断で未計測の語1語[議事録 書き方])
結果、daily.csv は 224列のまま完全に不変、行が1行増えただけになりました。
data/daily.csv
改修前 : 12,993バイト sha256=ad8417b3018cfedd...
現在 : 15,094バイト (+2,101バイト追記)
先頭12,993バイトのsha256 = ad8417b3018cfedd...
→ 既存部分が1バイトも変わっていない : YES
「スキーマを広げる」のではなく「観測の種類ごとにファイルを分ける」。 追記専用のデータを扱うときの原則として、これはかなり効きました。本が1冊増えても列は1つも増えません(行が増えるだけです)。
ついでに、過去の unknown_empty_page を遡って blocked に直すこともしていません。 当時のHTMLが残っていないので、遡って断定できないからです。状況証拠は強いのですが、推測でデータを埋めたら、そのCSVはもう観測値ではなくなります。
7-2. 実行時間の上限を「見積り」で切ると、削られるのは最後に足した項目
観測対象の語を13語から46語に増やしたとき、1回の実行に上限(秒)を置きました。最初は机上で見積もって、
1リクエスト 13秒 × 1語あたり 3.5リクエスト → 上限 2,700秒
としました。実測は違いました。
1リクエスト 16.5秒(実測:35リクエスト / 578秒)
1語あたり 4.0リクエスト(実測:8語で32リクエスト)
待ち時間(10〜16秒)に加えて、取得と解析で3〜4秒かかります。そして「絞り込みが外れたページを引いたら引き直す」ぶんが乗って、1語あたりが 3.5 ではなく 4.0 になっていました。
2,700秒のままだと、46語目まで届きませんでした。そして届かないのは末尾の語 = 一番あとに足した、一番知りたかった語です。
3 + 46語 × 4.0 = 187リクエスト × 16.5秒 = 3,086秒
遮断時の復旧待ち(50〜60秒 × 3件ぶん) = 180秒
→ 3,266秒
上限 = 3,900秒(約2割の余裕)
教訓は単純です。上限は見積りではなく、1回走らせた実測ペースで引き直すこと。 そして、上限に当たって測れなかったぶんは空欄ではなく unknown_budget_exhausted として残すこと(これも「無かった」ではありません)。
8. 運用でいちばん注意すべきこと
これで誤報が無くなる、とは言いません。今回の判定文字列は「2026-09-06 に返ってきた遮断ページ」に対して作ったものでしかありません。
なので、運用側にこれを書き残しました。
blocked_countが急に 0 件になったら、「直った」のではなく「検出できなくなった」を先に疑う。
遮断の形が変わると、また unknown_empty_page に化けて、同じ誤報が始まります。そのときは保存してある実物のHTMLを開いて、判定文字列を更新します。スクリプトは触りません(設定ファイルだけ)。
そして更新するときは、今回と同じように「遮断ページ何件・正常ページ何件」を数えてから決めること。 1件だけ見て決めると、今度は正常ページを遮断と誤判定して、逆向きの誤報が始まります。
もうひとつ。1回の測定で結論を出さないこと。 同じ検索語が10分で 4位 ↔ 2位、20分で件数 13 → 9 と動く実測があります。±2位程度は「変化なし」と読むようにしました。
9. 未解決のまま残していること
正直に書いておきます。
-
遮断ではないのに0件になったケースの真因が分かっていません。 1件、
bm-verifyを含まない**本物のページ(867,914バイト・<title>も正しい)**を受け取ったのに商品を1件も解析できませんでした。2回引き直して2回とも同じでした。セレクタが効かないページ変種の可能性がありますが、特定できていないのでunknown_empty_pageのまま残しています。推測で埋めていません。 - 遮断率6%が平常値かどうか分かりません。 1回の実行の数字でしかありません。数日ぶん貯まるまで基準線にしません。
- 遮断は「起こそうと思って起こせるもの」ではありませんでした。 意図的に起こそうとした試み(15連続リクエスト、bot 丸出しUA)はすべて空振りし、通常運転中に自然に起きました。この検証は再現性が保証できません。
10. この記事の立場について
最後にはっきり書いておきます。この記事は、bot 遮断を回避する方法の記事ではありません。
書いたのは次の2つです。
- 遮断されたことを「無かった」と誤記録しないための判定と記録の設計
- 相手のサーバを叩きすぎないための間隔設計と、遮断されたら引き下がる設計
遮断されたら引き下がるのが正しい、というのがこの記事の立場です。実装上も、リトライ予算を使い切ったら諦めて blocked_bot_check として記録し、次の実行まで待ちます。
そして当然ですが、
- アクセス先の利用規約と
robots.txtに従うこと - 公式APIが提供されているなら、そちらを使うこと(今回は1日1回・50リクエスト規模の個人の観測なのでこの形にしています)
- JS チャレンジを解こうとしないこと(この記事のコードは、遮断ページを検出してデータに「不明」と書くだけで、チャレンジには一切触れていません)
観測スクリプトを書く人の実装が「静かに嘘のデータを作る」ことにならないように、という話でした。
まとめ
- HTTP 200 でも遮断されていることがある。ステータスコードでは弾けない
- 判定文字列は推測で決めない。実物を「遮断N件・正常M件」で数えてから決める(今回は 14/14 検出・0/57 誤検出)
- 「取れなかった」と「無かった」は別の値で記録する。空欄にしない
- 遮断へのリトライは短く何度もが悪手(20秒 → 0/8。45〜60秒+セッション再作成 → 5/6)
- 待ち時間は遮断回避の呪文ではなく、相手への礼儀として設計する(実測では間隔と遮断に単純な相関が無かった)
Content-Encodingは名前を変えられることがある。本文の健全性も見る- 追記専用データに列を足したくなったら、行に逃がす
- 上限は見積りではなく実測ペースで引く。でないと削られるのは最後に足した項目