タグ
白いカードに、Google Search Consoleのロゴとグレーのロゴタイプ。ロゴは赤・緑・青の高さの異なる角丸バーに黄色い虫眼鏡が重なった形

AstroブログをGoogleとChatGPTに見つけてもらう — robots.txt・JSON-LD・IndexNow

このAstroブログにrobots.txt・JSON-LD・Markdown版・IndexNowを入れるまで。Googleに見つからなかった原因はドメインではなく、llms.txtも解決策ではありませんでした。

目次

はじめに

自分の記事のタイトルをそのままGoogleで検索したところ、何も返ってきませんでした。3言語で21本の記事を公開し、サイトは何か月も動いていたのに、Googleから見ればそのどれも存在していませんでした。

最初に疑ったのはドメインです。ブログはoharu-tech-blog.vercel.appで配信されていて、Vercelのプロジェクト間で共有される無料のサブドメインは、いかにも検索エンジンが黙って評価を下げそうなものに見えました。この推測は仕組みとしては当たっていて、重みの見積もりとしては外れていました。 ドメインの影響は実在しますが、二次的なものでした。理由はもっと地味で、サイトはどの検索エンジンにも登録されておらず、被リンクもなく、/robots.txtは404を返していました。

それを直す作業は、予定していなかった2つ目の問いにつながりました。サイトをGoogleに読めるようにするなら、ChatGPTやClaudeに読めるようにするには何が要るのか。こちらの半分からは、より役に立つ驚きが出てきました。誰もが最初に手を伸ばすファイル、llms.txtは、ほとんど誰にも取得されていません。 記事をAIの回答に載せるのは、実際にはBingでした。

本記事では、その両方を整理します。サイトが見つからなかった本当の原因と、診断が正しくなったあとで検索エンジンと回答エンジンのために作ったものです。

1つのサイト、2つの送信経路サイトは2つの経路に分かれる。GoogleへはSearch Consoleでサイトマップを送信する。ChatGPTへは遠回りで、IndexNowが更新URLをBingのインデックスへ送り、ChatGPTの検索はBingがインデックスした内容を引用する。GoogleはIndexNowに参加していないため、どちらの経路も他方の代わりにはならない。検索エンジン回答エンジンoharu121.com67 pagesSearch Consoleサイトマップを送信Googleオーガニック検索IndexNow更新URLを送信Bingその背後のインデックスChatGPTBingのインデックスを引用GoogleはIndexNowに参加していない。どちらの経路も他方の代わりにはならない。
1つのサイトから伸びる2つの経路。どちらも他方の代わりにはならない。

ドメインは真っ先に疑った容疑者だった

*.vercel.appは非常に多くのプロジェクトが使う共有サブドメインで、その中にはスパムも含まれます。そのため検索エンジンはこれをまとめてレート制限し、優先度を下げます。さらにGoogle Search Consoleではドメインプロパティとして登録できず、URLプレフィックスとしてしか登録できません。これは所有できる範囲が狭く、扱いも面倒です。

つまりドメインは本物の天井でした。しかし天井は床ではありません。インデックスに1件も載っていないことを、それだけでは説明できません。評価の低いホストにあるサイトでも、クロール自体はされます。

決め手は、プロセスを始めるための唯一の行動を、私が一度もしていなかったことです。検索エンジンは新しいサイトを偶然には見つけません。 リンクをたどるか、こちらが渡したサイトマップを読むかのどちらかです。このブログには被リンクがなく、サイトマップも送信していなかったので、Googleには入口がまったくありませんでした。

調査が実際に見つけたもの

推論ではなく実際に動いているサイトを調べると、影響の大きい順に短いリストが出てきました。

見つかったこと 重み
Search ConsoleにもBingにも未登録、被リンクなし 原因
*.vercel.appの共有サブドメイン 実在するが二次的
/robots.txtが404 Sitemap:行なし、AI方針の記載なし
hreflangcanonicalが別のURLを指していた 本物の不具合、偶然発見

技術面はおおむね問題なく、そこが有用な情報でした。全ページが静的HTMLでX-Robots-Tagもなく、sitemap-index.xmlは3ロケールとその代替言語を含めて200を返し、各記事にはすでにBlogPostingのノードがありました。サイトはクロール可能で、誰もクロールに招いていなかっただけです。

