孤立ページを構造的に作らない:sitemapに書く前に一覧から辿れるかを検証する

静的サイトにページを自動追加するスクリプトを書くと、ほぼ確実にこの事故が起きる。

新しいページのHTMLは生成された。sitemap.xml にも追記された。でも一覧ページにリンクが入っていない。

スクリプトは終了コード0を返す。ファイルは増えている。デプロイも通る。数週間後にSEO監査をして初めて、「トップから <a href> をたどっても到達できないページ」が何十枚も溜まっていることに気づく。

私が運用しているClaude Codeガイドという静的サイトで、実際にこれをやりかけた。原因は間抜けなもので、一覧が public/index.html ではなく public/tutorials/index.html にあったのに、スクリプトが前者を書き換えていた。書き換え自体は「成功」するので、誰も気づかない。

なぜ「成功したのに壊れている」が起きるのか

原因は、スクリプトの成功条件が「書き込めたこと」になっている点にある。

ページ追加スクリプトがやることを分解すると、こうなる。

  1. 記事HTMLを書く
  2. 一覧ページにカードを挿し込む
  3. 一覧の構造化データ(ItemList)の position を採番し直す
  4. 見出しの「(N件)」を更新する
  5. sitemap.xml にURLを追記する

このうち1と5は単独で完結するので、まず失敗しない。一方2〜4は既存HTMLの中の特定の場所を探して書き換えるので、セレクタや見出しIDが変わった瞬間に静かに空振りする。

そして厄介なことに、1と5だけが成功した状態は、外から見ると最悪の形になる。

  • sitemap には載っているのでクローラは来る
  • しかしサイト内のどこからもリンクされていない
  • リンクが無いページは、クローラから見て重要度が低い

つまり「作った」のに「無いのと同じ」ページが増えていく。人がSEO監査を回すまで、誰も検知しない。

直し方:出力ではなく「到達できること」を検証する

対策はシンプルで、スクリプトの最後に自分が書いたものを読み直して、リンクが実在するかを見る。書いたつもりではなく、書いた結果を検証する。

実際に入れているのは、この十数行だ。

1
2
3
4
5
6
7
8
9
// 挿入したカードが実際に一覧から辿れることを確認する
// (孤立ページを作らないための最終ゲート)
if (!indexHtml.includes(`href="/tutorials/${slug}.html"`)) {
process.stderr.write(
`挿入後の一覧に /tutorials/${slug}.html へのリンクがありません` +
`(孤立ページになるため中止)\n`,
);
process.exit(2);
}

ポイントは2つある。

1つ目は、検証の対象が「操作の戻り値」ではなく「最終的な文字列」であること。 挿入関数が成功を返したかではなく、挿入後のHTMLに実際にそのhrefがあるかを見る。挿入関数のバグも、セクションIDの取り違えも、これなら全部ここで止まる。

2つ目は、この検証が sitemap 追記より前にあること。 順番が逆だと、落ちたときに sitemap にだけURLが残る。まさに防ぎたかった状態そのものになってしまう。

同じ考え方で、セクションIDの妥当性も先に見ている。

1
2
3
4
5
6
7
8
9
const sectionIds = parseSections(indexHtml).map((s) => s.id);
if (!sectionIds.includes(section)) {
// 黙って sitemap だけ更新して孤立ページを作らない
process.stderr.write(
`--section が一覧の節と一致しません: ${JSON.stringify(section)}\n` +
`有効な節: ${sectionIds.join(", ")}\n`,
);
process.exit(2);
}

存在しないセクションを指定したら、どこかに適当に入れるのではなく落とす。 「とりあえず末尾に入れておく」といった親切心が、いちばん検知しづらい壊れ方を生む。

ついでに、ItemList の position は挿入のたびに以降が全部ずれるので、増分更新をやめて毎回カードの並びから作り直している

1
2
3
4
5
6
7
data.numberOfItems = cards.length;
data.itemListElement = cards.map((c, i) => ({
"@type": "ListItem",
position: i + 1,
name: c.title,
url: c.href.startsWith("http") ? c.href : baseUrl + c.href,
}));

見出しの「(N件)」も同じく実カード数から書き直す。増分ではなく再計算にすると、過去にずれた分もそのとき一緒に直るという副産物がある。

