2026年8月14日 金曜日
AI時短ラボ
活用· 約26

出典URLの死活チェックで404だけ見てはいけない──1,517件を全数検査したら、HEADに404を返す公式ドキュメントと403の一次資料が出てきた

出典URLの生存確認をHEADリクエストで機械判定すると、生きている一次資料ほど先に落ちます。当サイトの出典URLを2026年8月14日に全数検査したところ、support.google.com はHEADに404を返しながら同じURLへのGETでは200を返し、出典に使っている同ホストのURL22件すべてが同じ挙動でした。HEADのみで判定した場合とGETで確かめ直した場合を1,529件で比較すると分類が食い違ったのは56件、うち40件は「死んでいる」と判定されるのに実際はGETで200が返るURLでした。403で弾かれた56件の最多は米証券取引委員会(www.sec.gov)の16件で、www.bloomberg.com 11件が続き、www.w3.org・www.iea.org 各2件、www.oecd.org 1件も含まれます。

執筆・編集:
目次

出典URLがまだ生きているかを機械でチェックするとき、HEADリクエストを投げてステータスコードで判定する設計にすると、公式ドキュメントや公的機関の資料が先に「死んでいる」側へ落ちます。当サイトの出典URLを2026年8月14日に全数検査した結果、support.google.com はHEADリクエストに404を返す一方、まったく同じURLへのGETは200を返しました。対処は単純で、HEADが200を返したときだけそれを信じ、200以外が返ったURLは必ずGETで確かめ直すことです。加えて403・401・429は「リンク切れ」ではなく相手側のbot遮断なので、404と同じ扱いにしてはいけません。この記事は一般論ではなく、当サイトが実際に1,517件を叩いて出てきた数字と、その後に自分で再測した数字だけで書いています。

  • support.google.com は curl -I(HEAD)に HTTP/2 404 を返すが、同じURLへのGETは200。2026年8月14日に2URLで再現し、出典に使っている同ホストのURL22件すべてが同じ挙動だった【実測】
  • HEADのみで判定した場合とGETで確かめ直した場合を出典URL1,529件で比較すると、分類が食い違ったのは56件。うち40件は「死」判定なのにGETは200(HEAD側の内訳は404が33件、405が3件、400・500・タイムアウト・接続エラーが各1件)【実測】
  • 「死んでいる」と判定された73件のうち、本当の404は30件だけ。残り43件は302が13件・タイムアウト7件・URLError 6件・RemoteDisconnected 4件・202が4件・400が4件・203が2件・410/503/522が各1件だった【実測・2026-08-14 10:25時点】

症状

HEADには404、GETには200が返る

ターミナルに貼ると、そのまま再現します(2026年8月14日15:03、macOS 15.1.1/Darwin 24.1.0、curl 実行時)。

$ curl -sI https://support.google.com/webmasters/answer/7440203 | head -1
HTTP/2 404

$ curl -s -o /dev/null -w "GET %{http_code}\n" https://support.google.com/webmasters/answer/7440203
GET 200

Pythonの urllib でも同じです。HEADのときだけ例外になります。

urllib.error.HTTPError: HTTP Error 404: Not Found   ← method="HEAD"
(同じURLに method="GET" → status 200、本文 1,474,659 バイト。2026年8月14日15:00時点の実測値で、本文サイズはページ側の更新で変動します)

HEADに405 Method Not Allowedを返すホストもある

404だけを弾いても足りません。HEADそのものを許可していないホストがあります。

urllib.error.HTTPError: HTTP Error 405: Method Not Allowed

これが出たのは https://qwen.ai/blog?id=qwen3.8https://prices.azure.com/api/retail/priceshttps://bps.openai.com/basispoints/api/office/manifest.xml の3件で、いずれもGETは200でした。

302がリダイレクトループとして返ってくる

ai.google.dev の出典URLは、GETでも次の例外になりました。

urllib.error.HTTPError: HTTP Error 302: The HTTP server returned a redirect error that would lead to an infinite loop.
The last 30x error message was:
Found