robots.txtの404については正確に書く価値があります。過大に読まれやすいからです。robots.txtがないこと自体は何も止めません。 慣習として、ないことは「すべてクロールしてよい」を意味します。失われるのはSitemap:行、つまりサイトマップを持たずに来たクローラーがそれを見つけられる唯一の場所と、AIクローラーに関する記載一式です。

すべての記事が自分自身に2つのURLを掲げていた

調査では、誰も探していなかった不具合が1つ見つかりました。

このリポジトリのlocaleUrl()は、ナビゲーション、hreflang、RSSフィードのためにサイト相対パスを組み立てます。これが末尾のスラッシュなしのパスを返していました。canonicalタグとog:urlAstro.url.pathnameから作られ、静的ビルドではこちらにスラッシュが付きます。ビルドが<dir>/index.htmlを出力するからです。

その結果が、3言語すべての全記事に出ていました。

<link rel="canonical" href="https://oharu121.com/blog/rag-vs-finetuning-kb-search/">
<link rel="alternate" hreflang="en-US" href="https://oharu121.com/blog/rag-vs-finetuning-kb-search">

1つのページに2つのURL。しかもそれを突き合わせろとクローラーに渡していました。 何も失敗しません。どのチェックも捕まえませんでした。この2つの文字列を互いに比較するチェックがなかったからです。

修正はlocaleUrlの内部で正規化し、最後のセグメントにドットを含むものは対象外にします。これで/rss.xmlも引き続き解決します。

export function localeUrl(locale: Locale, path = '/'): string {
const suffix = path.startsWith('/') ? path : `/${path}`;
const isFile = suffix.slice(suffix.lastIndexOf('/') + 1).includes('.');
const normalized = isFile || suffix.endsWith('/') ? suffix : `${suffix}/`;
return `${localePrefix(locale)}${normalized}`;
}

同じ変更の中で直す必要があった副作用が1つあります。scripts/lib/article-lastmod.tsはサイトマップのlastmodをURLをキーにして持っていて、それを補うために自前でスラッシュを足していました。放っておけば全エントリが//になり、しかもこの失敗は静かです。キーが単に一致しなくなり、すべての記事が日付を失うだけです。

11のAIクローラーを名指しで許可する

robots.txtは静的ファイルではなくルートとして生成しているので、Sitemap:行はサイトのオリジンから導出され、ドメイン移行にも耐えます。方針は私が決めました。学習データを集めるものも含めて、AIクローラーはすべて許可する。

理由は、これが到達範囲を広げること自体を目的にした個人ブログだからです。学習コーパスに入ることはコストではなく利点です。Pagefindと日本語IMEの変換についての回答が、その時クローラーがオンラインかどうかに関係なく、このサイトの論拠を運んでくれるのはそのためです。

エージェントの設計判断は、User-agent: *Allow: /でカバーされるにもかかわらず、11すべてを名前で列挙することでした。

User-agent: GPTBot
Allow: /
# OpenAI — the index behind ChatGPT search
User-agent: OAI-SearchBot
Allow: /

冗長に見えますが、そうではありません。学習と検索は別のエージェントで、スイッチも別です。 1つを拒否しても他は拒否されません。Anthropicは3つのボットについてこれを明示しています。ClaudeBotは学習データを集め、Claude-SearchBotは検索インデックスを作り、Claude-Userは人が求めたページを取得します。問いは「AIクローラーを許すか否か」ではありません。学習には使わせずに引用はされたいのか、という問いであり、それはトークン単位でしか表現できません。

このうち2つには知っておくべき注意があります。ChatGPT-UserClaude-Userは特定のページを求めた人の代理で動きます。OpenAI自身のドキュメントは、これらにはrobots.txtが「適用されない場合がある」と書いています。そこでのDisallowは保証ではなく要望です。

