llms.txtファイルを検証する方法
依存関係のないNode.jsスクリプトでllms.txtのリンク、パッケージ名とバージョン、Markdown構造を検証し、CIで定期的にチェックする方法を紹介します。
llms.txtファイルを検証するには、次の4点を確認します。ファイルがH1で始まっていること、すべてのリンクがリンクテキストの示すページに正しく到達すること、記載されたすべてのパッケージ名とバージョンがレジストリ上に存在すること、そして内容が現行のドキュメントと矛盾していないことです。
llms.txtは、ローンチ前の短期集中開発の最中にドキュメントツールが自動生成し、それ以来誰も開いていない、というケースが多いでしょう。その間にページ名は変わり、パッケージは非推奨になっています。それでもコーディングエージェントはこのファイルを読み続けています。
フォーマットのおさらいが必要な場合は、まずllms.txtの解説記事をご覧ください。本記事では基本的な説明は省略します。具体的なチェック項目を順に解説し、依存関係ゼロのNodeスクリプトを紹介します。さらに、エージェントが問題のある記述に基づいて動作する前に検出できるよう、CIでの実行方法も説明します。
重要なポイント
- llms.txtの元の提案で必須とされている要素は、プロジェクト名またはサイト名を記したH1の1つだけです。引用ブロックによる概要、詳細説明の段落、H2のリンクセクションはいずれも任意です。
- llms.txtの提案では公式のバリデーターは公開されていません。また、一部のオンラインチェッカーは、提案上は任意とされている引用ブロックやH2セクションがないことを問題として指摘します。
- llms.txtに記載されたパッケージ名がレジストリ上にもう存在しない場合、その名前は誰でも登録できます。インストールコマンドの実行権限を持つエージェントは、そこに公開されたものを何であれ取り込んでしまう可能性があります。
- 自分で持ち込んだミスを検出するために、デプロイのたびにチェックを実行しましょう。また、自分の側で何も変更していなくても上流で壊れるリンクやパッケージを検出するために、週次のスケジュールでも実行しましょう。
古いllms.txtがサプライチェーンの問題になるのはなぜか
古いllms.txtがサプライチェーンの問題になるのは、エージェントがllms.txtを信頼できる参照資料として扱い、多くの場合は人間を介さずにその内容に基づいて動作するからです。その結果、古いリンクや誤ったパッケージ名がそのまま指示として機能してしまいます。人間の読み手であれば、リンク切れに当たった時点で諦めます。しかしシェルアクセス権を持つエージェントは、すでに他人の所有となった名前に対してnpm installを実行してしまうかもしれません。
llms.txtのサプライチェーンリスクは、実際に実証されています。TechRadarが報じたAlon Hertz氏の調査では、稼働中の6,214ドメインをスキャンし、8,265件のllms.txtおよびllms-full.txtファイルが見つかりました。同氏の集計によると、120のサイトのファイルが、誰も登録していないパッケージやドメインを参照していました。同氏はそうした空き名の一部を取得し、インストールされるたびに同氏へ通知を送るパッケージを公開しました。同氏によれば、あるFortune 500企業のシステムからは1時間以内に通知が届き始めたといいます。記事では、関与したエージェントとしてClaude、Codex、Hermesが挙げられています。
この件は冷静に捉える必要があります。これは管理された実験であり、実環境でマルウェアが確認された記録ではありません。それでも現実に存在するサプライチェーン上のリスクであることに変わりはなく、CIジョブを1つ追加するだけで低コストに対策できます。
アクセス制御は別の問題です。robots.txtによるAIクローラーのブロックは、サイトのどの部分をクローラーが読んでよいかを指定するものです。一方、llms.txtの検証は、クローラーが読む内容が今も正しいかどうかを担保するものです。
llms.txtの検証で確認すべき項目
有効なllms.txtとは、H1と正しい形式のリンクを持ち、すべてのリンクとパッケージ参照が実在し、かつその説明と一致しているファイルのことです。llms.txtの提案では公式のバリデーターは公開されていません。以下のチェック項目は、オンラインチェッカーが検査する内容に加え、ファイル内で言及されるパッケージのレジストリチェックも網羅しています。
リンクが到達可能であること
llms.txt内のすべてのURLは、リダイレクトを追跡した後に2xxステータスを返す必要があります。4xx、5xx、DNS解決の失敗はエラーとして扱います。名前解決できなくなったドメインは、未登録パッケージのドメイン版にあたります。つまり、誰でも購入できてしまいます。リダイレクトもログに記録しましょう。サイトのルートに着地するリダイレクトは、たいていそのページが削除されたことを意味します。サーバーによってはHEADを拒否したり正しく処理しなかったりするため、まずHEADを送信し、失敗した場合はGETにフォールバックします。
リンク先がリンクテキスト通りの内容であること
200レスポンスは、そのURLが応答することを示すにすぎません。リンクテキストが示す内容をページが今も掲載していることまでは保証しません。content-typeが想定どおりHTMLまたはMarkdownであることを確認しましょう。ページの<title>や最初の見出しをリンクテキストと比較し、エラーページが200ステータスで返されるソフト404にも注意します。このチェックは判定が曖昧になりやすいため、ビルドのゲートとしてではなく、定期的なレビューとして実施してください。
パッケージとバージョンがレジストリに存在すること
llms.txtでエージェントにインストールを指示しているすべてのパッケージについて、レジストリ上に存在するかを確認します。手作業で確認する場合は、npm view <name>を使うと、そのパッケージについてレジストリが保持している情報が表示され、パッケージが存在しなければエラーになります。スクリプトで確認する場合は、registry.npmjs.orgからパッケージドキュメントを取得します。レジストリが認識していない名前は、他人が取得できてしまう名前です。バージョンが固定されている場合は、その正確なバージョンがパッケージの公開済みバージョン一覧に含まれていることを確認します。PyPI、crates.ioなど他のエコシステムにも同等の照会手段があります。
Markdownの構造が正しいこと
llms.txtの元の提案で必須とされているセクションは、プロジェクト名またはサイト名を記したH1の1つだけです。任意のBOM(バイトオーダーマーク)を除き、ファイルは# Nameで始まる必要があります。現行のllms.txt仕様では、各H2セクションはMarkdownのリストを含み、その各項目は[name](url)形式のリンクで始まる必要があるとされています。コロンの後に続く補足説明は任意です。H1がない場合やリンク構文が壊れている場合はビルドを失敗させます。一方、引用ブロックやH2がないことを理由に失敗させてはいけません。一部のオンラインチェッカーはこの両方を指摘しますが、それらの警告を仕様違反として扱わないでください。
現行のドキュメントと矛盾していないこと
llms.txt内のURLの集合を、sitemap.xmlまたはドキュメントビルドのルートマニフェストと比較します。llms.txtには記載されているがサイトマップにはないURLは、疑わしいものとして扱います。パッケージについても同様です。ファイル内のすべての名前は、自社で公開しているpackage.jsonのnameフィールド、または推奨する意図のある依存パッケージのいずれかと一致している必要があります。
llms.txtを検証する依存関係ゼロのNodeスクリプト
このスクリプトは、URLまたはローカルパスを引数に取ります。H1とすべての絶対リンクをチェックし、インストールコマンドに含まれる各パッケージをnpmレジストリで照会します。Nodeのグローバルfetchを使用しているため、Node 24などの現行LTSであれば依存関係なしで動作します。
// scripts/check-llms.mjs — usage: node scripts/check-llms.mjs <url-or-path>
import { readFile } from 'node:fs/promises';
const src = process.argv[2];
if (!src) { console.error('usage: node check-llms.mjs <url-or-path>'); process.exit(2); }
const failures = [];
async function load(s) {
if (!/^https?:\/\//.test(s)) return readFile(s, 'utf8');
const res = await fetch(s);
if (!res.ok) { console.error(`✗ ${s} returned ${res.status}`); process.exit(1); }
return res.text();
}
async function checkLink(url) {
let res;
try {
res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
} catch {
// some servers reset the connection on HEAD; fall through to GET
}
try {
if (!res?.ok) res = await fetch(url, { redirect: 'follow' }); // some servers refuse HEAD
if (res.status >= 400) failures.push(`link ${res.status}: ${url}`);
else if (res.redirected) console.warn(`↪ ${url} -> ${res.url}`);
} catch (err) {
failures.push(`link unreachable (${err.cause?.code ?? err.message}): ${url}`);
}
}
async function checkPackage(spec) {
const [, name, version] = spec.match(/^(@?[^@\s]+)(?:@(\S+))?$/) ?? [];
if (!name) return;
const res = await fetch(`https://registry.npmjs.org/${name.replace('/', '%2F')}`);
if (res.status === 404) return failures.push(`npm: "${name}" is not registered`);
if (!res.ok) return failures.push(`npm: registry returned ${res.status} for "${name}"`);
const doc = await res.json();
if (/^\d+\.\d+\.\d+/.test(version ?? '') && !doc.versions?.[version])
failures.push(`npm: ${name}@${version} does not exist`);
}
const text = await load(src);
if (!/^# \S/.test(text.trimStart())) failures.push('structure: file does not start with an H1');
const links = new Set([...text.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)].map((m) => m[1]));
const specs = new Set([...text.matchAll(/\b(npx|npm (?:install|i|add)|pnpm add|yarn add)\s+([^\n`]+)/g)]
.flatMap(([, cmd, args]) => {
const tokens = args.trim().split(/\s+/).filter((t) => !t.startsWith('-'));
return cmd === 'npx' ? tokens.slice(0, 1) : tokens;
}));
for (const url of links) await checkLink(url);
for (const spec of specs) await checkPackage(spec);
failures.forEach((f) => console.error(`✗ ${f}`));
console.log(`${links.size} links, ${specs.size} package refs, ${failures.length} failures`);
process.exit(failures.length ? 1 : 0);
動作の仕組み:
loadは、ディスクまたはHTTP経由でファイルを読み込みます。llms.txtのURL自体の取得に失敗した場合、スクリプトは即座に終了します。checkLinkは、まずHEADを試し、HEADリクエストがエラーになるかOK以外のステータスを返した場合にGETで再試行します。リダイレクトは警告としてログに出力され、ドメインの名前解決失敗などのネットワークレベルの障害は失敗として記録されます。checkPackageは、各名前をnpmレジストリで照会し、存在しない名前と、公開されたことのない正確なバージョン指定を検出します。- サーバーとレジストリへの負荷を抑えるため、リクエストは意図的に1件ずつ順番に実行します。
- 失敗が1件でもあれば、スクリプトは終了コード1で終了します。
既知の制限もあります。相対リンクはスキップされます。インストールコマンドと同じ行にその後に続く文章があると、誤検知が発生する場合があります。バージョン範囲はチェックされず、正確なバージョン指定のみが対象です。
失敗時の実行結果は次のようになります(名前は架空のもので、出力は説明用の例です)。
↪ https://docs.example.com/guide -> https://docs.example.com/
✗ link unreachable (ENOTFOUND): https://docs.old.example.com/api
✗ npm: "example-sdk-legacy" is not registered
42 links, 3 package refs, 2 failures
デプロイのたびと毎週、CIでチェックを実行する
llms.txtのバリデーターは2通りの方法で実行します。1つはデプロイのたびに、ビルドしたばかりのファイルに対して実行する方法、もう1つはスケジュールに従って、公開中のファイルに対して実行する方法です。デプロイ時の実行では、自分で持ち込んだミスを検出できます。スケジュール実行では、リポジトリに一切変更がなくても発生するリンク切れや、上流で公開停止されたパッケージを検出できます。
name: Validate llms.txt
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Mondays 06:00: catches link rot and unpublished packages
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node scripts/check-llms.mjs https://docs.example.com/llms.txt
scheduleトリガーは標準的な5フィールドのcron文字列を受け取ります。IANAタイムゾーン名を指定したtimezoneキーを追加しない限り、GitHubはこれをUTCとして解釈します。workflow_dispatchを使うと、手動で実行をトリガーできます。ドキュメントのデプロイが同じワークフロー内にある場合は、公開ステップの前に、ビルド出力を対象とするステップ(例: node scripts/check-llms.mjs dist/llms.txt)を追加しましょう。こうすることで、チェックが失敗した場合にリリースをブロックできます。スケジュール実行のジョブは、公開中のURLを対象としたままにしておきます。
自動生成によってllms.txtを同期させる
llms.txtをドキュメントと同じソースから生成すれば、ドキュメントにもう存在しないページやパッケージがファイルに記載されることはありません。ドキュメントをレンダリングするのと同じステップで、同じページ一覧とフロントマターから生成しましょう。
// scripts/build-llms.mjs
import { writeFile } from 'node:fs/promises';
import pages from '../dist/pages.json' with { type: 'json' };
const lines = ['# Example SDK', '', '## Docs', ''];
for (const p of pages.filter((p) => !p.draft)) {
lines.push(`- [${p.title}](https://docs.example.com${p.path}): ${p.description}`);
}
await writeFile('dist/llms.txt', lines.join('\n') + '\n');
生成されたファイルを手作業で編集してはいけません。ジェネレーターのみをコミットするか、ジェネレーターとその出力の両方をコミットし、再生成によって差分が生じた場合にCIを失敗させるようにします。生成の仕組みが整えば、バリデーターは誰かが覚えておかなければならない後片付けの作業ではなく、ジェネレーターを守るガードとして機能するようになります。
まとめ
llms.txtファイルが役に立つのは、記載されたすべての項目が今も存在し、その説明と一致している場合に限られます。エージェントは確認せずにその内容に基づいて動作するからです。scripts/check-llms.mjsをリポジトリに追加し、公開中のファイルに対して一度実行して、報告された問題を修正しましょう。そのうえでワークフローをコミットすれば、次にリンク切れや宙に浮いたパッケージ名が発生したときに、エージェントがそれを見つける前にビルドを失敗させることができます。
よくある質問
llms-full.txtもllms.txtと同じ方法で検証すべきですか?
リンクとパッケージについてはそのとおりです。特にパッケージについては、より厳格に検証すべきです。llms-full.txtはドキュメントの全文をインラインで含むため、通常はllms.txtよりもはるかに多くのインストールコマンドやコードサンプルを含んでおり、そのすべてがエージェントによって実行されうるパッケージ名です。構造のチェックは緩めでも構いません。このファイルは短いリンク一覧ではなく、主にページの本文で構成されているからです。同じスクリプトを両方のファイルに対して実行してください。
ブラウザでは正常に表示されるページについて、リンクチェックが403エラーを報告するのはなぜですか?
ボット対策機能やWebアプリケーションファイアウォール(WAF)が、自動化されたリクエストをブロックしている可能性が高いです。CDNやセキュリティレイヤーの中には、ブラウザらしいヘッダーを持たないリクエストや、CIランナーのIPレンジからのリクエストを拒否するものがあります。自社のドキュメントドメインについてCIランナーやチェッカーのUser-Agentを許可リストに登録するか、fetchで内容のわかるUser-Agentヘッダーを送信してください。403を単純に無視してはいけません。エージェントも同じようにブロックされている可能性があるからです。
llms.txtに記載されたPythonのパッケージ名をPyPIで確認するにはどうすればよいですか?
pypi.org/pypi/NAME/json にあるPyPI JSON APIに問い合わせます。プロジェクトが存在する場合は、メタデータに加えてバージョン文字列をキーとするreleasesオブジェクトが返されるため、名前と固定されたバージョンの両方を確認できます。リクエストが404を返した場合は、その名前を未登録として扱います。スクリプトがnpm installを処理するのと同じ方法で、pip installやuv addコマンドから名前を抽出してください。
レジストリチェックに合格すれば、llms.txt内のパッケージは安全にインストールできるということですか?
いいえ。このチェックで確認できるのは名前が存在することだけで、正当な所有者がその名前を保持していることまでは確認できません。元のパッケージが放棄された後に、誰かがすでにその名前を取得している可能性があります。また、llms.txt内のタイプミスによって、実在する紛らわしい名前のパッケージを指してしまうこともあります。推奨するすべてのパッケージについて、公開者とリポジトリのリンクが自社の組織と一致していることを確認し、自社で管理するスコープ付きの名前を優先して使用してください。