原因はリンク切れではありません。レスポンスヘッダを見ると、ai.google.dev/oauth2authorize?return_url=...&prompt=none&auto_signin=True&scopes=... への302で、x-robots-tag: noindex が付いた自動サインインの往復でした。curl -L --max-redirs 5 で追っても final=302 redirects=5 で終わります。ページ自体はブラウザで開きます。

203や202も返ってくる

pubmed.ncbi.nlm.nih.gov の2件は203(Non-Authoritative Information)でした。レスポンスヘッダに vary: X-NCBI-CAPTCHA,X-GCP-Client-IP,User-Agent,Cookie,Accept-Encoding があり、content-length は 5,565 バイト。本文を取得すると「Cookies must be enabled / Enable cookies for pubmed.ncbi.nlm.nih.gov and reload this page to continue.」だけの案内ページで、論文ページの中身ではありませんでした(2026年8月14日15:17に本文を取得して確認)。www.meti.go.jp の3件は10:25の検査ではGETで202が返りましたが、15:02の再測ではHEADが200を返しました(再測スクリプトはHEADが200ならGETを投げない実装なので、この3件についてGETは試していません)。時刻によって変わります。

原因

HEADはGETと同じ結果を返すことが前提になっている

RFC 9110(HTTP Semantics)の9.3.2は、HEADについてこう定義しています。

The HEAD method is identical to GET except that the server MUST NOT send content in the response. HEAD is used to obtain metadata about the selected representation without transferring its representation data, often for the sake of testing hypertext links or finding recent modifications.

The server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request method had been GET.

(訳:HEADメソッドは、サーバーがレスポンスに本文を送ってはならない(MUST NOT)という点を除きGETと同一である。HEADは、表現データを転送せずに選択された表現のメタデータを得るために使われ、その用途はしばしばハイパーテキストリンクの検査や最終更新の確認である。/サーバーは、HEADリクエストに対して、リクエストメソッドがGETであった場合に送ったであろうものと同じヘッダフィールドを送るべきである(SHOULD)。)

— RFC 9110, Section 9.3.2

「しばしばハイパーテキストリンクの検査のために使われる」と仕様自身が書いています。リンクチェックにHEADを使うのは、仕様の想定内の使い方です。

では curl -I に404を返すサーバーは規格違反なのか。この節の読み方は割れます。「HEADはGETと同一(identical)である」という定義を素直に取れば、GETが200を返すURLにHEADで404を返すのは定義から外れています。一方、同じ節が明示的にMUSTとしているのは「レスポンスに本文を送ってはならない」の一点だけで、ヘッダフィールドについては「同じものを送るべき(SHOULD)」と一段弱い書き方になっており、ステータスコードの一致を名指しでMUSTと書いた文言はありません。どちらの読みを取るにせよ、実運用では現に食い違いが起きています。規格の解釈を待っている間もリンクチェックは誤答を返し続けるので、こちら側でGETを投げ直すしかありません。

curlの -I はHEADを投げる

curl公式マニュアルの -I, --head の項は次の通りです。

(HTTP FTP FILE) Fetch the headers only. HTTP-servers feature the command HEAD which this uses to get nothing but the header of a document.

(訳:(HTTP FTP FILE)ヘッダのみを取得する。HTTPサーバーにはHEADというコマンドがあり、これを使ってドキュメントのヘッダ以外は何も取得しない。)

— curl man page, -I, --head

curl -I が速いのはヘッダしか取らないからで、リンクチェックのスクリプトでこれが選ばれるのは自然です。ただし取っているのは「HEADに対する応答」であって、「そのURLをブラウザで開いたときの応答」ではありません。

ステータスコードは「ページの有無」と一致しない

Google Search Consoleのヘルプ(ページ インデックス登録レポート)は、ソフト404について次のように書いています。

The page request returns what we think is a soft 404 response. This means that it returns a user-friendly "not found" message but not a 404 HTTP response code. We recommend returning a 404 response code for truly "not found" pages and adding more information on the page to let us know that it is not a soft 404.

— Google Search Console ヘルプ「Page indexing report」

同ページのGoogle公式日本語版では、この箇所はこう表記されています。

