開発blog-platform

非公開記事を検索結果から消すために noindex の meta タグを書こうとして、手を止めた。調べたら、本番ビルドが非公開記事を最初から焼かない仕組みが既にあって、meta robots より強い「そもそも HTML が存在しない」状態が出来上がっていた。今日は「noindex を入れる作業」だと思って着手したのに、終わってみたら「既存実装が要件を満たすことの確認作業」だった。

きっかけ:unpublished 一覧、検索に出てない?

ローカルの localhost:3000/blog/unpublished に億り人まとめ記事を出していた。これは unpublished: true を立てた記事だけを集める非公開の一覧ページだ。

ここで気になった。このページに並んでいる記事、本番に出たら Google にインデックスされてしまうのか。検索結果に出したくない。URL を直打ちされるアクセスは最悪しょうがないが、できればそれも防ぎたい。

最初に頭に浮かんだのは <meta name="robots" content="noindex"> だった。実際、app/composables/useNoindex.ts にパスパターンで noindex を当てる仕組みが既にある。フォームページや薄いチャートページをここで弾いている。

const NOINDEX_PATTERNS: RegExp[] = [
  /^\/blog\/tax-consultation-form\/?$/,
  /^\/blog\/company_chart_absolute\/?$/,
  // ...
]

/blog/unpublished をこの配列に足せば終わり——そう思ってルーティング構造と既存の漏洩防止ロジックを読み始めた。

調べて手が止まった:noindex より強い壁が既にあった

読み進めて、考えが変わった。mark-unpublished.mjsseo-noindex.tsverify-unpublished-excluded.mjs という一式が既に存在していた。過去の自分が、同じ要望をもっと徹底した形で実装済みだった。

仕組みはこうだ。unpublished: true を立てた記事は、本番ビルドから6経路すべてで除外される。

  • HTML 本体
  • _raw の md ファイル
  • payload(Nuxt のデータ)
  • sitemap
  • redirects
  • _nuxt バンドル

そして /blog/unpublished ページ自体が本番では 404 になる。

noindex の meta タグは「クロールはされるが検索結果には出すな」というお願いでしかない。今回の実装はそれより一段強くて、本番にそもそも HTML が生成されない。検索エンジンが取りに来ても 404 が返る。

ここで「URL 直打ちは最悪しょうがない」と諦めていた点も解決していることに気づいた。HTML が焼かれていないのだから、本番で URL を直打ちされても 404 になる。閲覧できるのは dev(localhost)だけだ。meta robots を当てる出番がなかった。

検証していたら別のビルド事故を踏んだ

「本当に除外されているか」を確かめるため、漏洩検出テストとビルド検証を回した。ここで2つ、別の問題が表に出てきた。

ひとつは、公開記事から非公開記事へのリンクが2本残っていたこと(5/22 作業の積み残し)。漏洩を解説する記事自身が、例示として Markdown のリンク記法を本文に書いてしまい、それ自体が漏洩源になっていた。自己参照漏洩だ。リンク記法をプレーンテキストに直して、検出テスト9件を緑にした。

もうひとつが厄介だった。pnpm generate がプリレンダー完了後(3541ルート生成済み)に exit 1 で落ちた。ビルド本体は成功していて、その後の検証スクリプト verify-blog-payload.mjs が落としていた。

[verify-blog-payload] dist/blog/index.html: 1 article hrefs (1 unique)
  - Only 1 unique article links in /blog page HTML (expected at least 20).

「記事リンクが1本しかない、20本未満だ」という判定だった。だがフィルタは無傷で(article-date.ts のテスト24件は全部通る)、これは検証スクリプトの誤検知だった。

原因は日付依存だった。/blog のデフォルト表示はカレンダーで、SSG のプリレンダー HTML にはカレンダーだけが焼き込まれる。BlogCalendar.vue は当月を初期表示する。今日は6月1日。6月にはまだ記事が2本しかない。なのにスクリプトは「日付プレフィックス付きリンクが20本以上」を厳密カウントで要求していた。月をまたいだ瞬間に当月記事数が20を割って引っかかる。5/31までは5月カレンダーが満載だったから通っていただけだった。

verify-blog-payload.mjs を「件数」から「カバレッジ」に書き換えた

絶対件数の閾値という発想が脆かった。スクリプトを「カレンダー表示月のカバレッジ検証」に直した。

  • BLOG_MIN_ARTICLES という脆いゲートを撤廃
  • アプリと同じ規則で「プリレンダーされるカレンダーの表示月」を算出
  • frontmatter から updatedAt も読み、カレンダーのグルーピング日付に一致させる
  • 検証は3点:(1) content リンクが0本なら全面リグレッション、(2) 当月の最新記事が HTML に無ければ月末比較バグ、(3) 当月公開の記事が全部リンクされているか

カレンダーは1日の記事を v-for で省略せず全部 NuxtLink で描く(+N件 の切り詰めがない)。だから「当月記事は全部 HTML に出る」が day-of-month に依存しない不変条件になる。修正後にスクリプトを単体で回したら EXIT=0 で通った。記録は .claude/issues/2026-06-01-verify-blog-payload-month-start-false-positive.md に残した。

なお _redirects も再生成されていた(4804本)。SSG ビルドの副産物で、今回の noindex 論点とは別系統だ。

学び

noindex の meta タグは「載せないでください」というお願いに過ぎない。本当に出したくないものは、検索エンジンに渡す前に、ビルドの段階で成果物から消すのが確実だった。今回は新しく noindex を書く必要がなく、過去の自分が用意した「6経路除外+本番404」が要件を丸ごと満たしていた。

本番にはまだデプロイしていない。次にデプロイするときは、verify-unpublished-excluded.mjs が dist に対して緑であることと、unpublished 記事が本番で 404 になることを目で確認してから出す。