記事の回遊導線が欠けるのをデプロイ前に止める検査を書いた

静的サイトの記事に「前の記事 / 次の記事」や「関連記事」を置くのは、SEO でも読者の回遊でも定石です。問題は、書き忘れても何も壊れないことにあります。ビルドは通り、ページは 200 を返し、見た目も崩れません。気づくのは、たまたま人がそのページを開いたときだけです。

フリートで運用している Claude チュートリアル日本語版 で、まさにこれが起きていました。スキルの手順書には「記事末尾に前後リンク・関連セクション・一覧への導線を置く。1つでも欠けたら不可」と明記してあったのに、実測すると次の状態でした。

1
2
3
$ cd /Users/hashito/git/web/claude_web
$ echo "with nav: $(grep -l 'article-nav' public/tutorials/*.html | wc -l) / $(ls public/tutorials/*.html | wc -l)"
with nav: 52 / 60

一覧ページ index.html を除くと、記事7本に前後リンクが無い状態でした。規約はあったが、それが守られているかを誰も測っていなかったわけです。

欠けると何が起きるか

単に「そのページから次に行けない」だけではありません。前後リンクはなので、切れると影響が両側に出ます。

新しい記事を一覧の末尾に足すとき、直前の記事の「次の記事」を新記事に張り替えます。ところが直前の記事に前後リンクそのものが無いと、張り替える先がありません。結果として、新記事は自分から前へは辿れるのに、前からは辿り着けない片方向のページになります。クロールの経路としては、一覧ページからしか到達できないページが増えていくことになります。

検査をどこに置くか

日次の自動運用では、以前は次の順で走っていました。

  1. データ更新
  2. ビルド
  3. デプロイ
  4. コミット
  5. (全サイトのデプロイが終わってから)SEO 監査

この配置だと、監査が見つけるのはすでに本番に出たものです。そこで、ビルドとデプロイのあいだpreflight という反映前ゲートを置いてあります。ブランチの一致、サイトのテスト、期限切れコンテンツ、アフィリエイトタグの抜け、絵文字、SEO の MUST 未達を見て、1件でも落ちたらデプロイしない仕組みです。

今回はここに nav-links という検査を足しました。

実装

検査の中身は単純です。ディレクトリ内の HTML を読んで、必須のクラスが class 属性として存在するかを見るだけです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* ページ内に必須ブロックがあるかを見る(純粋関数)。
* @param {{name:string, html:string}[]} pages
* @param {string[]} required クラス名
* @returns {{page:string, missing:string[]}[]}
*/
function findMissingNavBlocks(pages, required) {
const out = [];
for (const p of pages || []) {
const html = String((p && p.html) || "");
const missing = (required || []).filter(
(cls) => !new RegExp(`class\\s*=\\s*["'][^"']*\\b${escapeRe(cls)}\\b`).test(html),
);
if (missing.length > 0) out.push({ page: p.name, missing });
}
return out;
}

ここで意図的に決めていることが3つあります。

1. 単なる文字列一致にしない。 html.includes('article-nav') で判定すると、フッターや本文に「article-nav」という語が出てくるだけのページが緑になります。class 属性としての一致だけを認めます。複数クラスの中に混ざっている class="foo article-nav bar" は当然通します。

2. 対象を設定で持つ。 全サイトに同じ3ブロックを課すと、そんな規約を持っていないサイトが一斉に赤くなります。「preflight はいつも赤い」状態になった瞬間、本物の違反も無視されるようになるので、これは避けなければいけません。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const NAV_LINK_TARGETS = {
claude_web: {
dir: "public/tutorials",
require: ["article-nav", "related-section", "related-actions"],
exclude: ["index.html"],
},
};

function checkNavLinks(r) {
const conf = NAV_LINK_TARGETS[r.site];
if (!conf) return skip("nav-links", "このサイトには回遊導線の規約設定が無い");
const dir = path.join(r.repo, conf.dir);
if (!fs.existsSync(dir)) {
return fail("nav-links", `${conf.dir} が無く、回遊導線を検査できません(欠測は合格にしない)`);
}
// ...走査して findMissingNavBlocks に渡す
}

設定の無いサイトは skip で、理由が出力に残ります。黙って緑にも赤にもしません。

3. 欠測を合格にしない。 ディレクトリが無い、記事が0本、走査で例外が出た。いずれも pass ではなく fail にしています。「検査できなかった」を「問題なし」と読み替えることが、この種のゲートが機能しなくなる典型的な原因だからです。

検査自体のテストを書く

ゲートが壊れると「検査したつもりで素通り」になるので、通る条件より落ちる条件を厚く書きます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const REQUIRED = ["article-nav", "related-section", "related-actions"];

test("class 属性でない単なる言及では通さない", () => {
const html = `<article>本文</article><p>article-nav related-section related-actions について</p>`;
const got = findMissingNavBlocks([{ name: "mention.html", html }], REQUIRED);
assert.deepEqual(got[0].missing, REQUIRED);
});

test("複数クラスの中に混ざっていても認める", () => {
const html = `<nav class="foo article-nav bar"></nav><div class="related-section related-actions"></div>`;
assert.deepEqual(findMissingNavBlocks([{ name: "multi.html", html }], REQUIRED), []);
});

test("nav-links は ALL_CHECKS に載っている(検査が呼ばれずに緑にならない)", () => {
assert.ok(ALL_CHECKS.includes("nav-links"));
});