ベンダーと用途で見るAIクローラー3つのベンダーと3つの用途のグリッド。OpenAIは学習用にGPTBot、検索インデックス用にOAI-SearchBot、ユーザー起点の取得にChatGPT-Userを動かす。Anthropicも同じ3用途にClaudeBot、Claude-SearchBot、Claude-Userを持つ。PerplexityはPerplexityBotとPerplexity-Userを動かすが、学習専用のエージェントは公開していない。各セルは独立したrobots.txtのスイッチであり、1つを拒否しても他は拒否されない。学習検索インデックスユーザー起点OpenAIGPTBotOAI-SearchBotChatGPT-UserAnthropicClaudeBotClaude-SearchBotClaude-UserPerplexity公開なしPerplexityBotPerplexity-User各セルは独立したrobots.txtのスイッチ。1つを拒否しても他は拒否されない。ユーザー起点のエージェントは依頼した人の代理で動くため、robots.txtが適用されない場合がある。
9つのエージェント、3つのベンダー。スイッチは企業単位ではなくセル単位。

llms.txtは注目を集め、ほとんど読まれていない

AI向けの可視性で真っ先に思いつく手はllms.txtです。決まったパスに置くサイトのMarkdownインデックスです。作る前に、それが実際に機能しているかを調べてほしいとエージェントに頼みました。

証拠は予想より悪いものでした。90日間の計測で、llms.txtへのリクエストはおよそ408件、対してAIボットの訪問は5億回超でした。2026年初頭の時点で、本番でこれを読むと公に表明した主要ベンダーはありません。導入率はサンプルによって大きく振れ、Tranco上位1,000サイトの約8.7%から、固定パネルでは半数超まであります。ただし導入は消費ではありません。

実際に取得しているのはIDEのエージェントとMCPサーバーです。開発者に読まれるブログにとっては本物の読者層なので、llms.txtは既存のRSSルートに合わせてロケールごとに1つ用意しました。得られるものに見合った規模で入れています。 目玉ではなく、安価なインデックスです。

本当に効くレバーは、別の2つでした。

すべての記事をMarkdownでも配信する

HTMLページを渡された回答エンジンは、ナビゲーション、目次、テーマ切り替え、共有ボタンの中から記事を探し出さなければなりません。/blog/<slug>.mdを渡せば、本文がそのまま手に入ります。

そこで各記事は2通りで配信されるようになりました。ページとして1回、同じパスの末尾に.mdを付けたMarkdownとして1回で、後者は記事自身の<head>からリンクされています。公開ロケールページ1つにつき1ファイル、合計67ファイルです。

同じ記事を、ページごと渡す場合と渡さない場合左はHTMLページで、ヘッダー、目次、本文、共有ボタン、フッターが並び、本文は5つの帯のうちの1つにすぎない。右は同じスラッグに.mdを付けたMarkdownの複製で、本文と短いメタデータの見出しだけが入る。テキストはどちらも同一だが、ほぼ記事だけで構成されているのは後者のみ。HTMLページ/blog/slug/ヘッダーとナビゲーション目次記事本文共有ボタンフッターMarkdownの複製/blog/slug.mdタイトル・日付・タグ・元URL記事本文本文は5つの帯のうちの1つ本文と、出典を示す見出しだけ
どちらもテキストは同一。ほぼ記事だけなのは片方だけ。

厄介なのは、このブログの公開記事はすべてコンポーネントをimportしていることです。Figureのimportが26回、<Figure>の使用が109回、そして各記事の_figures/に置かれた1回限りのインラインSVG図版コンポーネントが約40個あります。Astroコンポーネントが劣化して収まる先のMarkdownは存在しません。

エージェントの変換処理はimportを取り除き、<Callout>をラベル付きの引用に変え、図版はそのキャプションと図版名に置き換えます。

*Figure — RagVsFinetuning: RAG keeps facts outside the model; fine-tuning folds behaviour into it.*

ラスター画像は代替テキストを残し、URLを失います。ビルド後のアセットパスは変換処理には分からないコンテンツハッシュで、相対パスは/blog/slug.mdへ移した時点で成立しなくなるからです。404になるリンクはリンクがないより悪いことです。引用したモデルが壊れたURLをそのまま伝えてしまいます。

これは意図的に情報を落としています。 本文、見出し、表、コードフェンスはそのまま通り、引用に値する内容は実質そこにすべてあります。

検証コードはバグと同じ盲点を持っていた

この変換処理にはバグがあり、書いたのはエージェントで、そしてそれが見つかった経緯が本記事でいちばん役に立つ部分です。

