静的サイトの記事に「前の記事 / 次の記事」や「関連記事」を置くのは、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本に前後リンクが無い状態でした。規約はあったが、それが守られているかを誰も測っていなかったわけです。
欠けると何が起きるか
単に「そのページから次に行けない」だけではありません。前後リンクは鎖なので、切れると影響が両側に出ます。
新しい記事を一覧の末尾に足すとき、直前の記事の「次の記事」を新記事に張り替えます。ところが直前の記事に前後リンクそのものが無いと、張り替える先がありません。結果として、新記事は自分から前へは辿れるのに、前からは辿り着けない片方向のページになります。クロールの経路としては、一覧ページからしか到達できないページが増えていくことになります。
検査をどこに置くか
日次の自動運用では、以前は次の順で走っていました。
- データ更新
- ビルド
- デプロイ
- コミット
- (全サイトのデプロイが終わってから)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
|
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} が無く、回遊導線を検査できません(欠測は合格にしない)`); } }
|
設定の無いサイトは 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:test と node --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" ] } ] }
|
終了コードも確認しておきます。
テストも同じ場所で回せます。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 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 チュートリアル日本語版 です。前後リンクと関連リンクがどのページにもあることは、今日から機械が保証しています。