IT技術ブログ

llms.txt と llms-full.txt をAstroのビルドで自動生成する実装。載せる記事と載せないページの線引き、冒頭の段落から作る要点、テーマ別の入口の載せ方、生成した中身の確かめ方

llms.txt と llms-full.txt をAstroのビルドで毎回作り直す実装です。下書きや検索に出さないページを外す方法、記事冒頭の段落から要点を取る処理、テーマ別の入口の載せ方を、実際に生成した出力つきで説明します。

この記事の結論:llms.txt は手で書かず、記事ページやサイトマップと同じ「記事の一覧を返す関数」から、ビルドのたびに作り直すのが確実です。下書きはその関数の中で外し、検索に出さないページはそもそも一覧の元にしません。llms-full.txt に載せる記事の要点は、最初の見出しより前にある冒頭の段落から取ると、記事を書くときに結論を先に書く習慣がそのまま生きます。ただし、llms.txt を読むと公式に表明しているAIの事業者は、2026年10月4日の時点で確認できていません。置くのは手間が小さいからで、効果を当てにしてのことではありません。

このブログはAstroで作った静的サイトで、Cloudflare Pagesで公開しています。2026年10月2日から /llms.txt と /llms-full.txt をビルドで生成していて、この記事では、その実装をそのまま載せたうえで、10月4日に本番と同じビルドを実行して出てきた中身と、生成物を点検するスクリプトの結果を示します。llms.txt とは何か、手で書くならどう書くかは、会社のコラムllms.txtとは。書き方の例と作り方で扱っています。この記事は、自動で作る側の話だけを書きます。

llms.txt は、記事ページと同じ一覧から毎回作り直すと食い違わない

ビルドで作り直す一番の理由は、記事ページ・サイトマップ・llms.txt の3つが、同じ元から作られるようにするためです。元が同じなら、記事の題や説明文を直したときに、llms.txt だけが古いまま残ることがありません。

このブログでは、記事の front matter(題・説明文・カテゴリー・よくある質問)を、記事ページの <title> やメタディスクリプション、構造化データ、サイトマップにも使っています。llms.txt もその一員にしておけば、記事を直してビルドするだけで全部がそろいます。手で書く運用にした場合、記事が160本を超えたいまでは、どこかで必ず更新が漏れると考えました。

作り直しにかかる時間は小さく、10月4日のビルドのログでは、/llms-full.txt の生成が105ミリ秒、/llms.txt が3ミリ秒でした。

17:24:24   ├─ /llms-full.txt (+105ms)
17:24:24   ├─ /llms.txt (+3ms)
...
17:24:25 [build] 254 page(s) built in 3.55s

llmstxt.org の提案で必須なのは、最初の見出しだけ

llms.txt の形式は、Jeremy Howard氏が2024年9月に出した提案(llmstxt.org)で、2026年8月10日に第2版になりました。必須なのは先頭のH1(サイト名)だけで、要約の引用ブロック、見出しを使わない説明の段落、H2で区切ったリンクの一覧は、どれも任意です。

実装のうえで押さえておきたい第2版の要点は、次のとおりです。

項目 第2版での扱い このブログの実装
必須の要素 H1だけ サイト名をH1にしている
「Optional」の見出し 補足のリンクを置く慣習として使えるが、機械的に読み飛ばす目印ではなくなった llms-full.txt・サイトマップ・RSSへのリンクを置いている
置く場所 ルートのほか、/docs/llms.txt のような下の階層にも置け、いちばん近いファイルが使われる ルートに1つだけ
各ページの Markdown 版 同じURLの .md 版を用意し、rel="alternate" type="text/markdown" で示すことを提案 用意していない
HTML から llms.txt を示す rel="describedby" のリンクやHTTPヘッダーで示せる まだ入れていない
llms-full.txt 提案の文書に決まりはない 独自に置いている(下で説明)