ページをリクエストしたところ、ソフト 404 と見なされるレスポンスが返されました。つまり、404 HTTP レスポンス コードではなく、ユーザーフレンドリーな形の「見つかりませんでした」というメッセージが返されました。実際に「見つからなかった」ページで 404 レスポンス コードを返し、これがソフト 404 ではないことを示す詳細情報をページに追加することをおすすめします。

Googleがわざわざこの注意書きを書いているのは、200を返しながら中身は「見つかりません」というページが実在するからです。逆向きの事故(404を返しながら中身は生きている)を明示した記述は、今回参照した範囲の公式ドキュメントには見つけられませんでしたが、ステータスコードとページの実体がずれるという前提そのものは公式が認めていることになります。

なお同ページは404エラーの扱いについてもこう書いています。

In general, we recommend fixing only 404 errors that you link to yourself or list in a sitemap. If a page has been moved, you should return a 3XX redirect to the new page.

(公式日本語版:一般的に、自分自身にリンクしているか、サイトマップに記載している 404 エラーのみを修正することをおすすめします。該当ページが移動されている場合、新しいページへの 3XX リダイレクトを返す必要があります。)

対処

1. HEADは200のときだけ信じる

当サイトの scripts/check_source_links.py は、この事故のあとに次の実装へ変えています。HEADで200が返ったときだけその結果を採用し、200以外(404でも405でも例外でも)はすべてGETで確かめ直します。

