索引率の指標が「別々のURL窓」を比べていた — 壊れた観測を0%と偽らない指標ガードの設計
今回やったこと
Google Search Console(GSC)の URL 検査 API を定期実行してサイトのインデックス率(索引率)を記録するパイプラインにおいて、API への問い合わせが 100件すべて HTTP 403 で失敗していたにもかかわらず、「正常に観測できた結果として 0%(rate=0.0)」と台帳に4日間連続で記録され続けていた障害を改修した。
さらに、サイトマップの構造変更に伴い計測の分母が 3,408件から 23,342件へと急変した際、別枠で集計していた過去アーカイブ(legacy)の件数を二重計上してしまう指標の歪みも判明した。
「計測不能(rate=None)」と「実測0%(rate=0.0)」を取り違える脆弱性を排除し、全件失敗・エラー率超過・分母急変を検知して保存を拒絶する番人スクリプト(index_measure_guard.py)を導入した一連の設計をまとめる。
なぜ全件403が「正常な0%」に化けたのか
事象の発端は、GSC プロパティの移行作業だった。対象ドメインのプロパティをプレフィックス型からドメインプロパティ(sc-domain:)へ張り替えたが、定期実行ジョブが古いブランチのワークツリーを引き継いで動いていたため、未更新の設定を参照して 100/100 件すべての API 検査が 403 Forbidden(“You do not own this site, or the inspected URL is not part of this property.”)で跳ね返されていた。
本来であれば即座にエラーとして停止すべき事象だったが、データパイプラインの各層が以下のようにサイレントにエラーを丸め込んでいた。
[gsc_fetch.py]
API例外を辞書内の "error" フィールドに格納し、プロセスは exit 0 で終了
↓
[export_index_status.py]
coverageState を .get("coverageState", "") で取得。
エラー行はキーが存在しないため空文字 "" となり、集計結果が {"": 100} に分類される。
空文字を「未登録」とみなし、indexed=0 / indexRate=0.0 を算出
↓
[index_snapshot.py]
算出された 0.0 をそのまま日次台帳(index_snapshots.jsonl)に追記
この結果、「権限エラーで1件も測れなかった」という異常事態が、「実測した結果、サイト全体のインデックス率が0%だった」という正常な数値データとして4日間にわたり保存された。ダッシュボード側も正常な数値として描画したため、異変の察知が大幅に遅れる原因となった。
サイトマップ統合による分母の二重計上
同時期に、もう1つの指標破損が発生していた。過去システムから引き継いだ記事アーカイブ(legacy URL 約19,929件)を sitemap-index.xml 経由で検索エンジンに認識させるため、静的サイト側のサイトマップ定義を統合したことによるものである。
SEOの観点では全URLをインデックスに載せる正しい措置であったが、指標管理上は以下の食い違いを生んだ。
- 従来の分母: Astro で生成された最新記事
3,408件 - 統合後の全URL数:
23,342件(最新 3,417 件 + legacy 19,929 件) - 台帳の集計規則: legacy は別カラム(
legacy_urls)として管理し、メインの索引率計算には合算しない設計だった。
しかし、サイトマップ生成スクリプトがインデックスから全URLを展開したため、分母が 3,408 → 23,342 へと突然 6.8倍に跳ね上がり、legacy URL が二重にカウントされて全体のインデックス率が大幅に薄まる事態が生じた。
異常な観測を拒絶する index_measure_guard の設計
これらの問題に対処するため、集計処理と台帳保存の間に挟み込む防壁として scripts/index_measure_guard.py を新設した。
# 計測結果の健全性を評価し、問題があれば非0で停止する
python3 scripts/index_measure_guard.py --input index_status.json
このガードには、数値計算の前に満たすべき前提条件を明確に組み込んだ。
1. 「測定不能」と「0%」の厳密な分離
API 検査結果において、rate=None(測定失敗)と rate=0.0(実測してゼロ件)を明確に型レベルで区別した。
- 検査が100%失敗した場合
- エラー率が 20% を超えた場合
- 全件が同一の未知エラーメッセージで占められている場合
これらの条件に該当した場合は、台帳への書き込みを即座に拒否して非0でプロセスを終了し、Discord へのアラートを送信する。
2. 散発的な失敗の母数除外
ネットワークの瞬断などにより数件だけ取得に失敗した場合(エラー率 20% 未満)、その失敗行を「未インデックス(不合格)」として扱うのではなく、計算の母数から除外するようにした。
| 検査総数 | 成功(登録済) | 成功(未登録) | API失敗 | 修正前の計算(0扱い) | 修正後の計算(母数除外) |
|---|---|---|---|---|---|
| 100件 | 80件 | 10件 | 10件 | 80 / 100 = 80.0% | 80 / (100 - 10) = 88.8% |
失敗した行をゼロとして数えてしまうと、外部APIの不調によってサイトの索引率が実態よりも低く記録されてしまう。母数から外すことで、正常に観測できたサンプルの比率を維持できる。
3. 分母の急変ガード
サイトマップ統合のような構造変化があった際、無言で数値を更新させないため、前回のスナップショットと比較して分母が ±50% 超 変動した場合は書き込みを停止するリミッターを設けた。恒久的な仕様変更である場合は、人間または管理者が --allow-jump フラグを明示的に指定したときのみ通過を許可する。
さらに、レジストリ設定に sitemap_exclude を追加し、サイトマップ内に含まれていても集計から除外すべき legacy URL を分母および URL 検査の抽出対象から確実に切り離した。
過去台帳の訂正とテストによる検証
実装の改修と並行して、過去4日間にわたり 0.0 として誤記録されていたスナップショット台帳(docs/strategy/gsc_baseline/index_snapshots.jsonl)のデータを直接修正した。誤った数値を残すのではなく、該当日のレコードを null(観測不能)へと置き換え、分母も正規の 3,417 件へと復元した。
また、scripts/test_index_measure_guard.py を整備し、以下の対照群を用いてガードが確実に発火することを単体テストで固定した。
- 入力データが空の場合に停止すること
- 全件 403 エラーの入力に対して非0で終了し台帳が更新されないこと
- 散発失敗が正しく母数から除外され、適正な率が返ること
- 分母の急増・急減時にエラー終了すること(
--allow-jump付与時のみ通過すること)
やってみてわかったこと
数値指標を扱う自動化において、計算式(分子÷分母)だけに意識を向けていると、その前段階にあるデータパイプラインの歪みを見落とす。
- デフォルト値の罠:
.get(key, "")や.get(key, 0)による例外の丸め込みは、コードをクラッシュさせない利点がある反面、異常な状態を正常な値へ化けさせる最大の温床となる。 - 欠損値とゼロ値の区別: 「測定できなかった」ことと「測った結果がゼロだった」ことは、システム運用上まったく異なる事象である。欠損をゼロとして扱う安易な実装は、アラートを麻痺させる。
- 母数の監視: 指標を監視する際は、算出されたパーセンテージだけでなく、その分母となった母数そのものが予期せぬ変動を起こしていないかを同時に監査するガードが不可欠である。
更新履歴
- 2026-09-03: 初稿。GSC 観測パイプラインの改修記録(PR #2906)をもとに整理。
訂正履歴
(なし)