第2版の変更点のページには、以前の版にあった「Optional を省いて文脈を組み立てる道具」が提案から外されたこと、エージェントは llms.txt を眺めたり検索したりしてから必要なリンクをたどる、という使われ方を前提にしたことが書かれています。llms-full.txt のように全文を1ファイルにまとめる形は、提案の外にある慣習です。このブログでは「全記事の要点とよくある質問を、1回の取得で読める」ことに意味があると考えて置いていますが、仕様に沿っているからではありません。

主要なAI事業者が llms.txt をどう扱うかは、Google以外は公式な表明が見当たらない

2026年10月4日に各社の公式ページを確かめた範囲では、Googleは「検索では使っていない」と明言していて、OpenAI・Anthropic・Perplexity については、サイトに置いた llms.txt を読むという公式な対応表明は確認できませんでした。

事業者 確かめたページ llms.txt について書かれていること
Google 検索セントラルの生成AI機能向けの最適化ガイド AI向けのテキストファイルやMarkdownを新しく作る必要はなく、Google検索はそれらを使わない。置いても良くも悪くもならない
OpenAI クローラーの説明(OAI-SearchBot・GPTBot・ChatGPT-User など) robots.txt での制御の説明だけで、サイトの llms.txt を読むという記述は無い
Anthropic クローラーの説明(ClaudeBot・Claude-User・Claude-SearchBot) robots.txt での制御の説明だけで、llms.txt への言及は無い
Perplexity クローラーの説明(PerplexityBot・Perplexity-User) robots.txt・IPの範囲・WAFでの制御の説明だけで、サイトの llms.txt を読むという記述は無い

OpenAI・Anthropic・Perplexity は、自社の開発者向けドキュメントには llms.txt を置いています(10月4日に開くと、どれも200で返りました)。ただ、これは「自分たちの文書を読みやすくしている」ことで、「ほかのサイトの llms.txt を読む」という表明とは別のものです。ここを混ぜて「主要なAIが対応している」と書くのは言い過ぎだと考えています。

AIのクローラーそのものを robots.txt でどう扱うかは、AIクローラーのrobots.txt設定例に書きました。llms.txt を置いても、robots.txt やCDNの設定でクローラーを止めていれば読まれません。順番としては、そちらが先です。

載せるのは公開済みの記事とテーマ別の入口、載せないのは下書きと検索に出さないページ

llms.txt に載せる範囲は、「検索エンジンに読んでほしいページ」と同じにしています。基準をサイトマップとそろえておくと、迷ったときの判断が1つで済みます。

ページ llms.txt llms-full.txt 外し方
公開済みの記事 題・URL・説明文 題・URL・日付・カテゴリー・説明文・要点・よくある質問 ―
テーマ別の入口(ブライダル・フィットネスのカテゴリー) まとめと、テーマごとのページ内リンク 同じ ―
下書き(draft: true) 載せない 載せない 記事の一覧を返す関数で外す
LINEで配るテンプレート(/tools/ の下・noindex) 載せない 載せない 記事のコレクションに入れていないので、元から一覧に出ない
記事が無いカテゴリー・タグの一覧(noindex) 載せない 載せない 一覧のページは、テーマ別の入口の2つ以外は元にしていない

下書きを外しているのは、llms.txt の側ではなく、記事の一覧を返す関数の側です。記事ページ、一覧、サイトマップ、RSS、llms.txt が、すべてこの関数を通ります。

src/lib/posts.ts(抜粋)ts
import { getCollection, type CollectionEntry } from 'astro:content';

export type Post = CollectionEntry<'posts'>;

let cache: Post[] | null = null;
export async function getPosts(): Promise<Post[]> {
  if (cache) return cache;
  // 下書き(draft: true)は本番ビルドに出さない。npm run dev のときだけ表示して確認できるようにする
  const all = await getCollection('posts', (p) => !p.data.draft || import.meta.env.DEV);
  cache = all.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
  return cache;
}

