AI検索を第二戦線にする — llms.txt と構造化データを静的ブログに入れた

  • #llms.txt
  • #構造化データ
  • #Astro
  • #SEO
  • #AI検索
AI検索を第二戦線にする — llms.txt と構造化データを静的ブログに入れた

この記事について

検索エンジンからの流入経路とは別に、LLM(ChatGPTやClaudeなどの対話型AI、あるいはそれらが使う検索基盤)に直接読まれる経路を整備した実装記録。やったことは大きく2つで、(1) サイトの構造・記事一覧をAIクローラー向けに機械可読なテキストで配る llms.txt をAstroのページとして生成する、(2) Organization / WebSite / BlogPosting の構造化データ(JSON-LD)をレイアウトと記事ページに出す。どちらも実装済みで、効果測定はまだこれから。「入れた」と「効いた」は別の話として、この記事では前者だけを扱う。

試した環境

  • Astro(getCollection によるコンテンツコレクションAPIを使用)
  • 静的サイトホスティング(ビルド時生成 + デプロイの構成)

llms.txt をビルド時生成にした理由

llms.txt は llmstxt.org が提案している、AIクローラー向けにサイトの目録をプレーンテキストで配る仕組み。robots.txtsitemap.xml と役割が近いが、対象読者が検索エンジンのクローラーではなくLLM側という違いがある。

実装で最初に決めたのは、手書きファイルにしないことだった。手書きにすると記事が増えるたびに更新する場所が増え、そのうち更新を忘れてllms.txtだけが古い記事一覧を配り続ける状態になる。これは sitemap.xml を静的ファイルとして手書きしていた場合に起きるのと同じ失敗パターンで、このサイトでは sitemap.xml.ts も同じくビルド時に記事データから生成する構成にしていたので、llms.txt も同じ方式に揃えた。

Astroの getCollection で公開状態(status === 'publish')の記事だけを取得し、日付降順に並べ、タイトル・URL・説明文をMarkdownリンク形式で1行ずつ出力する。

const journalEntries = (await getCollection('journal', ({ data }) => data.status === 'publish'))
  .filter((post) => post.data.date)
  .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));

const line = (post) => {
  const slug = (post.slug || post.id).replace(/^\/+|\/+$/g, '');
  const url = `${baseUrl}/journal/${slug}/`;
  const desc = post.data.description ? `: ${post.data.description}` : '';
  return `- [${post.data.title}](${url})${desc}`;
};

出力の先頭には、サイトが何を扱っているか(AI実装・自動化パイプライン・メディア運用・インフラ障害の一次記録で、実際に起きたことと実測データだけを扱う)を短い説明文として置いている。これはllms.txtの慣習で、記事一覧の前にサイトの性質そのものをLLMに伝える1〜3行のサマリを置くことが推奨されている。

Content-Type: text/plain; charset=utf-8 で返すだけの単純なレスポンスなので、実装コストはsitemap.xmlの生成ロジックをほぼ流用できる範囲に収まった。下書き(status: draft)の記事は一覧に出さないようにフィルタしているのも、sitemap.xmlと同じ配慮で、公開前の記事がAIクローラー経由で先に出回ることを避けている。

構造化データ:Organization / WebSite / BlogPosting の3点

もう一つの実装は、JSON-LD形式の構造化データをレイアウトと記事ページの両方に置いたこと。

サイト全体のレイアウトには Organization(サイト運営主体)と WebSite(サイト自体)を1つのJSON-LDブロックにまとめて出す。

{
  '@type': 'Organization',
  '@id': `${baseUrl}/#organization`,
  name: 'ZashStudio',
  url: `${baseUrl}/`,
  description: '...',
  logo: `${baseUrl}/icon-180.png`,
},
{
  '@type': 'WebSite',
  '@id': `${baseUrl}/#website`,
  name: 'ZashStudio',
  url: `${baseUrl}/`,
  inLanguage: 'ja',
  publisher: { '@id': `${baseUrl}/#organization` },
},

記事ページ側では BlogPosting を個別に出し、publisher@id 参照でレイアウト側の Organization を指す形にしている。

const articleJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.data.title,
  description: post.data.description,
  datePublished: post.data.date?.toISOString(),
  image: featuredImageSrc ? `${baseUrl}${featuredImageSrc}` : undefined,
  keywords: post.data.tags?.join(', '),
  inLanguage: 'ja',
  mainEntityOfPage: `${baseUrl}/journal/${slug}/`,
  author: { '@type': 'Organization', name: 'ZashStudio', url: `${baseUrl}/` },
  publisher: { '@id': `${baseUrl}/#organization` },
};

@id で参照をつなぐ設計にしたのは、同じ Organization の情報を記事ごとに重複して埋め込まないため。構造化データはページ数が増えるほど重複が積み上がりやすく、後で運営名やロゴのURLを変更するときに全ページを書き換える羽目になる。@id 参照にしておけば、変更箇所はレイアウト側の1箇所で済む。

descriptionimage などの値は、frontmatterに元データが無い記事では undefined になるよう条件分岐している(スプレッド構文で存在しないキーはJSON-LDに出さない)。空文字列や null をそのまま出すより、キー自体を省略する方が構造化データとしては安全という判断で、これは以前実施した画像フォールバック設計(見出し画像が無い記事に共通画像を当てる一方、frontmatter側の空欄はそのまま残す設計)とも考え方が揃っている。

コスト・制約・まだやっていないこと

  • 実装コストは低かった。どちらもビルド時に既存のコンテンツコレクションデータから生成するだけで、新しいデータソースを増やしていない。運用中に「記事を書いたら忘れずに更新する」作業は発生しない
  • 効果測定はまだこれから。llms.txtやJSON-LDがAI検索経由の流入にどれだけ効いているかは、現時点で数値を持っていない。「入れた」ことと「効いた」ことを混ぜて書かないという方針上、この記事では実装の記録に留める
  • llms.txtの仕様自体がまだ流動的。llmstxt.orgの提案は業界標準として確定したものではなく、各AIベンダーがどこまで読みに来ているかも公開情報が少ない。仕様が変わればパーサ側の期待するフォーマットも変わりうる
  • 構造化データはGoogle側のリッチリザルト表示にも影響するが、これはAI検索とは別の効果経路なので、今回の実装ログでは意図的に区別して書いていない

やってみてわかったこと

  • llms.txtも構造化データも、「配る内容」より「配り続ける仕組み」の方が実装として重要だった。手書きファイルは初回は簡単だが、更新を人力に依存した瞬間に陳腐化が始まる
  • 既存のsitemap.xml生成ロジックがあると、同じビルド時生成パターンを横展開できるので実装コストが下がる。新機能を「ゼロから作る」前に、同じ形の既存実装がないか探す価値があった
  • 構造化データの @id 参照は、ページ数が増えたときの保守コストを先回りして下げる小さな設計判断だが、後から気づいて直すのは面倒なタイプの負債なので最初から入れておいてよかった

更新履歴

  • 2026-08-01: 初出。