最後のテストは地味ですが重要です。検査関数を書いても、それを呼ぶ配列に足し忘れると、検査は一度も実行されないまま全部 pass になります。「検査が存在すること」と「検査が実行されること」は別なので、後者もテストで固定しておきます。

実際に試す

前提は Node.js 18 以上(node:testnode --test が使えるバージョン)です。手元に1ファイル作れば動きます。

1
mkdir -p /tmp/navcheck && cd /tmp/navcheck

check.js:

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
const fs = require("fs");
const path = require("path");

const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

function findMissingNavBlocks(pages, required) {
const out = [];
for (const p of pages || []) {
const html = String((p && p.html) || "");
const missing = (required || []).filter(
(cls) => !new RegExp(`class\\s*=\\s*["'][^"']*\\b${escapeRe(cls)}\\b`).test(html),
);
if (missing.length > 0) out.push({ page: p.name, missing });
}
return out;
}

function scan(dir, required, exclude = []) {
const pages = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".html") && !exclude.includes(f))
.map((f) => ({ name: f, html: fs.readFileSync(path.join(dir, f), "utf8") }));
if (pages.length === 0) throw new Error(`${dir} に記事が1本もありません`);
return { total: pages.length, bad: findMissingNavBlocks(pages, required) };
}

module.exports = { findMissingNavBlocks, scan };

if (require.main === module) {
const dir = process.argv[2];
const required = (process.argv[3] || "article-nav,related-section").split(",");
const r = scan(dir, required, ["index.html"]);
console.log(JSON.stringify(r, null, 1));
process.exit(r.bad.length === 0 ? 0 : 1);
}

動作を確かめるためのサンプルを2枚だけ作ります。

1
2
3
4
5
6
7
8
9
10
11
mkdir -p pages
cat > pages/ok.html <<'EOF'
<article>本文</article>
<nav class="article-nav"><a href="/a.html">前</a></nav>
<div class="related-section"><a href="/list/">一覧</a></div>
EOF
cat > pages/bad.html <<'EOF'
<article>本文</article>
<p>article-nav については別途書きます</p>
EOF
node check.js ./pages

期待される出力は次のとおりです。bad.html は「article-nav」という語を含んでいますが、class 属性ではないので落ちます。

1
2
3
4
5
6
7
8
9
10
11
12
{
"total": 2,
"bad": [
{
"page": "bad.html",
"missing": [
"article-nav",
"related-section"
]
}
]
}

終了コードも確認しておきます。

1
echo "exit=$?"    # exit=1

テストも同じ場所で回せます。check.test.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const test = require("node:test");
const assert = require("node:assert/strict");
const { findMissingNavBlocks } = require("./check.js");

const REQUIRED = ["article-nav", "related-section"];

test("class 属性でない単なる言及では通さない", () => {
const html = `<article>本文</article><p>article-nav related-section について</p>`;
assert.deepEqual(
findMissingNavBlocks([{ name: "mention.html", html }], REQUIRED)[0].missing,
REQUIRED,
);
});

test("複数クラスの中に混ざっていても認める", () => {
const html = `<nav class="foo article-nav bar"></nav><div class="x related-section"></div>`;
assert.deepEqual(findMissingNavBlocks([{ name: "multi.html", html }], REQUIRED), []);
});
1
node --test
1
2
3
# tests 2
# pass 2
# fail 0

欠けていた分を埋める

検査を足す前に、既存の欠落は直す必要があります。前後リンクの順序は手で決めると必ずずれるので、一覧ページの並びから機械的に採りました。この一覧ページには ItemList の JSON-LD が入っていて、カードの並びと position が一致するように生成されているので、そこを正としています。

1
2
3
const items = [...idx.matchAll(
/"position":\s*(\d+),\s*\n\s*"name":\s*"([^"]+)",\s*\n\s*"url":\s*"[^"]*\/tutorials\/([a-z0-9-]+)\.html"/g
)].map(m => ({ pos: +m[1], title: m[2], slug: m[3] })).sort((a, b) => a.pos - b.pos);

表示順の情報が構造化データとして既にあるなら、それを再利用するのがいちばん安全です。人間が読む一覧と、機械が読む前後関係が、定義上ずれません。

補修後の実測です。

1
2
$ node automation/run.js preflight --site claude_web
nav-links pass 62本すべてに article-nav / related-section / related-actions あり

この型が効く場所

「規約はあるが、守られているかを測っていない」ものは、どのプロジェクトにもあります。今回のケースで学びとして残ったのは次の3点です。

  • 書き忘れても壊れないものは、検査を書かないかぎり必ず壊れる。 ビルドが通ることは、規約が守られている証拠にならない
  • 検査は反映の前に置く。 全部デプロイした後の監査は「本番で気づく」仕組みであって、止める仕組みではない
  • 欠測を合格にしない。 検査できなかったときに skip を返すか fail を返すかで、ゲートの意味は逆になる

検査対象のサイトは、この記事で紹介した Claude チュートリアル日本語版 です。前後リンクと関連リンクがどのページにもあることは、今日から機械が保証しています。


記事の回遊導線が欠けるのをデプロイ前に止める検査を書いた
https://blog.hashito.biz/2026/08/02/nav-links-preflight-gate-internal-link-regression/
著者
hashito
作成日
2026年8月2日
著作権