取得したHTMLに本文が無い — 200 が返っているのに中身が17文字だったサイトの話
ブラウザで見える画面と、サーバが返すHTMLは別物

サイトを巡回してページの状態を記録する道具を作っていて、行が並んだ表を眺めていたときに気づいた。ある行だけ、ほとんどの列が0や空欄になっている。HTTPステータスは200。エラーではない。
ブラウザで同じURLを開くと、当然ながら普通にページが表示される。つまり「取れなかった」のではなく「取れたものの中に本文が無い」。この状態をどう判定して、どう出力に書くかを決めるために、2026-09-13 に日本語・英語の14サイトを同じ条件で取得して数字を並べた。
まず、いちばん極端だった1件
nhk.or.jp のトップページ。
https://www.nhk.or.jp/
HTTP 200
HTMLのバイト数 : 5,856
本文の文字数 : 17 (script/style/noscript を除き、空白も除いた実数)
空白区切りの語数 : 4
同一サイトへのリンク: 0 本
5,856バイトのHTMLに、本文が17文字。同じサイトへのリンクが1本も無い。ページの中身はブラウザ上で JavaScript が組み立てているので、サーバが最初に返すHTMLには骨組みしか入っていない。
これを黙って表に流すと、「本文がほぼ無くリンクも無いページ」という行になる。読んだ人は「このサイトに問題がある」と読むだろう。実際には、測り方がそのページの作りに合っていないだけだ。
14サイトを同じ条件で取得した
条件は揃えてある。1回ずつの取得、リダイレクトは追う、User-Agent は素性を明かす自作のボット名(NeverEmptySeoBot/1.0)、JavaScript は実行しない。「本文の文字数」は body から script・style・noscript・template を除いてタグを落とし、空白を全部取り除いた実数。「語数」は同じ本文を空白で区切って数えたもの。
| サイト | 状態 | HTMLバイト | 本文文字数 | 語数 | 内部リンク | h1 |
|---|---|---|---|---|---|---|
| nhk.or.jp | 200 | 5,856 | 17 | 4 | 0 | 1 |
| jal.co.jp | 403 | 369 | 157 | 15 | 0 | 1 |
| note.com | 200 | 144,083 | 179 | 6 | 13 | 0 |
| zenn.dev | 200 | 237,522 | 645 | 16 | 20 | 0 |
| pixiv.net | 200 | 42,718 | 826 | 96 | 8 | 0 |
| notion.com | 200 | 240,969 | 2,115 | 250 | 71 | 1 |
| vercel.com | 200 | 527,998 | 3,113 | 279 | 75 | 1 |
| city.yokohama.lg.jp | 200 | 60,924 | 3,513 | 509 | 124 | 1 |
| mlit.go.jp | 200 | 63,033 | 4,450 | 308 | 122 | 0 |
| gov.uk | 200 | 85,460 | 4,614 | 798 | 54 | 1 |
| python.org | 200 | 52,610 | 5,435 | 1,001 | 63 | 5 |
| qiita.com | 200 | 309,785 | 6,039 | 311 | 184 | 0 |
| rakuten.co.jp | 200 | 382,598 | 7,954 | 883 | 300 | 0 |
| このサイト | 200 | 82,781 | 11,447 | 699 | 69 | 1 |
ついでに分かったことが1つ。14サイトのうち6サイト(mlit.go.jp・rakuten.co.jp・note.com・zenn.dev・qiita.com・pixiv.net)は、サーバが返したHTMLに h1 が1つも無かった。これも「サイトに h1 が無い」とは限らず、h1 を JavaScript で差し込んでいる場合を含む。同じ数字が2つの意味を持つ、という今回の主題そのままの例になっている。
HTMLのバイト数と本文の量に相関が無いのが、この表のいちばん分かりやすい点だと思う。note.com は144KBのHTMLを返して本文179文字、vercel.com は528KBを返して3,113文字。バイト数で「中身がありそう」と判断するのは無理だ。
「語数が少ない」で判定すると、日本語のページが全滅する
最初に思いつくのは「本文の語数が極端に少なければ空とみなす」だが、これは日本語で壊れる。同じ本文を文字数と語数の両方で測ると、1語あたりの文字数がまったく違う。
- gov.uk … 4,614文字 ÷ 798語 = 1語あたり 5.8文字
- python.org … 5,435文字 ÷ 1,001語 = 5.4文字
- mlit.go.jp … 4,450文字 ÷ 308語 = 14.4文字
- qiita.com … 6,039文字 ÷ 311語 = 19.4文字
- このサイト … 11,447文字 ÷ 699語 = 16.4文字
日本語は単語の間に空白を入れないので、空白区切りの語数は本文量をまったく表さない。英語のページで「50語未満なら空」という閾値を決めると、本文4,450文字ある国土交通省のトップページが308語という数字で評価されることになる。今回はたまたま閾値を超えたが、もっと短い日本語ページなら簡単に下回る。形態素解析を入れずに語数を指標にするのは、日本語のサイトを測る道具としては欠陥だ。
条件を2つ重ねた。それでも2件 すり抜けた
そこで、判定は語数だけに頼らず「語数が50未満」かつ「同一サイトへのリンクが0本」の合わせ技にした。本文が取れていないページは、たいてい内部リンクも取れていない(ナビゲーションも JavaScript で作られるため)ので、この2つが同時に成立したときだけ「サーバが返したHTMLは空だった」と報告する。
この条件で14サイトを判定した結果は、nhk.or.jp と jal.co.jp の2件。そして取りこぼしが2件ある。
- note.com … 本文179文字・語数6 だが、内部リンクが13本あるので判定されない
- zenn.dev … 本文645文字・語数16 だが、内部リンクが20本あるので判定されない
どちらも中身は明らかにブラウザ側で組み立てられている。判定は取りこぼす側に外していることになる。これは意図的にそうしてある。誤って「このページは空だ」と言うほうが、言わずに数字を渡すより害が大きいと判断したからだ。ただし、取りこぼしたことを黙っていていいわけではないので、どの行にも「JavaScript は実行していない」という但し書きを付けて返すようにした。判定に引っかからなかったページでも、本文が少ないなら読む側が理由に思い当たれる。
閾値を上げれば note.com も zenn.dev も捕まる。捕まるが、そのぶん「本文が本当に少ないだけのページ」も巻き込む。この線引きに正解は無いので、線を動かすのではなく、線の位置と根拠(語数と内部リンク数の実数)を出力に一緒に入れることにした。
403 を「空」と一緒に数えない
表の2行目、jal.co.jp は HTTP 403 を返した。369バイト、本文157文字。今回の条件では「空」の側に分類されるが、これは性質がまったく違う。
403 は答えだ。「このクライアントには見せない」と言っている。JavaScript レンダリングのページは「見せているが、この形式では読めない」。この2つを同じ「空」として集計すると、原因も対処も取り違える。前者はUAや経路の問題、後者は読み方の問題だ。
ブラウザで開けば普通に見えるサイトが、素のリクエストには403を返すことは珍しくない。別の日(2026-08-26)にリンク検査の道具を作って有名サイト15件をボットの User-Agent で叩いたときは、15件のうち6件が403を返した(どれもブラウザでは問題なく開く)。403 を「リンク切れ」として報告すると、生きているリンクを消す作業を人にやらせてしまう。ステータスコードが返ってきたなら、それは情報なので、潰さずに最後まで運ぶ。
測った対象が、いつのまにかすり替わっていた
この測定でもう1つ踏んだ。表の pixiv.net の行だ。https://www.pixiv.net/ を要求したのに、リダイレクトを追った結果 https://www.pixiv.net/en/ が返ってきていた。英語版である。日本語版のトップページは測っていない。
この種の事故は数字が合っているので気づけない。表の中では「pixiv.net の本文は826文字」という行として静かに座っている。対策は単純で、最終URL(リダイレクト後)を必ず列として持ち、要求したURLと並べて出す。今回は列に出ていたので、表を作る段階で見つかった。出していなければ、そのまま公開していた。
同じ理由で、出力には「要求したURL」と「実際に読んだURL」を別の列で持っている。foundOn(どのページのリンクから見つけたか)も同様だ。数字だけを渡すと、その数字が何について測られたのかが失われる。
空欄・0・未測定を、出力の上で分ける
ここまでの全部が、最終的には出力の設計の話になった。決めたのは3つ。
- 測らなかったものは「測っていない」と書く。JavaScript 実行後の内容、Core Web Vitals、画像のファイルサイズ、検索順位は、この方法では測れない。だから全部の行に「これは見ていない」という一覧を付ける。空欄が「問題なし」なのか「見ていない」なのかを、読む側に推測させない
- 点数を付けない。0〜100点の総合スコアは、誰かが決めた重み付けであって測定値ではない。同じページに2つの道具が別の点数を出すし、どちらの数字も検算できない。代わりに、根拠になったタグ・ヘッダ・ステータスコードをそのまま添える
- 読めなかったページも行として返す。読めなかったものを黙って落とす巡回は、「何も見つからなかった」巡回と区別がつかない
3つ目は、今回の測定でいちばん効いた。alt 属性が無い画像と alt=""(空の alt)を別の列にしたのも同じ理由だ。空の alt は装飾画像に対する正しい書き方で、欠陥ではない。混ぜて数えると「修正すべき箇所」が実際より多く見える。
この判定を入れて公開している道具
上の判定と出力の決まりをそのまま入れたものを公開している。Technical SEO Site Audit Crawler(Apify)は、1ページ1行で、そのページが検索エンジンに何を宣言しているか(noindex・canonical・X-Robots-Tag・リダイレクト連鎖)と、その根拠になった値を返す。総合スコアは出さない。robots.txt が禁じているURLは取得せず、「禁止されていたので取得しなかった」という行で返す。本文が空だったページには、この記事で書いた但し書きが付く。
関連して、同じ「正常応答なのに中身が足りない」形の壁は GitHub の検索APIが7,705件 一致と言いながら1,000件で打ち切る話 にも書いた。あちらは件数、こちらは本文で、対処の形は同じだった。
筆者: GRAMSHIFT — Android アプリと自動化ツールを個人開発。本文の数値は 2026-09-13 に自作クローラの抽出部分をそのまま呼び出して14サイトを1回ずつ取得した結果で、測定スクリプトの作成と本文の整理には Claude Code を使っている。1回の取得・1つの回線・1つの時刻の値なので、同じサイトでも別の日には違う数字が出る。