AIに記事を書かせると被リンク0の孤児が量産される|404を公開する寸前だった

AIエージェントに記事を書かせると、本文の質より先に壊れるものがあります。記事どうしのつながりです。
1本ずつ見れば、どの記事もそれらしい内部リンクを持っています。ところがサイト全体で見ると、どこからもリンクされていない記事が溜まり、frontmatter に書いた宣言と本文のリンクが少しずつずれていき、最後には存在しないURLへのリンクを公開する寸前まで行きました。
🧭 この記事の前提
AIエージェントに記事を量産させている個人サイト(現在74本)で、内部リンクの管理を人が目視で追っていました。
結果、被リンク0の孤児記事と、404へのリンクが同時に発生しました。人の目では検出できませんでした。
持ち帰れるもの:AIに記事を量産させている人向けに、リンク整合を機械で閉じる検査スクリプトの実物を出します。手書きで月に数本のペースなら、この記事は不要です。
結論:内部リンクは書かせるものではなく、検査するもの
📌 先に結論 ① AIは「もっともらしいスラッグ」を書く。実在確認はしない ② 宣言(frontmatter)と本文は必ずずれるので、双方向に突き合わせる ③ 自動修正は宣言の再生成だけにする。本文を機械に書き換えさせない
何が起きたか:zaitaku-print という記事は存在しなかった
エージェントが書いた記事の本文に、こういうリンクが入っていました(この記事自身が検査で落ちないよう、先頭のスラッシュだけ外して引用します)。
プリンタが必要かどうかは[在宅ワークにプリンタは必要か](posts/zaitaku-print)にまとめています。
リンク先の記事は確かに存在します。ただし実際のスラッグは zaitaku-printer-hitsuyou でした。zaitaku-print という記事は無く、このリンクは404です。
厄介なのは、読んで気づけないことです。表示文字は正しく、スラッグも記事の内容と合っています。ファイル名を1つずつ照合しない限り、これは通ります。そして通りました。公開直前の検査で初めて止まりました。
これは「AIが嘘をついた」というより、AIにはリンク先の実在を確認する手段が無いという構造の問題です。もっともらしい文字列を書くのが仕事なので、もっともらしい間違いを書きます。同じ性質の失敗はAIの成果物は目視で検証しないにも書きました。
なぜ人の目では追えないのか:74本 × 73通り
記事74本のサイトで、内部リンクの向きを含めた組み合わせは 74 × 73 = 5,402通りあります。実際に張られている internal_out の宣言は 405件。この405件それぞれについて、
- リンク先のスラッグが実在するか
- 本文にも同じリンクがあるか
- 逆に本文にあるのに宣言に無いものはないか
を見る必要があります。さらにキーワードの重複(カニバリ)まで見るなら、230語の総当たりです。
1本追加するたびに、確認対象は1本ぶんではなく全体ぶん増えます。 記事を書いた直後に「この記事のリンクは正しい」と確認しても、その記事が他の記事の孤児判定を変えるので、確認は毎回サイト全体でやり直しになります。人がやる作業ではありません。
⚠ 「1本ずつ丁寧に確認する」は量産では成立しない 内部リンクの整合は1記事の性質ではなく、サイト全体の性質です。1本ずつ見る限り、孤児記事は永久に見つかりません。孤児かどうかは、その記事を見ても分からないからです。
まず作った検査:本文と宣言の両方でスラッグの実在を見る
最初に作ったのが check-internal-links.js です。冒頭のコメントに、なぜ作ったかをそのまま書いてあります。
/**
* 記事どうしの内部リンクが実在するスラッグを指しているかを検査する。
*
* node scripts/check-internal-links.js
*
* check-links.js はアフィリエイトリンク(外部)を見る。内部リンクは見ていなかった。
* 実際に `/posts/zaitaku-print` と書いたが正しくは `zaitaku-printer-hitsuyou` で、
* 404 へのリンクを公開しかけた。ファイル名とスラッグが一致しない書き方をした時に起きる。
*
* 本文中の `/posts/<slug>` と、frontmatter の `internal_out` の両方を見る。
*/
なお、このコメントを記事に引用しただけで、この記事自身が check-internal-links.js に404として検出されます。 応急処置版はスラッシュ付きの /posts/... を無条件に拾うので、コードブロックの中も引用も区別しないからです。後述の本検査 check-links.js がリンク記法(](/posts/...))に限定して拾っているのは、この誤検知を避けるためです。検査の精度は、正規表現をどこまで絞るかで決まります。
中身は驚くほど短いです。実在するスラッグの集合を作り、書かれているスラッグがそこに入っているかを見るだけです。
const DIR = path.resolve(__dirname, '../site/content/posts');
const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.mdx'));
const slugs = new Set(files.map((f) => f.replace(/\.mdx$/, '')));
const problems = [];
for (const f of files) {
const from = f.replace(/\.mdx$/, '');
const src = fs.readFileSync(path.join(DIR, f), 'utf8');
for (const m of src.matchAll(/\/posts\/([a-z0-9-]+)/g)) {
if (!slugs.has(m[1])) problems.push({ from, to: m[1], where: '本文' });
}
const fm = /internal_out:\s*\[([^\]]*)\]/.exec(src);
if (fm) {
for (const q of fm[1].matchAll(/"([^"]+)"/g)) {
if (!slugs.has(q[1])) problems.push({ from, to: q[1], where: 'internal_out' });
}
}
}
ファイル名をスラッグの正とするのが要点です。ファイル名と frontmatter の slug がずれていると、この前提が崩れます。だから後述の本検査では、その一致自体もエラー扱いにしています。
本検査:16項目を1本のスクリプトで閉じる
check-internal-links.js は応急処置でした。本命は check-links.js で、こちらは公開ブロックを担当します。冒頭の方針です。
/**
* 内部リンク・記事整合の一括検査。
*
* node scripts/check-links.js [--fix]
*
* 量産(1日10本)では手作業のリンク管理が必ず破綻するため、機械で閉じる。
* 終了コード 1 = エラーあり(公開ブロック)。警告のみなら 0。
*
* --fix は「本文のリンクから internal_out を再生成する」安全な修正のみ行う。
* 本文には手を入れない(意図が読めないものを機械が書き換えないため)。
*/
実際に見ている項目は16あります。エラー(公開ブロック)と警告(公開はできる)を分けているのが運用上のポイントです。
| # | 検査項目 | 判定 |
|---|---|---|
| 1 | frontmatter が読めない | ❌ エラー |
| 2 | 必須6フィールド(title/description/slug/type/category/date)の欠落 | ❌ エラー |
| 3 | frontmatter の slug とファイル名の不一致 | ❌ エラー |
| 4 | description の長さ(130字超/60字未満) | ⚠ 警告 |
| 5 | image / thumbnail の未設定 | ⚠ 警告 |
| 6 | image / thumbnail のファイルが実在しない | ❌ エラー |
| 7 | 自分自身へのリンク(自己リンク) | ❌ エラー |
| 8 | リンク切れ(存在しないスラッグ) | ❌ エラー |
| 9 | internal_out に存在しないスラッグ | ❌ エラー |
| 10 | internal_out にあるが本文にリンクが無い | ⚠ 警告 |
| 11 | 本文にあるが internal_out に無い | ⚠ 警告 |
| 12 | 発リンクが1つも無い(回遊が成立しない) | ❌ エラー |
| 13 | 未設定のアフィリトークン(行き止まり #) | ❌ エラー |
| 14 | 本文に h1(#) がある(h2から始めるべき) | ❌ エラー |
| 15 | 被リンク0(孤児記事) | ⚠ 警告 |
| 16 | keywords の重複(カニバリ) | ❌ エラー |
このうち 7・8・15・16 の4つは、1記事だけ見ても絶対に判定できません。 サイト全体を読み込んで初めて出る値です。
双方向の突合:宣言と本文は必ずずれる
internal_out は「この記事はここへ送客する」という宣言です。AIはこれを本文とは別に書きます。だから片方だけ直したり、片方だけ足したりが起きます。記事20本の時点で、宣言と本文のずれが18件ありました。ほぼ全記事です。
そこで両方向から突き合わせています。
// internal_out(宣言)と本文の突き合わせ
const declared = new Set(fm.internal_out || []);
for (const d of declared) {
if (!bySlug.has(d)) err(slug, `internal_out に存在しない slug: ${d}`);
else if (!bodySet.has(d)) warn(slug, `internal_out に "${d}" があるが本文にリンクが無い`);
}
for (const b of bodySet) {
if (bySlug.has(b) && !declared.has(b)) warn(slug, `本文に "${b}" へのリンクがあるが internal_out に無い`);
}
// 発リンクゼロ(回遊が死ぬ)
if (bodySet.size === 0) err(slug, '本文に内部リンクが1つも無い(回遊が成立しない)');
片方向だけだと半分しか見つかりません。 「宣言にあるのに本文に無い」は宣言の書き過ぎ、「本文にあるのに宣言に無い」は宣言の書き漏れで、原因が違うので両方出す必要があります。
孤児記事:被リンク0は、その記事を見ても分からない
いちばん静かに増えるのがこれです。エージェントが書いた6本が、どこからもリンクされていませんでした。
検出そのものは単純で、全記事を走査するときに「誰から誰へ」を集めておき、最後に受け側が空のものを出すだけです。
// 孤児記事(被リンク0)= Google からも読者からも到達しにくい
for (const p of posts) {
if (inbound.get(p.slug).size === 0) {
warn(p.slug, '被リンク0(孤児記事)。関連記事からリンクを張る');
}
}
inbound は各記事の走査中に inbound.get(target).add(slug) で埋めています。孤児かどうかは、他の73本を全部読み終わるまで確定しません。 ここが目視で無理な理由の核心です。書いた本人は「関連記事へのリンクを張った」と思っていて、実際に張っています。足りないのは出ていくリンクではなく、入ってくるリンクでした。
孤児のまま置くと、読者もクローラも到達しにくくなります。実際、この6本は収益導線を張ろうとしていた記事群で、リンクを配線した直後に「そもそも誰も来ない」ことが判明しました。エージェントに何を書かせるかの指示設計はCLAUDE.mdの書き方にまとめています。
カニバリ:量産でいちばん起きやすい事故
もう1つ、記事を増やすほど確実に起きるのがキーワードの食い合いです。エージェントは各記事を独立に企画するので、同じ検索語を狙う記事を平気で2本書きます。
// キーワードの重複=カニバリゼーション(量産で最も起きやすい事故)
const kwOwner = new Map();
for (const p of posts) {
for (const kw of p.fm.keywords || []) {
const norm = kw.trim().toLowerCase().replace(/\s+/g, ' ');
if (!norm) continue;
if (kwOwner.has(norm)) {
err(p.slug, `キーワード重複(カニバリ): "${kw}" は ${kwOwner.get(norm)} と競合`);
} else {
kwOwner.set(norm, p.slug);
}
}
}
正規化(trim + 小文字化 + 連続空白の圧縮)を入れてあるのが実務的なところです。これが無いと半角空白と連続空白の違いが別物として通ります。人が読めば同じでも、機械には別の文字列です。
なお、これを警告ではなくエラーにしています。カニバリは公開してから直すとURLが増えて余計に散らかるためです。書く前に止めるべき類の事故でした。
--fix の線引き:宣言は直す、本文は直さない
検査を作ると必ず「自動で直してほしい」となります。ここで線を引きました。
// ---- --fix: internal_out を本文から再生成 ------------------------------
let fixed = 0;
if (FIX) {
for (const p of posts) {
const bodyLinks = [...new Set(collectLinks(p.body))].filter((s) => bySlug.has(s) && s !== p.slug);
const current = JSON.stringify(p.fm.internal_out || []);
const next = JSON.stringify(bodyLinks);
if (current === next) continue;
const line = `internal_out: [${bodyLinks.map((s) => `"${s}"`).join(', ')}]`;
let out;
if (/^internal_out:.*$/m.test(p.raw.split('---')[1] || '')) {
out = p.raw.replace(/^internal_out:.*$/m, line);
} else {
out = p.raw.replace(/^(date:.*)$/m, `${line}\n$1`);
}
fs.writeFileSync(p.full, out);
fixed++;
}
}
やっているのは本文のリンクから internal_out を再生成することだけです。--fix を回して、警告は34件から14件に減りました。
本文には一切触りません。 理由は単純で、本文のリンクには意図があるからです。「宣言に無いリンクが本文にある」とき、正解は2通りあります。宣言を足すのが正しい場合と、そのリンク自体が間違いで消すべき場合です。機械にはこの2つが区別できません。
📌 自動修正の対象は「一意に正解が決まるもの」だけにする
宣言(internal_out)は本文から一意に導けるので機械に任せられます。本文は導けません。導けないものを直させると、静かに意味が変わります。
この線引きは、AIに作業を任せる範囲を決めるときの一般則としても使えます。同じ考え方を運用ルールに落とした話はClaude Codeの実務での使い方にまとめています。
検査自体がバグる:CTAボックスのリンクを見ていなかった
ここが今回いちばん学びになった部分です。検査スクリプトが「問題なし」を返すのは、問題が無いときだけとは限りません。
このサイトのCTAボックスは、Markdownのリンク記法ではなくHTMLの <a href> で書かれています。ところが最初の実装は、Markdown形式しか見ていませんでした。結果どうなったか。
- CTAボックスから出ているリンクが発リンクとして数えられない → 「本文に内部リンクが1つも無い」と誤検知
- そのリンクの受け側も被リンクとして数えられない → 孤児ではない記事が孤児と誤検知
つまり実在するリンクを見落として、正常な記事をエラーにしていました。 直したのがここです。
/** 本文から内部リンク先の slug を集める。マークダウンと HTML(CTAボックス)の両形式に対応。 */
function collectLinks(body) {
const out = [];
for (const re of [/\]\(\/posts\/([a-z0-9-]+)\)/g, /href="\/posts\/([a-z0-9-]+)"/g]) {
for (const m of body.matchAll(re)) out.push(m[1]);
}
return out;
}
正規表現を2本回すだけの修正です。問題は書き方ではなく、「本文のリンク」を1つの形式だと思い込んでいたことでした。
⚠ 検査結果を疑う手順を持っておく 誤検知が出たとき、記事側を直したくなります。今回は検査側が間違っていました。「明らかにおかしい判定」が1件でも出たら、記事ではなく検査の対象範囲をまず疑うほうが早いです。
同じ穴を2回踏む:gen-amazon-links.js
構造が同じ失敗をもう1つ踏んでいます。アフィリエイトリンクの生成スクリプトです。
このサイトでは、楽天とAmazonの両方に同じ商品のリンクを持たせています。Amazon側のリンクは、楽天側のリンクから検索キーワードを復元して作ります。ここで、楽天側にしかキーが存在しないという状態が起きます。記事を1本足して楽天リンクだけ生成すると、Amazon側にはそのキー自体が無い。すると生成ループがそのキーを一度も見ないまま終わります。
エラーは出ません。**「Amazon側は永久に空のまま、誰も気づかない」**という結末になります。
// 楽天側にあってAmazon側に無いキーを先に作る。
// これが無いと、記事を足して楽天リンクだけ生成したとき
// Amazon側は永久に空のまま気づけない(gen-rakuten-links.js で同じ穴を踏んだ)。
for (const rk of Object.keys(map)) {
if (!rk.startsWith('__SHOP_RAKUTEN_')) continue;
const ak = rk.replace('__SHOP_RAKUTEN_', '__SHOP_AMAZON_');
if (!(ak in map)) map[ak] = '';
}
コメントに書いてあるとおり、gen-rakuten-links.js で一度踏んだ穴を、gen-amazon-links.js でもう一度踏みました。 共通しているのは「既存のキーを埋めるループしか書いておらず、新規キーを作る処理が無い」という形です。
孤児記事とまったく同じ構造です。「あるものを処理する」ループは、無いものを見つけられません。 何本のリンクを生成したかは出力されますが、生成すべきだったのに一度も見なかった件数はどこにも出ません。
実際の回し方
公開前に回すのは、実質この3行です。
node scripts/check-links.js # 16項目の一括検査(エラーがあれば終了コード1)
node scripts/check-internal-links.js # 404になる内部リンクだけを速く見る
node scripts/check-links.js --fix # internal_out を本文から再生成(本文は触らない)
check-links.js はエラーがあれば終了コード1で終わります。これが重要で、ビルドの前段に置けば、人が判断する前に公開が止まります。 「気をつける」ではなく「通らない」にするのが、量産で唯一機能した方法でした。
複数のAIに作業を引き継ぐときも、この検査コマンドをブリーフに明記しています(AIにAIを引き継ぐブリーフとメモリ設計)。引き継いだ側が最初に回すものが決まっていないと、検査は数日で回されなくなります。 そもそもClaude Codeで何ができるのかはClaude Codeとは何かにまとめました。
そのまま使える頼み方
site/content/posts/ の .mdx 記事について、内部リンクの整合を検査する
Node.jsスクリプトを1本書いてください。
検査するもの:
・本文の /posts/<slug> が実在するファイルを指しているか
・frontmatter の slug とファイル名が一致しているか
・frontmatter の宣言と本文のリンクを「両方向」で突き合わせる
・被リンク0の記事(孤児)を列挙する
・keywords が他の記事と重複していないか(正規化してから比較)
条件:
・本文のリンクは Markdown 記法と HTML の <a href> の両方を拾ってください
・検査した記事の本数を必ず出力してください
・エラー(公開を止める)と警告(止めない)を分けてください
・エラーがあれば終了コード1で終わってください
・自動修正は frontmatter の再生成だけにしてください。本文は書き換えないでください
最後の2行を省かないでください。終了コードを返さない検査は、読まれなければ存在しないのと同じです。そして本文の自動修正を許すと、検査が壊れたときに記事が壊れます。
まとめ
- AIは実在しないスラッグをもっともらしく書く。
zaitaku-print(正しくはzaitaku-printer-hitsuyou)で404を公開しかけた - 内部リンクの整合はサイト全体の性質。74本なら5,402通り、宣言405件・キーワード230語の総当たりで、目視では原理的に追えない
- 実際に 6本が被リンク0の孤児、宣言と本文のずれが18件(記事20本の時点)
- 検査は16項目をエラー/警告に分け、エラーは終了コード1で公開を止める
--fixは宣言の再生成だけ。本文は意図が読めないので機械に書き換えさせない- 検査自体もバグる。 CTAボックスがHTMLの
<a href>なのにMarkdown形式しか見ておらず、正常な記事を孤児と誤検知していた - 「あるものを処理するループは、無いものを見つけられない」。孤児記事も
gen-amazon-links.jsの空リンクも同じ形の失敗
AIに書かせること自体が問題なのではありません。量産する側には、量産する側の検査が要るというだけの話です。1本ずつ丁寧に書くなら要らない道具で、本数を増やすと決めた瞬間に必須になる道具です。
在宅で1人でこうした運用を回す環境そのものの作り方は在宅ワークの作業環境ガイドにまとめています。