実際に試す:既存サイトの孤立ページを数える

ここまでは「これから追加するページ」の話だが、既に溜まっている分も知りたい。トップから <a href> だけを辿って到達できるURLの集合と、sitemap.xml に載っているURLの集合を比べればいい。

依存パッケージなしで動く。Node.js 18以降(グローバルの fetch を使う)で、対象は自分が運用しているサイトに向けること。

orphan-check.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const BASE = process.argv[2];
if (!BASE) {
console.error("usage: node orphan-check.mjs https://example.com");
process.exit(1);
}
const LIMIT = Number(process.argv[3] || 300);
const origin = new URL(BASE).origin;

const norm = (u) => {
const x = new URL(u, origin);
x.hash = "";
return x.href.replace(/\/index\.html$/, "/");
};

// 1) sitemap.xml の <loc> を集める
const sm = await (await fetch(new URL("/sitemap.xml", origin))).text();
const listed = new Set(
[...sm.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => norm(m[1])),
);

// 2) トップから <a href> だけを BFS でたどる(JS は実行しない)
const seen = new Set([norm(origin + "/")]);
const queue = [norm(origin + "/")];
while (queue.length && seen.size < LIMIT) {
const url = queue.shift();
let html;
try {
const res = await fetch(url);
if (!res.ok) continue;
if (!(res.headers.get("content-type") || "").includes("text/html")) continue;
html = await res.text();
} catch {
continue;
}
for (const m of html.matchAll(/<a\b[^>]*\bhref="([^"#][^"]*)"/g)) {
let next;
try {
next = norm(m[1]);
} catch {
continue;
}
if (!next.startsWith(origin)) continue;
if (seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
await new Promise((r) => setTimeout(r, 250)); // 相手に優しく
}

// 3) 差分を出す
const orphans = [...listed].filter((u) => !seen.has(u));
console.log(`sitemap: ${listed.size} / 到達: ${seen.size} / 孤立候補: ${orphans.length}`);
for (const u of orphans.slice(0, 50)) console.log(" " + u);
if (seen.size >= LIMIT) console.log("※ クロール上限に達したため未確定です");

実行した結果(2026-08-01・Node.js v20.17.0 で実測)がこれだ。

1
2
$ node orphan-check.mjs https://claude-guide.autoarticles.net
sitemap: 70 / 到達: 70 / 孤立候補: 0

読むときに注意する点が2つある。

クロール上限で打ち切った分を「孤立」と数えてはいけない。 上の実装では最後に警告を出しているが、LIMIT に達した実行の結果は未確定として扱う。確定させたいなら上限を外す必要があり、そのぶん相手のサーバーに負荷がかかる。日次で回すならサンプリング、週次で全件、という使い分けが現実的だ。

JavaScriptを実行していないのは意図的だ。 クローラが最初に受け取るHTMLに <a href> が無いなら、それはリンクとして数えないほうが実態に近い。一覧をクライアント側のJavaScriptだけで描画しているサイトは、このスクリプトで「全部孤立」と出る。それは検出漏れではなく、そういう状態だということだ。

また、フォーム送信でしか到達できない /search のようなページは、当然この方法では到達できない。実質的に孤立しているという判定は正しいので、sitemapから外すか、どこかからリンクするかを決めることになる。

まとめ

自動生成スクリプトのバグは、「落ちるバグ」より「静かに成功するバグ」のほうが高くつく。落ちれば気づくが、成功したように見えるものは、人が監査するまで積み上がり続ける。

やることは2つだけだ。

  • 書き込みの成否ではなく、書き込んだ結果を読み直して検証する
  • 検証に落ちたら、部分的な成果(sitemapへの追記)を残さずに落とす

sitemap への追記を検証の後ろに置く、という順番だけでも効果がある。順番はコストゼロで変えられる。

この仕組みで運用しているサイトの実物はClaude Codeガイドから見られる。一覧・構造化データ・sitemap が同じスクリプトで一括更新されているので、ページを増やしても導線がずれない。


孤立ページを構造的に作らない:sitemapに書く前に一覧から辿れるかを検証する
https://blog.hashito.biz/2026/08/01/orphan-page-guard-sitemap-index-reachability/
著者
hashito
作成日
2026年8月1日
著作権