def probe(url):
    try:
        req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=20, context=ctx) as r:
            if r.status == 200:
                return 200
    except Exception:
        pass
    try:
        req = urllib.request.Request(url, method="GET", headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
            return r.status
    except urllib.error.HTTPError as e:
        return e.code
    except Exception as e:
        return f"ERR:{type(e).__name__}"

HEADで200が返ったURLについてはGETを投げていないので、この実装でも「HEADは200だがGETは違う」方向の食い違いは検出できません。速度と引き換えに残している穴です。

2. 403・401・429を404と同じ扱いにしない

403はbot遮断であって、リンク切れではありません。10:25の検査で403だった56件のホスト内訳は次の通りでした。

最多は米証券取引委員会(SEC)の16件。ほかにW3C 2件、IEA 2件、OECD 1件が含まれ、56件中21件が公的機関・国際機関・規格団体です。ここを機械的に「死んだ出典」として消すと、その記事から一次資料が消えます。

さらに、HEADで403が返ったURLをGETで叩き直すと200になるケースもあります。15:02の再測では、HEADが403または429で「遮断」に分類されたのにGETでは200が返ったURLが14件(403が12件、429が2件)ありました。403もまた、投げ方次第で変わります。

3. 「死」の判定はステータスコードだけで決めない

15:02の再測(1,529件)で、GETまで確かめた分類とHEADのみの分類は次のように割れました。

分類 GETで確かめた版 HEADのみ版
200(生存) 1,393 1,339
遮断(403/401/429) 72 84
それ以外(死と判定) 64 106

食い違ったのは1,529件中56件です。内訳は、HEADは「死」だがGETは200が40件、HEADは「遮断」だがGETは200が14件、HEADは「死」だがGETは403が2件でした。HEADのみで運用すると、GETなら200が返る40件と、遮断(403)に分類されるべき2件の計42件が、余分に「死」の側へ振り分けられます。

HEADが404でGETが200だった33件のホスト内訳は次の通りです。

ホスト 件数
support.google.com 22
thenextweb.com 5
nftrs.or.jp 1
www.tiktok.com 1
newsroom.tiktok.com 1
www.canva.com 1
openai.com 1
www.kaggle.com 1

当サイトの出典に入っている support.google.com のURLは22件で、22件すべてがHEAD 404/GET 200でした。つまり curl -I でリンクチェックを回した場合、当サイトが引いているGoogle公式ヘルプへの出典は22件全部が「死んでいる」側に落ちます。他サイトで同じ比率になるかは、そのサイトが引いているURLの構成次第です。

4. 404が出ても消さずに但し書きを残す

当サイトは、リンク切れが確定した出典についても、sources欄からURLを削除せず、出典タイトルに「※2026-08-14時点でリンク切れ(404)」と併記する方針を取りました。この日の検査を受けて16本の記事にこの注記が入っています。執筆時に何を参照したかの記録が消えると、後から検証できなくなるためです。

踏んだ記録

2026年8月14日、当サイトの記事本数がその日のうちに大きく増えたので、出典URLをまとめて機械で叩くことにしました(増加の内訳は当時の作業記録と現在のリポジトリの記録が食い違うため、具体的な本数は書きません)。環境はmacOS 15.1.1(Darwin 24.1.0)、Python 3の urllib.request、User-Agentは Chrome 126 相当の文字列、並列12スレッドです。

10:25の実行結果は、出典URL 1,517件のうち200が1,381件、bot遮断・レート制限が63件(403が56件、429が4件、401が3件)、それ以外が73件でした。この73件を「要対処」として出力させたのですが、ステータス別に数え直すと本当の404は30件しかありませんでした。残り43件の内訳は次の通りです。

ステータス 件数 主な中身
302 13 ai.google.dev 11件、www.statista.com 2件(うちai.google.dev の1件はヘッダまで確認し、自動サインインの往復と判明)
ERR:TimeoutError 7 7件すべてMcKinsey(www.mckinsey.com 5件、mckinsey.com 1件、www.mckinsey.com.br 1件)
ERR:URLError 6 www.edtechinnovationhub.com 2件、blog.khanacademy.org、spectrum.ieee.org、www.speakaholic.com、macnica.co.jp 各1件
ERR:RemoteDisconnected 4 4件すべて pasqualepillitteri.it
202 4 www.meti.go.jp 3件、typeset.io 1件
400 4 developer.meta.com 2件、ai.meta.com 1件、www.llama.com 1件
203 2 pubmed.ncbi.nlm.nih.gov(Cookie有効化を求める案内ページ)
410 1 medium.com
503 1 web.archive.org
522 1 diyai.io

503を返したのが web.archive.org 自身だったのは、この日いちばん脱力した結果でした。リンク切れの退避先として使う相手が、生存確認の最中に落ちています。

最初にこのスクリプトを書いたときはHEADだけで判定していました。その版で「死んでいる」と出た中に support.google.com が並んでいて、ブラウザで開いたら普通に表示されたので気づきました。気づかずに出典を消していたら、Google公式ドキュメントへのリンクを、生きているのに自分の手で削除するところでした。

支払った代償は、スクリプトの書き直しと再検査、そしてこの記事を書くための再測1回です(再測は1,529件を16並列で1分32秒。10:25の実行そのものの所要時間は記録していません)。得られたのは「ステータスコードで自動判定する設計そのものが誤り」という結論で、これは403にも404にも同じように当てはまります。

正直な但し書き

HEADのみ版の誤判定件数は、当時の数字を確定できませんでした。 scripts/check_source_links.pyprobe() のdocstringには「生きているリンクを63件も死んだと誤判定した」と書かれていますが、同じ変更を入れたコミット ef7d1b8 のメッセージには「生きている公式ドキュメント32件を死んだと誤判定していた」と書かれており、数字が食い違っています。この記事の執筆にあたって15:00〜15:02に測り直したところ、HEAD 404かつGET 200が33件、HEADで「死」判定になるがGETは200が40件でした。docstringの「63」は同じJSONに記録された遮断(403/401/429)の件数63と一致するため、取り違えの可能性がありますが、断定はできません。10:25時点の出典URLは1,517件、15:02時点は1,529件と集合が変わっているので当時の完全な再現は不可能です。したがってこの記事では「63」という数字は採用せず、自分で測り直した33件・40件のほうを使っています。

HEADが200を返したURLについてはGETを投げていません。 現在の実装が速度のために省いているので、「HEADは200だがGETは404」という逆向きの食い違いが何件あるかは測っていません。この記事の56件という食い違い件数は、その分だけ下限です。

同じホストでも時刻によって結果が変わります。 www.meti.go.jp は10:25にはGETで202、15:02にはHEADで200でした(15:02の再測はHEADが200ならGETを投げないため、この3件についてGETは試していません)。403の件数も10:25が56件、15:02が65件と一致していません。URL集合が12件増えていることに加えて、相手側の遮断判定が時刻や試行回数で動くためと考えられますが、原因を分離して確かめてはいません。

202の中身は確認できていません。 pubmed の203は本文まで取得して「Cookies must be enabled」の案内ページであることを確認しましたが、www.meti.go.jp の202についてはヘッダも本文も保存していないため、10:25に返っていた実体を確認できていません。

この検査はすべて日本国内の1つの回線・1つのIPからの実行です。 403の出方はIPやリージョン、User-Agentで変わります。同じスクリプトを別環境で回して同じ内訳になるかは確かめていません。

RFC 9110の解釈について。 9.3.2はヘッダフィールドについて「同じものを送るべき(SHOULD)」と書いていますが、ステータスコードがGETと一致することを名指しで要求する文言は同節にありません。ただし冒頭の「identical to GET」を厳格に読めば違反と解する余地もあります。本文で両論を併記したのはそのためで、どちらが正しいかについてIETFの公式見解を確認したわけではありません。

出典

シェア: ポスト はてブ

出典・参照資料

AIニュースの解説を動画でも

YouTubeでは注目ニュースの背景を解説し、Xでは新着記事をお知らせしています。

コメント

まだコメントはありません。最初のコメントを書いてみませんか?

AIについて聞きたいことはありますか?

質問箱で無料で受け付けています。回答は公開され、他の方の参考にもなります。

質問箱を見る →

新しい記事をメールで受け取る

AIの新しい発表を、出典付きで整理して届けます。

関連記事

AI時短ラボ
活用08.14読了31

URL Inspection APIのレスポンスを機械判定すると壊れる3点──coverageStateは表示文字列、ROBOTS_TXT_STATE_UNSPECIFIEDはrobots.txtの問題ではない

出典 ─ Method: index.inspect
AI時短ラボ
活用08.14読了35

www化の308を入れてもSearch Consoleの旧プロパティは集計を続ける──URLプレフィックス型で5週間データを取り逃した記録

出典 ─ Add a website or platf
AI時短ラボ
活用08.14読了36

Vercelの自動生成ドメインがGSCの参照元に117本入っていた──*.vercel.appと「Duplicate, Google chose different canonical than user」を全351記事で実測

出典 ─ Accessing Deployments
AI時短ラボ
活用08.14読了21

curlに-Lを付けずに308を叩くと15バイトの「Redirecting...」が返る──sitemapが0件・サイトが壊れたと誤診する罠

出典 ─ 308 Permanent Redirect(HTTP response status codes)
Vercelデプロイのエラーを減らす個人開発チェックリスト──ローカルビルドから本番200確認までの記事画像
活用07.16読了7

Vercelデプロイのエラーを減らす個人開発チェックリスト──ローカルビルドから本番200確認まで

出典 ─ Vercel Docs: Troublesh
「AIっぽい見た目」を卒業する──デザイントークンをAIに渡す方法の記事画像
活用06.01読了11

「AIっぽい見た目」を卒業する──デザイントークンをAIに渡す方法

出典 ─ Design Tokens Communit
Google、Gemini 3.7 Flashを発表──3.6から3週間・導入価格は半額の0.75ドル、一方で総合指標と知識労働は他社の下の記事画像
モデル08.14読了21

Google、Gemini 3.7 Flashを発表──3.6から3週間・導入価格は半額の0.75ドル、一方で総合指標と知識労働は他社の下

出典 ─ Google公式ブログ: Introduci
Google、Gemini 3.6 Flashなど3モデルを同時発表──出力トークン17%減・長文1Mで54.0%、一方でコーディング4種目は1位ゼロの記事画像
モデル07.22読了13

Google、Gemini 3.6 Flashなど3モデルを同時発表──出力トークン17%減・長文1Mで54.0%、一方でコーディング4種目は1位ゼロ

出典 ─ Google公式ブログ: Introduci