タグの書き換えより前にコードフェンスをマスクしなければなりません。さもないと<Figure>を例として引用している記事が、自分のスニペットを書き換えられてしまいます。そのマスク処理自体は存在していました。インラインコードを扱う側が使っていたのがこのパターンです。

`[^`\n]+`

これは区切り文字の間に非バッククォートを1文字以上必要とします。二重のスパンでは失敗しますが、このブログのハウススタイルは二重のスパンを日常的に使います。インラインコードの中にバッククォートを書く方法がそれだからです。

`` `{ foo: 1 }` ``

パターンは代わりにバッククォート・空白・バッククォートに一致し、その段落でそれ以降のバッククォートはすべて1つずれて対応づけられました。

コードスパンの終わりを見つける2つの方法同じソースを2通りの方法で照合する。ラン長による照合は、バッククォート2つの連なりで開き、次に現れるちょうど2つの連なりで閉じるため、スパン全体を捕捉する。一方、単一バッククォートのパターンは区切り文字の間に非バッククォートを1文字以上必要とするため、開始側の対を飛ばし、バッククォート・空白・バッククォートに一致してしまう。その地点より後ろはすべて1つずれて対応づけられ、マスクの外に出たCalloutタグが引用に書き換えられた。ラン長: N個で開き、次のちょうどN個の連なりで閉じる``·`{ foo: 1 }`·``スパン全体がマスクされる単一バッククォート: 区切りの間に非バッククォートが必要``·`{ foo: 1 }`·``誤った対に一致し、以降のバッククォートがすべてずれるマスクから外れ、書き換えられた
同じ9文字を2通りで照合する。後者は最後まで戻れない。

2つのファイルがその損傷を抱えたまま公開されました。content collectionsの記事の日本語版と繁体中国語版で、インラインの`<Callout>text</Callout>`がマスクの外に落ち、コードスパンの内側で本物の引用に書き換えられていました。

`> **Note**
>
> text`はさらに2言語への翻訳に耐えますが、``は翻訳者に…

ここからが残しておく価値のある部分です。私が見せられていた検証結果は「問題なし」でした。同じ単一バッククォートのパターンでコードを除去していたからです。 検証コードは、それが捕まえるために存在していたはずの盲点を、そのまま持っていました。差分を読んだコードレビューのエージェントが、チェック結果を信じずにビルド後の成果物まで戻って確認し、それだけの理由でリリース前に見つかりました。

修正するとすぐに2つ目のバグが入りました。広げたパターンがフェンスのプレースホルダーを飲み込んでしまい、3つのファイルに生のNULバイトが混入しました。どちらも現在はラン長での照合、つまりMarkdown自身が使う規則で扱っています。スパンはN個のバッククォートの連なりで開き、次に現れるちょうどN個の連なりで閉じます。

チェックが緑であることは、コードについてと同じくらい、そのチェック自身についての証拠です。 チェックと検査対象が同じ前提を共有していると、まさにそれが問題になる瞬間に「問題なし」と報告します。

IndexNow、ChatGPTが読むのはBingだから

AI向け可視性でいちばん効く項目は、AI用のファイル形式とは何の関係もありません。BingのインデックスはChatGPTの引用の大半を支えていると報告されています。 つまりBingがクロールしていないページは、スキーマがどれだけ整っていてもChatGPTの回答に引用されません。

IndexNowはそのインデックスへの送信経路です。1つのエンドポイントがBing、Yandex、Seznam、Naverに対応します。Googleは参加していないので、これはSearch Consoleの代わりではなく、もう半分にあたります。

キーはサイトのルートに置くファイルで、中身がそのファイル名と一致します。検証は「取得して照合する」だけです。この仕組みによりキーは本質的に公開されるものなので、リポジトリにコミットしてあり、送信スクリプトは環境変数ではなくpublic/から読み戻します。配信しているファイルと読むファイルが同じであれば、キーとその証明が食い違うことはありません。

どのURLを送るかは追加コストなしで決まりました。buildLastmodMapはサイトマップの記事ごとのlastmodを出すためにすでに存在していて、status: publishedでないものはすでに除外しています。そのためスクリプトはgitの差分ではなく、そのマップを日付の窓で絞り込むだけです。

70 URL(s) for oharu121.com:
IndexNow accepted the batch (200).

CIは本番デプロイのたびにこれを実行します。最初のデプロイでは既定の7日間の窓に入る45件のURLを送信し、202が返りました。受領済みでキー検証は保留、という意味です。あとから全件を送った際は200、つまり検証済みで受理された、が返りました。

oharu121.comへ移す

診断がはっきりした時点でドメインを買いました。最初に選んだのは年3ドルの.clickでしたが、エージェントが反対しました。安価な新奇TLDに直接の順位ペナルティはないものの、スパム登録が非常に多く、そのためインデックスされにくく、他人からリンクされにくくもなります。それはまさに、すでにボトルネックになっていた2点そのものです。私はその主張を受け入れてoharu121.comを買いました。

blog.のサブドメインではなく裸のapexを使うことも私が決めました。サブドメインは親ドメインと権威を自動的には共有しません。どこにも権威がない状態で、それを2つに分けるのは形として間違っています。

DNSはCloudflareのまま、プロキシはオフにしました。グレークラウドはここでは暫定状態ではなく、恒久的に正しい状態です。 Vercelがすでに自前のエッジネットワークでサイトの前面に立っているため、プロキシは2つ目のCDNを足すことになり、デプロイ後に古いHTMLを返しうるからです。さらに重要なのは、Cloudflareが新規に登録されたドメインでAIの学習エージェントと自律エージェントを既定でブロックし始めたことで、これは上に書いた許可リストを黙って無効化します。ボットのルールはプロキシ経由のトラフィックにしか適用されないため、DNSのみにしておけば無効のままです。

oharu121.comのDNSレコード画面に開いたCloudflareのAdd recordダイアログ。TypeはCNAME、Nameの欄にはルートには@を使うよう促す表示があり、Proxy statusのトグルはオフでDNS onlyと表示されている

プロキシはオフ。リクエスト経路に入るCloudflareはVercelの証明書発行を壊し、デプロイで置き換わったことを知りようがないHTMLをキャッシュする。

Vercelがapexに求めたのは、多くのガイドが今も載せている汎用のcname.vercel-dns.comではなく、プロジェクトごとのホスト名を指すCNAMEでした。Cloudflareはゾーンapexでのcnameフラット化を行うため、DNSがそこに実体としてのCNAMEを禁じているにもかかわらず、これで動きます。

レコード伝播前のVercelのDomains一覧。oharu121.comが赤いInvalid Configurationの表示で、308でwww.oharu121.comへリダイレクトしており、その下にDNS RecordsとVercel DNSのタブが並ぶ

レコード伝播前。apexは検証を通らず、wwwサブドメインへリダイレクトしたまま。

レコード伝播後の同じVercelのDomains画面。oharu121.comがValid ConfigurationかつProductionと表示され、www.oharu121.comとoharu-tech-blog.vercel.appはどちらもValid Configurationで、308でoharu121.comへリダイレクトしている

伝播後。apexが本番ドメインで、wwwも旧vercel.appホストもそこへリダイレクトする。

旧ホストは現在、新ホストへパス単位の308を返します。あちらの/blog/x/はこちらの/blog/x/に着きます。これは2つのホストを完全に統合します。 この用途で一般に勧められるcanonicalタグの構成より強い方法です。リダイレクトされたリクエストは、クローラーがheadを読めるページに到達しません。

リポジトリ側での移行は1行でした。サイト上のすべての絶対URLが、1つの定数から解決されるからです。

export const SITE = 'https://oharu121.com';

GoogleとBingに送信する

Search Consoleが受け付けるのはDNSのTXTで検証するドメインプロパティで、apex、www、両方のプロトコル、そして将来のサブドメインまで1つでカバーします。GoogleとCloudflareには、そのレコードを代わりに書いてくれる1回限りの認可フローがあります。

Cloudflareの1回限りの認可画面。oharu121.com向けにGoogleが追加するDNSレコードとして、apexにTXTレコードを置き、内容は伏せられたgoogle-site-verificationの値、TTLは1時間、Proxy statusはDNS onlyと並んでいる

検証レコードはGoogle自身が1回だけ書き、プロキシはオフのままにする。

oharu121.comのGoogle Search Consoleのサイトマップ画面。https://oharu121.com/sitemap-index.xmlについて緑色のSitemap submitted successfullyの確認が表示されている

ドメインプロパティが求めるのは完全なサイトマップURL。拒否されるのは裸のパスのほう。

そして、構造化データの作業が報われたと感じた部分です。GoogleのURL検査はページを利用可能と報告し、見つけたBreadcrumbListのノードも報告します。

公開記事に対するGoogle Search ConsoleのURL検査。URL is available to Google、Page availabilityはPage can be indexed、Enhancements and Experienceの下にBreadcrumbsの行があり1 valid item detectedと表示されている

スキーマを出力した側へ、GoogleがBreadcrumbListのノードを見つけたと返してくる。

Bingの設定はもっと早く終わりました。検証もサイトマップもSearch Consoleからそのまま取り込めるからです。

失敗に見えた2つのメッセージ

送信の過程で、エラーに読めるのに実際はそうではないものが2つあり、どちらも時間を使わせました。

1つ目は私が踏み抜いたものです。Search Consoleはサイトマップを**「Invalid sitemap address. Please enter a valid path to a sitemap in your site.」**として拒否しました。私が受け取っていた助言は、裸のパスsitemap-index.xmlを入力するというものでした。これはURLプレフィックスプロパティでは正しく、その場合は入力欄の横に固定のホストが表示されます。ドメインプロパティは複数のホストにまたがるため、裸のパスは曖昧で拒否されます。求められるのは完全なURLです。

2つ目はBingが赤いバツ印とともに**「Discovered but not crawled」**と表示し、「URL cannot appear on Bing」と添えたものです。

Bing Webmaster ToolsのURL検査。赤いDiscovered but not crawledのステータスにURL cannot appear on Bingという注記が付き、Discoverの段階は緑で2026年8月18日、Crawlの段階は失敗として、Bing Webmaster Guidelinesを勧める一般的なメッセージが出ている

IndexNowが送信した当日に検出されている。クロールの段階は具体的な問題を1つも挙げていない。それは「まだ取得していない」ときの表示。

これを不具合と読むのは自然な誤りです。Discovered on 18 Aug 2026はIndexNowの送信が届いた記録であり、パイプラインが機能したことを意味します。メッセージ本文は一般的なガイドラインを勧めるだけで、具体的な問題を1つも挙げていません。 これはあの画面が「まだ取得していない」と言うときの表示です。直接確かめても直すものはありませんでした。robots.txtDisallow行は1つもなく、Bingbotのユーザーエージェントを付けたリクエストはトップページでも記事でもサイトマップでも200を返します。

正直な読み方は、被リンクのない数日前のドメインにはクロール予算がほぼないということです。IndexNowはBingにURLの存在を伝えられます。Bingにそれを欲しがらせることはできません。

まとめ

作業はきれいに2つに分かれ、どちらの半分も形は同じでした。声高な主張が、必ずしも重みを支えているわけではありませんでした。

  • Googleに載っていなかったのは技術的な不具合ではありません。 サイトは公開初日からクロール可能でした。登録されておらず被リンクもなかっただけで、そのどちらもマークアップでは代替できません。
  • ホストの影響は見た目ほどではありませんでした。 共有の*.vercel.appサブドメインは本物の天井ですが、律速からは程遠い位置にありました。
  • llms.txtはAI向けのレバーではありません。 5億回のAIボット訪問に対しておよそ408回の取得です。IDEエージェントのために入れる価値はあり、その規模で入れる価値があります。
  • AI向けのレバーはBingです。 そのインデックスがChatGPTの検索を支えており、IndexNowがそこへの送信経路だからです。
  • ファイル形式よりMarkdown版が効きます。 ページの装飾を外した本文こそ、回答エンジンが実際に使えるものです。
  • コードと前提を共有したチェックは、肝心なときに通ります。 Markdown変換の検証は、変換自身が使っていた壊れたパターンでコードを除去し、2つの壊れたファイルを前に「問題なし」と報告しました。

残っているのはいちばん時間のかかる項目で、ここまでの投資が実を結ぶかを決める項目でもあります。被リンクです。ここまでの作業はサイトを読めるようにするものです。誰かにリンクさせるものは1つもありません。

参考リンク

この記事をシェア