import.meta.env.DEV の条件のとおり、開発サーバー(astro dev)では下書きも一覧に出ます。実際に10月4日、手元で動いていた開発サーバーから /llms.txt を取ると、公開前の記事のURLが入っていて、- [ で始まるリンクの行は本番ビルドの177行に対して181行ありました。開発中に llms.txt を開いて中身を確かめるときは、この違いを忘れると「下書きが漏れている」と勘違いします。確認は astro build の出力でします。

/tools/ の下のページは、記事のコレクションとは別のページとして作っていて、HTMLの meta robots とCloudflare Pagesの _headers の両方で noindex にしています。llms.txt の元は記事のコレクションだけなので、こうしたページは何もしなくても入りません。逆に言えば、あとで「記事のコレクションに入れたうえで noindex にする記事」を作るなら、そのときは一覧の関数に条件を足す必要があります。

llms-full.txt の要点は、最初の見出しより前の段落から取る

llms-full.txt の「要点」には、記事の冒頭、最初のH2より前にある段落のうち、40文字以上の最初の1つを使っています。このブログの記事は「この記事の結論」から書き始めるので、その段落がそのまま要点になります。

src/lib/llms.ts(要点の取り出し)ts
/** 記事冒頭の要点(最初の見出しより前の、最初の段落)。なければ説明文 */
export function postSummary(p: Post): string {
  const $ = cheerio.load(p.rendered?.html ?? '', null, false);
  for (const el of $.root().children().toArray()) {
    if ((el as { name?: string }).name === 'h2') break;
    if ((el as { name?: string }).name !== 'p') continue;
    const t = oneLine($(el).text()).replace(/^この記事の結論\s*[::]\s*/, '');
    if (t.length >= 40) return t;
  }
  return p.data.description;
}

要点を別の項目として front matter に書かせる方法もありますが、そうすると本文の結論と要点の2か所を直すことになり、片方だけ古くなります。本文の冒頭から取れば、記事を直したときに要点も一緒に変わります。

決めごとは3つです。

  • Markdown ではなく、描画後のHTMLから取る。 リンクや太字の記号を取り除く処理を自分で書かずに済みます。Astroのコンテンツコレクションでは、rendered.html に描画済みのHTMLが入っています
  • 「この記事の結論:」の見出し語は外す。 要点として読ませるときには意味の無い前置きだからです
  • 40文字に満たない段落は飛ばす。 「こんにちは」のような書き出しの短い段落を、要点として拾わないためです。最後まで見つからなければ説明文を使います

10月4日のビルドでは、167本すべての記事で冒頭の段落から要点が取れていて、説明文で代わりにした記事は0本でした。要点の長さは、短いもので87文字、真ん中が257文字、長いもので429文字です。

テーマ別の入口は、まとめとページ内リンクの組で載せる

ブライダルとフィットネス・健康のカテゴリーは、記事をテーマごとに並べた入口のページにしています。llms.txt には、そのページのまとめを1行、テーマごとに「見出しへのページ内リンク」と「そのテーマの答えの1〜2文」を1行ずつ載せています。

src/lib/llms.ts(テーマ別の入口)ts
/** テーマ別の入口(ブライダル・フィットネスのカテゴリー)。まとめと、テーマごとの要点・ページ内リンク */
function hubs(): string[] {
  const out = ['## テーマ別の入口', ''];
  for (const h of hubDefs) {
    const c = getCategory(h.category);
    if (!c) continue;
    const url = abs(categoryUrl(c));
    out.push(`- [${c.name}(テーマ別の記事一覧)](${url}): ${oneLine(h.intro)}`);
    for (const s of h.sections) out.push(`  - [${s.title}](${url}#${s.id}): ${oneLine(s.lead)}`);
  }
  out.push('');
  return out;
}

hubDefs は、入口のページそのものを組み立てる設定と同じものです。ページに表示している「まとめ」と「テーマごとの答え」を、文言を変えずに llms.txt にも出しています。テーマの見出しの id(#marketing など)を変えるとページ内リンクが切れるので、設定のファイルに「変えるとリンクが切れる」と書いておきました。

記事の一覧だけを並べると、AIのエージェントから見て「このサイトは何のテーマを、どの順で読めばよいか」が分かりません。入口のページを先に置くと、テーマから記事へたどる道が1本できます。

実装の全体:ライブラリ1つと、エンドポイント2つ

生成の処理は src/lib/llms.ts に置き、src/pages/llms.txt.ts と src/pages/llms-full.txt.ts から呼んでいます。Astroの静的出力では、src/pages/ の .ts ファイルで GET を書くと、ビルドのときに1回だけ実行され、その戻り値がファイルとして書き出されます。

src/pages/llms.txt.tsts
// /llms.txt(ビルド時に生成)
import { llmsTxt } from '../lib/llms';

export async function GET() {
  return new Response(await llmsTxt(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
src/pages/llms-full.txt.tsts
// /llms-full.txt(ビルド時に生成)。全記事の題・URL・説明・要点・よくある質問
import { llmsFullTxt } from '../lib/llms';

export async function GET() {
  return new Response(await llmsFullTxt(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}

静的出力では、ここで書いた Content-Type は配信のときには使われません。配信するのはCloudflare Pagesなので、public/_headers で文字コードまで指定し、日本語のテキストとして読まれるようにしています。

public/_headers(抜粋)
/llms.txt
  Content-Type: text/plain; charset=utf-8

/llms-full.txt
  Content-Type: text/plain; charset=utf-8

本体の src/lib/llms.ts は次のとおりです。SITE・AUTHOR・COMPANY はサイトの設定、categories などはカテゴリーの定義、plainMd はよくある質問の Markdown を平文にする関数で、どれもこのブログの中のモジュールです。

src/lib/llms.tsts
// /llms.txt と /llms-full.txt の本文(AI の検索・回答システム向けのサイト案内)
// Google 検索は llms.txt を使っていない(公式)。ほかの AI 向けに置いているだけで、主役は通常の HTML と構造化データ
import * as cheerio from 'cheerio';
import { SITE, AUTHOR, COMPANY } from '../config/site';
import { getPosts, isoDate, type Post } from './posts';
import { categories, categoryUrl, getCategory, primaryCategory, ancestorCategories } from './taxonomy';
import { plainMd } from './schema';
import { hubDefs } from '../data/hubs';

const abs = (p: string) => new URL(p, SITE.url).href;
const day = (d: Date) => isoDate(d).slice(0, 10);
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();

/** 記事冒頭の要点(最初の見出しより前の、最初の段落)。なければ説明文 */
export function postSummary(p: Post): string {
  const $ = cheerio.load(p.rendered?.html ?? '', null, false);
  for (const el of $.root().children().toArray()) {
    if ((el as { name?: string }).name === 'h2') break;
    if ((el as { name?: string }).name !== 'p') continue;
    const t = oneLine($(el).text()).replace(/^この記事の結論\s*[::]\s*/, '');
    if (t.length >= 40) return t;
  }
  return p.data.description;
}

function header(): string[] {
  return [
    `# ${SITE.name}`,
    '',
    `> ${SITE.description}`,
    '',
    `${SITE.name}(${abs('/')})は、${COMPANY.name}(${COMPANY.url})の${AUTHOR.jobTitle}・${AUTHOR.name}(${AUTHOR.nameEn})が個人で書いている開発ブログです。`,
    `著者は${COMPANY.name}で${AUTHOR.role}しています。${AUTHOR.summary}`,
    AUTHOR.aboutBlog,
    AUTHOR.regions,
    `お仕事のご相談は${COMPANY.name}のお問い合わせ(${COMPANY.contactUrl})で受け付けています。`,
    '',
    '## 著者と会社',
    '',
    `- [運営者について(${AUTHOR.name}のプロフィール)](${abs('/about/')})`,
    `- [${COMPANY.name}](${COMPANY.url}): 著者が${AUTHOR.jobTitle}を務める会社`,
    `- [bundlyze コラムの執筆者ページ](${AUTHOR.profileUrl})`,
    `- [X(${AUTHOR.xHandle})](${AUTHOR.x})`,
    `- [Instagram(${AUTHOR.instagramHandle})](${AUTHOR.instagram})`,
    '',
    ...hubs(),
  ];
}

/** テーマ別の入口(ブライダル・フィットネスのカテゴリー)。まとめと、テーマごとの要点・ページ内リンク */
function hubs(): string[] {
  const out = ['## テーマ別の入口', ''];
  for (const h of hubDefs) {
    const c = getCategory(h.category);
    if (!c) continue;
    const url = abs(categoryUrl(c));
    out.push(`- [${c.name}(テーマ別の記事一覧)](${url}): ${oneLine(h.intro)}`);
    for (const s of h.sections) out.push(`  - [${s.title}](${url}#${s.id}): ${oneLine(s.lead)}`);
  }
  out.push('');
  return out;
}

/** ルートカテゴリごとに記事をまとめる */
function grouped(posts: Post[]) {
  const groups = new Map<string, Post[]>();
  for (const p of posts) {
    const c = primaryCategory(p.data.categories);
    const root = c ? (ancestorCategories(c)[0] ?? c) : undefined;
    const key = root?.slug ?? '';
    groups.set(key, [...(groups.get(key) ?? []), p]);
  }
  const order = categories.filter((c) => !c.parent).map((c) => c.slug);
  return [...groups].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
}

export async function llmsTxt(): Promise<string> {
  const posts = await getPosts();
  const out = header();
  for (const [slug, list] of grouped(posts)) {
    const c = getCategory(slug);
    out.push(`## 記事:${c?.name ?? 'その他'}`, '');
    for (const p of list) out.push(`- [${p.data.title}](${abs(`/${p.data.slug}/`)}): ${oneLine(p.data.description)}`);
    out.push('');
  }
  out.push(
    '## Optional',
    '',
    `- [全記事の要点(llms-full.txt)](${abs('/llms-full.txt')})`,
    `- [サイトマップ](${abs('/sitemap-index.xml')})`,
    `- [RSS](${abs('/feed/')})`,
    '',
  );
  return out.join('\n');
}

export async function llmsFullTxt(): Promise<string> {
  const posts = await getPosts();
  const out = header();
  out.push('## 記事(新しい順)', '');
  for (const p of posts) {
    const d = p.data;
    const c = primaryCategory(d.categories);
    const cats = c ? [...ancestorCategories(c), c].map((x) => x.name).join(' > ') : '';
    const modified = d.modified && d.modified > d.date ? d.modified : d.date;
    out.push(
      `### ${d.title}`,
      '',
      `- URL: ${abs(`/${d.slug}/`)}`,
      `- 公開日: ${day(d.date)} / 最終更新日: ${day(modified)}`,
      ...(cats ? [`- カテゴリー: ${cats}(${abs(categoryUrl(c!))})`] : []),
      `- 著者: ${AUTHOR.name}(${COMPANY.name} ${AUTHOR.jobTitle})`,
      `- 説明: ${oneLine(d.description)}`,
      '',
      `要点: ${postSummary(p)}`,
      '',
    );
    if (d.faq.length) {
      out.push('よくある質問:', '');
      for (const f of d.faq) out.push(`- Q. ${oneLine(f.q)}`, `  A. ${oneLine(plainMd(f.a))}`);
      out.push('');
    }
  }
  return out.join('\n');
}

2つのファイルで、役割を分けています。

  • llms.txt は目次。 記事は題と説明文だけにし、カテゴリーの大分類ごとにH2で区切ります。エージェントが全体を見渡して、読む記事を選ぶためのファイルです
  • llms-full.txt は要約の束。 記事ごとに日付・カテゴリーの階層・要点・よくある質問まで載せます。新しい順に並べ、最終更新日も入れて、情報の新しさが分かるようにしています

URLは new URL(path, SITE.url).href で組み立てているので、日本語を含むスラッグやカテゴリーは、パーセントエンコードされた形で出ます。ブラウザのアドレス欄の見た目とは違いますが、サイトマップと同じ表記になるので、点検のときに文字列のまま突き合わせられます。

ビルドで出力された中身

10月4日に npm run build を実行すると、dist/llms.txt は217行・97,877バイト、dist/llms-full.txt は3,323行・524,130バイトになりました。llms.txt に並んだ記事は、IT 61本・ブライダル 66本・フィットネス・健康 40本の計167本です。

llms.txt の先頭は、次のように出力されます(テーマ別の入口は1行目以外を途中で切っています)。

dist/llms.txt(先頭)markdown
# やまやまブログ

> 株式会社bundlyze CTO・津嘉山 洸の開発ブログ。Web・システム開発の実装メモから、エンジニアのキャリアまで。

やまやまブログ(https://www.yamayamabloglink.com/)は、株式会社bundlyze(https://www.bundlyze.co.jp/)のCTO・津嘉山 洸(Ko Tsukayama)が個人で書いている開発ブログです。
...

## 著者と会社

- [運営者について(津嘉山 洸のプロフィール)](https://www.yamayamabloglink.com/about/)
- [株式会社bundlyze](https://www.bundlyze.co.jp/): 著者がCTOを務める会社
...

## テーマ別の入口

- [ブライダル(テーマ別の記事一覧)](https://www.yamayamabloglink.com/category/bridal/): 結婚式場・ホテルの婚礼部門・フォトスタジオ・ドレスショップのWeb集客、…
  - [集客と検索](https://www.yamayamabloglink.com/category/bridal/#marketing): 結婚式場がフェア予約につながる人に…
  - [公式サイトとCMS](https://www.yamayamabloglink.com/category/bridal/#website): 式場の公式サイトで、…
...

## 記事:IT

- [ホームページ制作を依頼する前に社内で決めておくこと。…](https://www.yamayamabloglink.com/website-production-internal-decisions-before-ordering/): ホームページ制作を依頼する前に、…
...

## Optional

- [全記事の要点(llms-full.txt)](https://www.yamayamabloglink.com/llms-full.txt)
- [サイトマップ](https://www.yamayamabloglink.com/sitemap-index.xml)
- [RSS](https://www.yamayamabloglink.com/feed/)

llms-full.txt の1記事分は、次のようになります(要点とよくある質問の答えは途中で切っています)。

dist/llms-full.txt(1記事分)markdown
### AIクローラーのrobots.txt設定例。GPTBot・ClaudeBot と Google-Extended・PerplexityBotを学習用と検索用に分けて書き、Cloudflare側の設定まで確かめる

- URL: https://www.yamayamabloglink.com/ai-crawler-robots-txt-cloudflare/
- 公開日: 2026-10-03 / 最終更新日: 2026-10-03
- カテゴリー: IT > 技術ブログ(https://www.yamayamabloglink.com/category/it/%E6%8A%80%E8%A1%93%E3%83%96%E3%83%AD%E3%82%B0/)
- 著者: 津嘉山 洸(株式会社bundlyze CTO)
- 説明: AIクローラーをrobots.txtで扱う設定例です。…

要点: AIクローラーを robots.txt で扱うときは、各社の読み取りを「学習用」「検索用」「利用者の依頼でその場で読みに来るもの」の3つに分け、…

よくある質問:

- Q. GPTBotだけ拒否すれば、ChatGPTの検索の答えには今までどおり出ますか?
  A. OpenAIの説明では、検索の答えに表示するための読み取りは OAI-SearchBot が担い、…

本文の冒頭が「この記事の結論:」で始まっていても、要点からはその前置きが外れていることが分かります。

生成物は、ビルドのあとにスクリプトで点検する

生成した2つのファイルは、目で見るだけでなく、ビルドのあとに点検のスクリプトを流して確かめました。見ているのは、リンク切れ、下書きの混入、/tools/ の混入、サイトマップにある記事の漏れ、要点を説明文で代わりにした記事の数です。

check-llms.mjsjs
// ビルド後の dist/llms.txt・llms-full.txt を点検する
// 使い方: node check-llms.mjs <サイトのルート(package.json がある場所)>
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const root = process.argv[2] ?? '.';
const dist = join(root, 'dist');
const HOST = 'https://www.yamayamabloglink.com';
const llms = readFileSync(join(dist, 'llms.txt'), 'utf8');
const full = readFileSync(join(dist, 'llms-full.txt'), 'utf8');
const sitemap = readFileSync(join(dist, 'sitemap-0.xml'), 'utf8');
const problems = [];

// 1. llms.txt のリンク先が、ビルド結果に実在するか
const links = [...llms.matchAll(/\]\((https?:\/\/[^)]+)\)/g)].map((m) => m[1]);
const own = links.filter((u) => u.startsWith(HOST));
for (const u of own) {
  const path = decodeURI(new URL(u).pathname);
  const file = path.endsWith('/') ? join(dist, path, 'index.html') : join(dist, path);
  if (!existsSync(file)) problems.push(`リンク切れ: ${u}`);
}

// 2. 下書きの記事が混ざっていないか
const postsDir = join(root, 'src/content/posts');
const drafts = [];
for (const dir of readdirSync(postsDir)) {
  const md = join(postsDir, dir, 'index.md');
  if (!existsSync(md)) continue;
  const fm = readFileSync(md, 'utf8').split('\n---')[0];
  if (/^draft:\s*true/m.test(fm)) drafts.push(fm.match(/^slug:\s*"?([^"\n]+)"?/m)[1]);
}
for (const s of drafts) {
  if (llms.includes(`/${s}/`) || full.includes(`/${s}/`)) problems.push(`下書きが載っている: ${s}`);
}

// 3. 検索に出さないページ(/tools/)が混ざっていないか
if (/yamayamabloglink\.com\/tools\//.test(llms + full)) problems.push('/tools/ が載っている');

// 4. サイトマップで lastmod を持つURL(=記事)が、すべて llms.txt に載っているか
const articles = [...sitemap.matchAll(/<url><loc>([^<]+)<\/loc><lastmod>/g)].map((m) => m[1]);
const missing = articles.filter((u) => !llms.includes(`(${u})`));
for (const u of missing) problems.push(`記事が llms.txt に無い: ${u}`);

// 5. 要点が説明文の使い回し(冒頭の段落が取れなかった)になっている記事
const blocks = full.split('\n### ').slice(1);
const fallback = blocks.filter((b) => {
  const desc = b.match(/^- 説明: (.*)$/m)?.[1];
  const sum = b.match(/^要点: (.*)$/m)?.[1];
  return desc && sum === desc;
});
const lens = blocks.map((b) => (b.match(/^要点: (.*)$/m)?.[1] ?? '').length).sort((a, b) => a - b);

console.log(`llms.txt: ${llms.length.toLocaleString()}文字 / 自サイトへのリンク ${own.length}件(うち記事 ${articles.length}件)`);
console.log(`llms-full.txt: ${full.length.toLocaleString()}文字 / 記事 ${blocks.length}件`);
console.log(`下書き: ${drafts.length}件(載っていないかを確認)`);
console.log(`要点を説明文で代用した記事: ${fallback.length}件`);
console.log(`要点の長さ: 最短${lens[0]}字 / 中央${lens[Math.floor(lens.length / 2)]}字 / 最長${lens.at(-1)}字`);
console.log(problems.length ? `問題 ${problems.length}件\n- ${problems.join('\n- ')}` : '問題なし');
process.exit(problems.length ? 1 : 0);

このブログのサイトマップでは、lastmod を付けているのが記事のURLだけなので、「lastmod があるURL=記事」として突き合わせています。10月4日のビルドの直後に実行した結果は、次のとおりです。

$ node check-llms.mjs .
llms.txt: 43,921文字 / 自サイトへのリンク 186件(うち記事 167件)
llms-full.txt: 212,269文字 / 記事 167件
下書き: 8件(載っていないかを確認)
要点を説明文で代用した記事: 0件
要点の長さ: 最短87字 / 中央257字 / 最長429字
問題なし

自サイトへのリンク186件の内訳は、記事167件、テーマ別の入口とその節15件、プロフィール1件、Optional の3件です。この時点で下書きは8本あり、どれも2つのファイルに入っていませんでした。失敗したときに終了コードが1になるようにしてあるので、ビルドの後ろにつなげば、問題があるときにデプロイを止められます。ただし、このスクリプトはまだビルドの流れには組み込んでおらず、手で実行しただけです。

誰が取りに来ているかは、記録の準備を終えたところ

llms.txt を作っただけでは、AIに読まれているかは分かりません。このブログでは、2026年10月4日に、/llms.txt と /llms-full.txt へのアクセスを、ボットの名前と一緒に記録する処理をCloudflare Pagesの Functions に入れました。記録の保存先(Workers Analytics Engine)の有効化がまだのため、この記事を書いた時点では記録は動き出していません。人のアクセスも「ボットではない」として数えるので、取りに来ているのが人だけなのか、どのクローラーなのかを分けて見られます。

保存先を有効にして数週間分がたまったら、どのボットがいつ取りに来たか(あるいは来なかったか)を、数字のまま別の記事に書く予定です。

会社のサイトでも、コラムを足すたびに llms.txt が書き換わる作りにしていて、その経過は会社のコラムで記録しています。株式会社bundlyzeでは、自社サイトでLLMOを実践しています。サイト制作の際に、llms.txt やサイトマップの生成をサイトの仕組みに組み込むところから相談できます。ご相談の窓口はSEO・LLMO対策のページです。

動作確認した環境

2026年10月4日に、手元のMacで確かめました。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
Astro 7.3.5(output: 'static'、trailingSlash: 'always')
cheerio 1.2.0(要点の取り出し)
実行したこと npm run build(表紙の生成と astro build)、点検のスクリプト、手元で動いていた開発サーバーからの /llms.txt の取得、公開中の /llms.txt と /llms-full.txt が200で返ることの確認

点検のスクリプトをビルドやデプロイの流れに組み込むこと、rel="describedby" のリンクと各記事の Markdown 版(第2版の提案)は、まだ行っていません。AIのクローラーが llms.txt を取りに来ているかどうかは、記録がまだ動き出していないため確かめられていません。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

llms.txt は手で書くのと、ビルドで作るのと、どちらがよいですか?

記事やページが月に何本も増えるサイトなら、ビルドで作る方が向いています。手で書くと、記事の題や説明文を直したときに llms.txt だけ古いまま残りやすいからです。会社概要とサービスのページだけのような、ほとんど変わらないサイトなら、手で書いて置くだけでも足ります。

下書きの記事が llms.txt に載らないようにするには、どうすればよいですか?

記事の一覧を取る関数を、サイトマップや記事ページと同じものにし、その関数の中で下書きを外します。llms.txt のためだけに別の絞り込みを書くと、条件がずれて下書きが漏れることがあります。Astro の開発サーバーでは下書きを表示する設定にしている場合、開発中に開いた llms.txt にも下書きが出るので、確認は本番と同じビルドの出力で行います。

llms-full.txt は置かないといけないものですか?

置く義務はありません。llmstxt.org の提案(2026年8月の第2版)が決めているのは llms.txt の形式で、llms-full.txt という名前のファイルについての決まりは書かれていません。このブログでは、全記事の要点とよくある質問を1つのファイルで読めるようにするために、自分たちの判断で置いています。

llms.txt を置けば、ChatGPTやClaudeが読んでくれますか?

そうとは言えません。Googleは検索で llms.txt を使っていないと公式に書いています。OpenAI・Anthropic・Perplexity のクローラーの説明ページは robots.txt での制御を案内していて、サイトに置いた llms.txt を読むという公式な対応表明は、2026年10月4日の時点で確認できませんでした。