<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Crawlers · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/crawlers/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/crawlers/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>ChatGPTに引用されるには：小さなブログでやったこととログの数字</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</guid>
      <pubDate>Wed, 16 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>SEO</category>
      <category>Crawlers</category>
      <category>LLM</category>
      <category>Hugo</category>
      <category>Cloudflare</category>
      <category>content strategy</category>
      <category>Writing</category>
      <description>ChatGPTなどのAIアシスタントに引用されるために（GEO）このブログでやったこと。通すべきボット、ブラウザ不要のHTML、著者入りの構造化データ、誰も読んでいなかったllms.txt。</description>
      <content:encoded><![CDATA[<p>ChatGPTなどのAIアシスタントに引用されたいなら、まず回答時にページを取りに来る検索用のボットを通すことです。学習用のクローラーとは別物で、片方をブロックしてももう片方には影響しません。そのうえで、JavaScriptを動かさなくても読めるHTMLを、著者名入りの構造化データ付きで返します。llms.txtは、私のログを見る限り誰も取りに来ていません。いちばん大事なのは、各セクションが最初の一文で主張を言い、段落を単独で引用できるように書くことです。</p>
<p>このブログへの検索流入はここ2年ほど横ばいで、動くことを期待するのはやめました。そこで代わりに、訪問者がどこから来ているのか、その中にAIアシスタントはいるのかを見に行きました。いることはいます。かろうじて、ですが。2026年9月16日までの12か月で、アクセス解析が数えたこのサイトのセッションは5,372。そのうちAIアシスタント経由は27で、内訳はchatgpt.comから12、gemini.google.comから9、Perplexityから6、claude.aiからは0でした。0.5%です。ただ、ChatGPTから来た人は良い訪問者でした。平均の滞在時間は5分。直接アクセスの27秒、Google経由の3分と比べると長い。すでに答えを読んだうえで、詳細を知りたくて来ているからです。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="12か月間の参照元別セッション数。AIアシスタントを強調表示"
    width="2400"
    height="1164"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_d8da7a3f1d1c9190.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_b3dda7d23abf271d.webp"></span><figcaption>参照元別のセッション数、12か月分。青がAIアシスタントで、5,372セッション中27。</figcaption></figure>
</p>
<p>そこで、小さなブログがそういう回答の情報源に選ばれるにはどうすればいいのか、少し時間をかけて調べました。この記事はこのサイトでやったことと、それについてログが何を言っているかの話です。数字があるところは数字を出し、推測でしかないところはそう書きます。最初の草稿にあった3つの文は、実際に調べてみたら間違っていました。その訂正はそのまま残してあります。</p>

<h2 class="relative group">ボットは2種類あって、引用してくれるのは片方だけ
    <div id="ボットは2種類あって引用してくれるのは片方だけ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9c%e3%83%83%e3%83%88%e3%81%af2%e7%a8%ae%e9%a1%9e%e3%81%82%e3%81%a3%e3%81%a6%e5%bc%95%e7%94%a8%e3%81%97%e3%81%a6%e3%81%8f%e3%82%8c%e3%82%8b%e3%81%ae%e3%81%af%e7%89%87%e6%96%b9%e3%81%a0%e3%81%91" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>まずはっきりさせておきたいのは、「AIボット」と呼ばれるものは仕事の違う2種類で、ユーザーエージェントも別、片方をブロックしてももう片方には何の影響もない、ということです。</p>
<p>学習用クローラーは、次のモデルを作るためにページを大量に持っていきます。OpenAIのはGPTBot、AnthropicのはClaudeBot、GoogleのはGoogle-Extendedというトークンで、AppleのはApplebot-Extendedで制御し、Common CrawlのCCBotは公開データセットの大半の元になっています。持っていかれたものは重みになって終わりで、こちらへのリンクは残りません。これらをブロックする理屈はあって、私も馬鹿げた話だとは思いません。</p>
<p>もう一方が検索取得用のボットです。誰かがChatGPTやClaudeやPerplexityに質問すると、アシスタントは検索を走らせ、その場で数ページを取得して読み、出典付きで答えを書きます。その取得はOAI-SearchBotとChatGPT-User、Claude-SearchBotとClaude-User、PerplexityBotとPerplexity-Userとして届きます。GoogleのAI Overviewsは普通のGooglebotを使うので、専用のスイッチはそもそも存在しません。訪問者を送り返してくれるのはこちらのボットです。引用されるのが目的なら最適化する相手はこちらで、GPTBotをブロックしてもChatGPTの検索結果には良くも悪くも何の影響もない、というのは知っておく価値があります。</p>
<p>このサイトのrobots.txtはHugoのデフォルトで、すべて許可です：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /
</span></span><span class="line"><span class="cl">Sitemap: https://jared.lynskey.co.nz/sitemap.xml</span></span></code></pre></div></div>
<p>分けることも考えました。取得用のボットは通し、学習用は締め出す。でも、読まれることがそもそもの目的のブログでやる価値はないと判断しました。もしやるなら下のようになります。あえて載せるのは、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare が Googlebot に空のページを返していた</a>と同じ教訓があるからです。一覧に書くのはボット名であって、会社名ではありません。「OpenAI」と書ける行は存在しないのです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">Disallow: /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /</span></span></code></pre></div></div>
<p>サイトがCloudflareの後ろにあるなら、確認しておくことが1つあります。2025年半ば以降、新しいゾーンはデフォルトでAIクローラーをブロックしますし、ボット設定には既存のゾーンにワンクリックで同じことをするトグルがあります。これは両方の種類をブロックします。このブログは先週Cloudflare Pagesに移したので、私の確認リストにも入っていました。「AIボット」というのが締め出すべきものに聞こえて有効にしたのなら、引用も一緒に切ってしまっています。その後Cloudflareは、管理しているrobots.txtに<code>Content-Signal</code>の行を書き込むようになりました。<code>search</code>、<code>ai-input</code>、<code>ai-train</code>という別々のフラグを持つものです。誰も従う義務はありませんが、語彙としては正しいと思います。上と同じ区分を、壁ではなく希望として表現したものです。</p>

<h2 class="relative group">実際にドアを叩いているのは誰か
    <div id="実際にドアを叩いているのは誰か" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9f%e9%9a%9b%e3%81%ab%e3%83%89%e3%82%a2%e3%82%92%e5%8f%a9%e3%81%84%e3%81%a6%e3%81%84%e3%82%8b%e3%81%ae%e3%81%af%e8%aa%b0%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflareへの移行には副作用がありました。初めて、すべてのリクエストをユーザーエージェント別に見られるようになったのです。2026年9月13日から16日までの4日間に来たものを、偽物を除いたうえで並べたのが下の表です。偽物についてはあとで書きます。</p>
<table>
	<thead>
			<tr>
					<th>ボット</th>
					<th>リクエスト数</th>
					<th>やったこと</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot（学習用）</td>
					<td>1,777</td>
					<td>2日間で1,380の異なるページ、5言語すべて、加えてrobots.txtを37回</td>
			</tr>
			<tr>
					<td>meta-externalagent（学習用）</td>
					<td>1,748</td>
					<td>同じことを、1日遅れで</td>
			</tr>
			<tr>
					<td>OAI-SearchBot（ChatGPTの検索インデックス）</td>
					<td>59</td>
					<td>5言語すべてのページと、robots.txtを16回</td>
			</tr>
			<tr>
					<td>ChatGPT-User（回答中に取得）</td>
					<td>20</td>
					<td>コールドスタートの日本語記事、自前のアップデートサーバーの記事、トップページ</td>
			</tr>
			<tr>
					<td>Claude-User（回答中に取得）</td>
					<td>8</td>
					<td>アップデートサーバーの記事、contactページ、トップページ</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>iOSデバッグの記事、英語版とベトナム語版</td>
			</tr>
			<tr>
					<td>Claude-SearchBot、Perplexity-User</td>
					<td>0</td>
					<td></td>
			</tr>
			<tr>
					<td>Googlebot / Bingbot / Applebot / CCBot</td>
					<td>168 / 71 / 45 / 28</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>目を引くことが2つあります。学習用クローラーは、取得用ボットが数十ページを読むあいだに、すべてを2回読んでいます。そして取得用ボットは手当たり次第に取っているわけではありません。ChatGPT-UserもClaude-Userも、このサイトでいちばん具体的な記事であるアップデートサーバーの記事を取りに来ていて、コールドスタートの記事は日本語版が取得されています。</p>
<p>次に偽物の話です。GPTBotを名乗るリクエスト20件のうち18件は、OpenAIが公開しているIPレンジの外から来ていて、<code>/.env</code>、<code>/.git/HEAD</code>、<code>/firebase-adminsdk.json</code>を要求していました。「ChatGPT-User」を名乗る36件のうち15件も同じ類のものです。「PerplexityBot」のトラフィックは大半が<code>/id_ed25519</code>を欲しがっていました。ユーザーエージェントは誰でも送れるただの文字列で、脆弱性スキャナーはAIボットの名前なら素通しされることに気づいています。OpenAI、Anthropic、PerplexityはいずれもIPレンジを公開しています。ボットを特別扱いするなら、通すにせよ止めるにせよ、名前ではなくアドレスを確認すること。上の20、59、8は、正しいアドレスから来たリクエストの数です。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="OpenAIのユーザーエージェントを名乗るリクエストを、OpenAI公開のIPレンジ由来かどうかで分けたもの"
    width="2400"
    height="722"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_638e8c6286f5c91f.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_11d44262308d7c6c.webp"></span><figcaption>同じユーザーエージェント文字列を送信元アドレスで分けたもの。「GPTBot」を名乗るトラフィックの大半はOpenAIから来ていません。</figcaption></figure>
</p>

<h2 class="relative group">ページはブラウザなしで読めなければならない
    <div id="ページはブラウザなしで読めなければならない" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9a%e3%83%bc%e3%82%b8%e3%81%af%e3%83%96%e3%83%a9%e3%82%a6%e3%82%b6%e3%81%aa%e3%81%97%e3%81%a7%e8%aa%ad%e3%82%81%e3%81%aa%e3%81%91%e3%82%8c%e3%81%b0%e3%81%aa%e3%82%89%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>取得用のボットは回答時にタイムアウト付きで取りに来て、ほとんどはJavaScriptを実行しません。バンドルが読み込まれるまで空のdivでしかないページは、彼らには読めないページです。Hugoは完成したHTMLを書き出すので、このブログはそれをただで手に入れていて、もっと凝ったものに移そうと思ったことが一度もない最大の理由がこれです。私が運営しているマーケットプレイスはその正反対で、React NativeのWebアプリです。あそこでクローラーに本物のドキュメントを渡すには動的レンダリングの分岐が必要で、そのあとキャッシュのバグがそれを静かに台無しにしました。上のCloudflareの記事がその話です。</p>
<p>速度も同じように効きます。そしてここでは、ログに間違いを正されました。Cloudflareの後ろにある静的サイトは、いちばん近いエッジから配信されるものだと思い込んでいました。そうではありませんでした。このブログはCloudflare Pagesにあって、HTMLはキャッシュされずに出ていき、ソウルからだとロサンゼルスから0.5秒ほどで返ってきます。数秒のタイムアウトを持つボットにはそれでも十分ですが、私が言い張っていたであろう数十ミリ秒ではありませんし、それが分かったのは測ったからです。6ページから答えを組み立てているアシスタントは、3秒かかるページを落とします。落としたとは教えてくれません。</p>

<h2 class="relative group">段落をそのまま切り出せるように書く
    <div id="段落をそのまま切り出せるように書く" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%ae%b5%e8%90%bd%e3%82%92%e3%81%9d%e3%81%ae%e3%81%be%e3%81%be%e5%88%87%e3%82%8a%e5%87%ba%e3%81%9b%e3%82%8b%e3%82%88%e3%81%86%e3%81%ab%e6%9b%b8%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>こうしたシステムのページの使い方は、読者の読み方とは違います。ページをチャンクに分け、質問に対して各チャンクを採点し、点の高いものを引用します。800語かけて結論に向かっていく記事は、どのチャンクでも点が低い。各セクションが最初の文で主張を述べてから裏付ける記事は、質問に合ったセクションで点を取ります。</p>
<p>これは、どんな技術的なことよりも、ここでの書き方を変えました。フロントマターのdescriptionは今、読ませるためのフックではなく、記事が何を言っているかの要約です。アシスタントが見せるのはdescriptionだけ、ということがよくあるからです。セクションの見出しは、そのセクションの結論を書く。数字はグラフではなく、単位と一緒に文の中に入れる。日付は書き出す。1年後にページを読むモデルにとって「先週」は何の意味もないからです。そして、1本の記事は1つのことについて書く。</p>
<p>もう半分は、引用する価値のある何かを持っていることで、ここはデータが割れています。ログを見ると、回答時のボットが取得するのは具体的な記事です。コールドスタート、アップデートサーバー、iOSのデバッグ。Naverのクローラーの名前はYetiで、DaumのはDaumoa。私のホスティング先の再起動は504が出る30秒の窓。CloudflareのオンザフライのBrotli圧縮は、私のバンドルではgzipより悪い数字だった。こういう事実は書かれている場所が少ないので、それを必要とする質問はここに着地します。ところが、アシスタントからクリックして来る人の大半が着地するのは、2023年と2024年のマネジメントについての記事で、それぞれ1、2セッションずつ。具体的な記事は機械に読まれ、一般的な記事はクリックされる。これにきれいな説明は持っていませんし、でっち上げるよりは、そのまま報告するほうがいい。効くところがあるとすれば、それでもやはり、今週自分が突き止めた、検索しても見つからなかった具体的なことを書き残すことです。機械が実際に読んでいるのはそれだと、ログが示しているからです。</p>

<h2 class="relative group">誰が書いたかを言い、同じことを言い続ける
    <div id="誰が書いたかを言い同じことを言い続ける" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%aa%b0%e3%81%8c%e6%9b%b8%e3%81%84%e3%81%9f%e3%81%8b%e3%82%92%e8%a8%80%e3%81%84%e5%90%8c%e3%81%98%e3%81%93%e3%81%a8%e3%82%92%e8%a8%80%e3%81%84%e7%b6%9a%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ページを引用するかどうか決めているアシスタントがやっているのは、注意深い読者がやることと似ています。誰がいつ書いたのか、他に見つけたものすべてと矛盾しないか。このサイトでその仕組みを担っているのは、ほとんどテーマです。すべての記事に、著者、公開日、更新日を持つArticleの構造化データが付いていて、著者は同じ公開プロフィールへのリンクを持つPersonです。テンプレートの数行ですが、それが「あるページ」と「経歴のある名前の付いた人物によるページ」の違いです。</p>
<p>テーマがやってくれないのは一貫性です。aboutページ、llms.txt、各種の公開プロフィール、すべての記事の先頭にある1行は、同じ人物を同じ職歴で説明しています。何かが変わればすべて変える。そうしないと、忘れた1つが間違っているものになります。細かい話に聞こえますが、あるページではエンジニアリングマネージャーで別のページではフリーランス、というのはまさに、モデルが言葉を濁したり、別の人を選んだりする種類の矛盾です。</p>
<p>更新日も正直でなければなりません。内容が変わったときに変わり、それ以外では触りません。新しく見せるために日付を上げるのは、一度は効いて、そのあとドメイン全体が割り引かれる類のことです。</p>

<h2 class="relative group">llms.txtとは何か、そして誰も読んでいないという事実
    <div id="llmstxtとは何かそして誰も読んでいないという事実" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#llmstxt%e3%81%a8%e3%81%af%e4%bd%95%e3%81%8b%e3%81%9d%e3%81%97%e3%81%a6%e8%aa%b0%e3%82%82%e8%aa%ad%e3%82%93%e3%81%a7%e3%81%84%e3%81%aa%e3%81%84%e3%81%a8%e3%81%84%e3%81%86%e4%ba%8b%e5%ae%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>llms.txtは2024年9月に提案されたもので、サイトのルートに置くMarkdownファイルに、サイトの要約と厳選したリンクの一覧を書いて言語モデルに渡す、というものです。今年の2月に追加しました。私が誰かについての段落が1つ、そのあとマネジメント、インフラ、モバイル、コミュニティのセクションがあって、記事ごとに1行。タイトル、URL、そして記事の結論を述べた1文です。急いでいる人のために書いた目次のように読めます。</p>
<p>但し書きというのは、誰も読んでいない、ということです。手元にある4日分のログで<code>/llms.txt</code>へのリクエストは、自分のものだけでした。Common Crawlは2026年8月のクロールでこのサイトの76ページを取り込んでいますが、このファイルは一度もありません。主要なアシスタントで読むと表明しているところもない。つまり、ここの何かが引用される理由がこれだということはなく、その根拠で誰かに勧めるのはやめておきます。それでも残しているのには2つ理由があります。かかったのは1時間で、記事ごとに「この記事は実際に何を結論づけているか」を1文で書く作業のほうがファイルそのものより価値がありました。どの記事が何も結論づけていないかが分かったからです。それに、このサイトが何のためにあるかを機械に伝える唯一のページでもあり、必要にならなくても持っておきたい。手で書いていて忘れるので、その後ずれてきていて、それがHugoの出力フォーマットとしてフロントマターから生成すべきだという論拠になっています。やることリストには入っています。</p>

<h2 class="relative group">フィード、サイトマップ、5つの言語
    <div id="フィードサイトマップ5つの言語" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%95%e3%82%a3%e3%83%bc%e3%83%89%e3%82%b5%e3%82%a4%e3%83%88%e3%83%9e%e3%83%83%e3%83%975%e3%81%a4%e3%81%ae%e8%a8%80%e8%aa%9e" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>地味な配管も相変わらず効きます。サイトマップは、クローラーが新しい記事を公開当日に見つける手段です。RSSは、記事が変わったことを知る手段。どちらもHugoのデフォルトで、考えたことがありません。ClaudeBotは2日間でサイトマップを36回読んでいて、1,380ページをあれだけ速く見つけられたのはそのためです。</p>
<p>言語は、重要だと思っていなかった部分です。ここにあるものはすべて英語、日本語、韓国語、簡体字中国語、ベトナム語で、同じ記事だとクローラーに分かるようalternateリンクが付いています。クローラーはこれを5つのサイトとして扱います。ClaudeBotのクロールは5言語にほぼ均等に割れていて、Common Crawlが8月に取り込んだ73の記事ページのうち、英語版は4つだけでした。アシスタントは聞かれた言語で答え、その言語の情報源に寄るので、コールドスタートについての韓国語の質問は、英語ページをその場で翻訳したものではなく韓国語のページから答えられますし、ChatGPTが実際に取得したのは日本語のページでした。条件は、翻訳がその言語で書かれたもののように読めることです。今年の初めにネイティブスピーカーのチェックを入れてやった見直しは人間の読者のためでしたが、ベトナム語のページを珍しいものではなく情報源にしているのもそれです。</p>

<h2 class="relative group">測れること
    <div id="測れること" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%ac%e3%82%8c%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>推測をやめてみたら、思っていたより多くのことが測れました。Cloudflareの無料プランでは、ユーザーエージェントとパス別のリクエストログが1クエリにつき1日分見られて、ボットの表はそこから来ています。リファラを見るには有料プランが要ります。リファラはアクセス解析ツールにあって、ダッシュボードを目を細めて眺めるのではなくレポートAPIで引き出したのが、冒頭の数字です。2つを合わせると全体の形が見えます。4日間で、取得用ボットはOpenAIとAnthropicのアドレスから90ページほどを取得しました。この1か月でChatGPTから来た人は2人。たくさん読まれ、ときどき引用され、めったにクリックされない。ページを使ったけれどクリックされなかった回答を見る方法は、相変わらず私の知るかぎりありません。いちばん役に立つ確認は今でも手作業です。記事が答えている質問をアシスタントに聞いて、誰のページを引用するか見る。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="13か月間の月別AIアシスタント経由セッション数。傾向なし"
    width="2400"
    height="968"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_7d401a84f177509c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_5c308d89ad110ef7.webp"></span><figcaption>AIアシスタント経由の訪問を月別に。傾向と呼べるものはありません。</figcaption></figure>
</p>
<p>この中から1つだけ残すなら、書き方のルールです。robots.txtと構造化データとllms.txtは半日の作業で、終われば終わりです。各セクションが主張を最初に言うように書くこと、一般的なことではなく具体的なことを書くことは習慣で、ページに引用する価値があるかどうかを決めるのはその習慣です。2つ目は、主張する前に測ること。機械に信頼されるというのは、結局のところ人に信頼されるのとよく似ています。自分が誰かを言い、知っていることを言い、毎回同じように言う。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web の SEO：Cloudflare が Googlebot に空のページを返していた</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>React Native Web の SPA を Google と Naver のクローラーに見せるダイナミックレンダリングと、Googlebot に空の SPA シェルを返していた Cloudflare のキャッシュルールの話です。</description>
      <content:encoded><![CDATA[<p>React や React Native Web で作ったシングルページアプリは、そのままだと Naver のクローラー（Yeti）にも Bing にも JavaScript を実行してもらえず、Google も実行するとは限りません。対処法はダイナミックレンダリングで、User-Agent でクローラーを判定し、アプリが描くのと同じ内容のサーバーレンダリング HTML を返します。Cloudflare で避けるべきなのは、その URL を全員向けにキャッシュすることです。Cloudflare は HTML の <code>Vary</code> を無視するので、最初に届いた本文がその後の全員に配られ、Googlebot に空のシェルが返ることもあります。キャッシュするのはクローラーのリクエストだけにして、ルールを絞ったらパージしてください。</p>
<p>私が作っているマーケットプレイスアプリは、React Native Web で作ったシングルページアプリです。すべての出品ページは、JavaScript が動くまでは script タグ1つと空の div でしかありません。人間には問題なくても、検索には致命的です。Google は JS を実行することもありますが、Bing は実行しませんし、出品のほとんどが韓国語のサイトにとって最も重要な Naver のクローラーは、間違いなく実行しません。</p>
<p>これは、そのページを見えるようにした話と、そのあと私の予想しなかった形でそれを台無しにしたキャッシュルールの話です。</p>

<h2 class="relative group">クローキングではないダイナミックレンダリング
    <div id="クローキングではないダイナミックレンダリング" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%af%e3%83%ad%e3%83%bc%e3%82%ad%e3%83%b3%e3%82%b0%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84%e3%83%80%e3%82%a4%e3%83%8a%e3%83%9f%e3%83%83%e3%82%af%e3%83%ac%e3%83%b3%e3%83%80%e3%83%aa%e3%83%b3%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>対処法は古く、Google も認めています。クローラーを判定し、SPA が描いたはずのものと同じ内容を含むサーバーレンダリング HTML を返す。別の内容ではなく、同じ内容を、より早く。</p>
<p>そこで <code>/listings/view?id=...</code> のような出品 URL は分岐します。ブラウザには SPA のシェル。クローラーには本物のドキュメント。見出し、出品の主要な情報、alt 付きの写真ごとの画像、本文の抜粋、そしてこのページが孤立しないよう関連する出品いくつかへのリンク。</p>
<p>最後の項目が思っていたより効きました。内部リンクができる前は、約1,000件の出品ページに何もリンクしておらず、Search Console でクロールが1日7〜11リクエストまで沈んでいくのが見えました。ページを見つけられないクローラーは、その中身がどれだけ良いかを気にしません。</p>

<h2 class="relative group">罠: ベンダー名はそのボットの名前ではない
    <div id="罠-ベンダー名はそのボットの名前ではない" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%bd%a0-%e3%83%99%e3%83%b3%e3%83%80%e3%83%bc%e5%90%8d%e3%81%af%e3%81%9d%e3%81%ae%e3%83%9c%e3%83%83%e3%83%88%e3%81%ae%e5%90%8d%e5%89%8d%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー判定は user-agent のマッチで、ここでハマりました。</p>
<p>韓国のプラットフォームは、会社名とまったく似ていない名前でクロールします。Naver のクローラーは <strong>Yeti</strong>。Daum は <strong>Daumoa</strong>。Kakao のリンクスクレイパーは <strong>kakaotalk-scrap</strong>。3つとも、ベンダー名ではありません。</p>
<p>一方で同じベンダーが、埋め込みブラウザを持つアプリを出しています。そのブラウザは本物のブラウザ UA の末尾にアプリ名を足して送ります。KakaoTalk のアプリ内ブラウザは <code>KAKAOTALK/10.5.1</code>。Kakao のスクレイパーは <code>kakaotalk-scrap/1.0</code>。</p>
<p><code>kakao</code> でマッチすれば両方に当たります。私はまさにそれをやり、その結果リンクを開いた KakaoTalk・Naver アプリ・Daum アプリのユーザー全員がクローラー向けページを受け取りました。JavaScript もナビゲーションもログインもないページを。ドキュメントとしては良くできていて、人間にはまったく使えないものを。</p>
<p>修正は順序のルールで、ルールとして書き出す価値があります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># アプリ内ブラウザは、普通のブラウザ UA の後ろにアプリ名を付ける。</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ベンダー名ではなくボット名で照合する。Naver は Yeti、Daum は Daumoa、</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao のリンクプレビュー用スクレイパーは kakaotalk-scrap。</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># アプリ内ブラウザは決してクローラーではない。だからこの判定が先。</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>アプリ内ブラウザを先に見て、そちらが勝ちます。そしてクローラーのトークンは必ず<strong>ボット</strong>を指す必要があります。ベンダーではなく。</p>

<h2 class="relative group">そして前段に Cloudflare を置いた
    <div id="そして前段に-cloudflare-を置いた" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%9d%e3%81%97%e3%81%a6%e5%89%8d%e6%ae%b5%e3%81%ab-cloudflare-%e3%82%92%e7%bd%ae%e3%81%84%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>オリジンはシドニーの共有 CPU マシン1台で、毎秒20〜30リクエストを支えます。Googlebot が巡回する URL は約995本。その全部がシドニーまで到達していました。すべての URL で <code>cf-cache-status</code> が <code>DYNAMIC</code> だったからです。<strong>Cloudflare はルールが指示しない限り HTML をキャッシュしません。</strong> ゾーンの約16%だけがキャッシュされ、肝心の部分は1つも入っていませんでした。</p>
<p>そこでクローラー向けページにキャッシュルールを。単純な話です。出品 URL の本文が1つではない、という点を除けば。本文は3つあります。</p>
<ul>
<li>HTML を求めるクローラーには、サーバーレンダリングされたページ。</li>
<li>人間には、SPA のシェル。</li>
<li><code>Accept: text/markdown</code> を送るエージェントには、データベースから組み立てた Markdown。空の HTML シェルを変換しても空のドキュメントにしかならないからです。</li>
</ul>
<p>オリジンは <code>Vary: Accept</code> を送り、user-agent に対する <code>Vary</code> はどのみち望み薄です。そしてここに、私が知らなかったことがあります。</p>
<blockquote><p><strong>Cloudflare は HTML では <code>Vary</code> を無視します。</strong> 3つの本文が1つのキャッシュキーを共有します。</p>
</blockquote><p>先に到着した本文がその URL を取り、同じエッジロケーションのその後の訪問者全員に対して、オリジンの <code>s-maxage</code> が許す1時間、さらに <code>stale-while-revalidate</code> の1日ぶん、それが出続けます。</p>
<p>本番で両方向とも観測できました。</p>
<ul>
<li>Naver アプリのユーザーがクローラー向けページで出品 URL を温め、そのあと素の iPhone Safari が HIT でそのページを受け取りました。JavaScript のないドキュメントを眺める人間の出来上がりです。</li>
<li>人間が SPA シェルで URL を温め、Googlebot が空のシェルを受け取りました。SEO の取り組み全体が防ごうとしていたまさにその失敗が、今度はより速く、エッジから提供されたわけです。</li>
</ul>

<h2 class="relative group">絞ることで直すルール
    <div id="絞ることで直すルール" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%b5%9e%e3%82%8b%e3%81%93%e3%81%a8%e3%81%a7%e7%9b%b4%e3%81%99%e3%83%ab%e3%83%bc%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflare に HTML で <code>Vary</code> を尊重させることはできません。できるのは、3つの本文のうち<strong>1つだけ</strong>がキャッシュに入る資格を持つようにすることです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>クローラーの UA だけにマッチさせ、Markdown リクエストは除外します。これで人間のリクエストはどのルールにもマッチせず、以前どおり <code>DYNAMIC</code> を読んでオリジンへ行きます。キャッシュを読み書きするのはクローラーのリクエストだけで、そこに入りうる本文はクローラー向けの1つだけです。</p>
<p>エッジ TTL は <code>respect_origin</code>。寿命はダッシュボードに固定されるのではなく、ページをレンダリングするコードが持ち続けます。</p>
<p>見落としやすい運用上の細部を1つ。<strong>ルールを絞っても、すでにキャッシュにあるものは消えません。</strong> パージが必要です。さもないと、たった今生成を止めたばかりの汚染されたエントリを配り続けることになります。</p>

<h2 class="relative group">2つのリストと、その誤りが対称でない理由
    <div id="2つのリストとその誤りが対称でない理由" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2%e3%81%a4%e3%81%ae%e3%83%aa%e3%82%b9%e3%83%88%e3%81%a8%e3%81%9d%e3%81%ae%e8%aa%a4%e3%82%8a%e3%81%8c%e5%af%be%e7%a7%b0%e3%81%a7%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このルールにはクローラー UA のリストが必要で、Django にはすでに1つあります。同じ知識が2つのシステムに2部あるのは普通なら臭いますが、ここで面白いのは、両者がずれたときに何が起きるかです。対称ではないのです。</p>
<ul>
<li><strong>Cloudflare 側のボットリストは、Django 側の部分集合であり続ける必要があります。</strong> Cloudflare がボットだと思い Django はそう思わないトークンがあると、クローラー専用のキーの下に SPA シェルがキャッシュされ、それが本物のクローラーに出ていきます。</li>
<li><strong>アプリ内ブラウザの除外リストは、Django 側の上位集合である必要があります。</strong> 除外しすぎても、失うのはキャッシュヒットだけです。</li>
</ul>
<p>どちらも安全な方向に間違えれば、キャッシュが少し減るだけ。1つ目を逆方向に間違えれば、また Googlebot に空のページを返すことになります。ちなみにこの非対称性こそ、リストが正規表現ではなく <code>contains</code> を使う理由でもあります。<code>matches</code> は Business プランが必要で、既知の安全な方向に大雑把でよいリストには <code>contains</code> で十分だからです。</p>

<h2 class="relative group">ページではなくビルドを落とす不変条件
    <div id="ページではなくビルドを落とす不変条件" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9a%e3%83%bc%e3%82%b8%e3%81%a7%e3%81%af%e3%81%aa%e3%81%8f%e3%83%93%e3%83%ab%e3%83%89%e3%82%92%e8%90%bd%e3%81%a8%e3%81%99%e4%b8%8d%e5%a4%89%e6%9d%a1%e4%bb%b6" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー向けページには、7日間有効な署名付き画像 URL が埋め込まれています。ページ自体はエッジで1時間、加えて stale-while-revalidate の1日。</p>
<p>この2つの数字は関係していて、キャッシュ寿命が画像の寿命を超えた瞬間、エッジは全写真が403になるページを配り始めます。それを知らせるアラートはありません。HTML 自体は正常なのですから。</p>
<p>そこで、この数値を定義するコードは、関係が壊れていればインポート自体を拒否します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># 署名付き画像 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># エッジキャッシュが画像 URL より長生きしうるなら import 時に失敗させる。</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>別々のファイルにある2つの定数のあいだの制約は、コードレビューでは見えず、プロセス起動時には明白です。プロセスが起動する場所に置きましょう。</p>

<h2 class="relative group">Cloudflare がやったことのうち、取り消す必要があった2つ
    <div id="cloudflare-がやったことのうち取り消す必要があった2つ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare-%e3%81%8c%e3%82%84%e3%81%a3%e3%81%9f%e3%81%93%e3%81%a8%e3%81%ae%e3%81%86%e3%81%a1%e5%8f%96%e3%82%8a%e6%b6%88%e3%81%99%e5%bf%85%e8%a6%81%e3%81%8c%e3%81%82%e3%81%a3%e3%81%9f2%e3%81%a4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> がバンドルをキャッシュから締め出していました。</strong> <code>django-cors-headers</code> は触れたすべてのレスポンスに <code>Vary: Origin</code> を刻み、Cloudflare は <code>Accept-Encoding</code> を超えて vary するレスポンスをキャッシュしません。その結果、クリティカルパス上で最大の 1.6MB の JavaScript バンドルが、まるごと静かにエッジから除外されていました。対処はミドルウェアをリストの先頭に置き、出ていく側で最後に走らせて、静的アセットのパスの <code>Vary</code> を書き換えることです。</p>
<p><strong>エッジでの圧縮はオリジンでの圧縮より悪かった。</strong> Cloudflare にその場で Brotli をやらせると 2,041,742 バイト、オリジン自身の gzip は 1,658,115 バイトでした。何も考えなくていい代わりに23%大きい。オンザフライ圧縮は圧縮率ではなく CPU を最適化します。オリジンからの事前圧縮済みアセットが勝ちます。</p>

<h2 class="relative group">クロールを待たずに検索エンジンへ知らせる
    <div id="クロールを待たずに検索エンジンへ知らせる" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%af%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%92%e5%be%85%e3%81%9f%e3%81%9a%e3%81%ab%e6%a4%9c%e7%b4%a2%e3%82%a8%e3%83%b3%e3%82%b8%e3%83%b3%e3%81%b8%e7%9f%a5%e3%82%89%e3%81%9b%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>プロトコルは2つ、そして2つ合わせてもカバー範囲は残念なものです。</p>
<p><strong>IndexNow</strong> は ping を1つ受け取り、Bing、Yandex、Seznam をカバーします。Google と Naver は参加していません。出品が保存されるたびにバックグラウンドで短いタイムアウト付きの ping を送り、売却済み・終了済みの出品はスキップします。ネットワーク障害は警告を残すだけで、それを引き起こした保存を決して失敗させません。ベストエフォートのサイドチャネルに許される唯一の振る舞いです。</p>
<p><strong>Google の Indexing API</strong> はちょうど2つのスキーマ型しか受け付けず、その1つが私の持っている <code>JobPosting</code> です。生きている求人は <code>URL_UPDATED</code>、終了したものは <code>URL_DELETED</code>。その削除通知が誠実でいられるのは、終了した出品が <code>noindex</code> も出しているからで、コードにそう書いてあります。あとで noindex を外す人が、ほかに何を壊すのかを知れるように。</p>
<p>ここでは2つのことに時間を取られました。クォータは1日200 URL で、出品を一括インポートすると一度に数百回呼ばれます。だからインポートされた出品はスキップします。さもないと1回のインポートで1日分の予算を使い切ります。そしてエンドポイントはコロン入りの <code>urlNotifications:publish</code> です。スラッシュを使うと Google はただの HTML 404 を返し、それは API が無効になっているのと見分けがつきません。</p>

<h2 class="relative group">構造化データが教えてくれたこと
    <div id="構造化データが教えてくれたこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a7%8b%e9%80%a0%e5%8c%96%e3%83%87%e3%83%bc%e3%82%bf%e3%81%8c%e6%95%99%e3%81%88%e3%81%a6%e3%81%8f%e3%82%8c%e3%81%9f%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>3つのルール、どれも Search Console に叱られて覚えました。</p>
<ol>
<li><strong>パースできないものは出さない。</strong> 給与は自由テキストで届きます。以前は生の文字列を <code>baseSalary.value</code> として出していて、それは無効で、すべての求人に単位欠落のフラグが立ちました。今はパースできない給与は単に存在しません。</li>
<li><strong>スキーマを満たすためにフィールドをでっち上げない。</strong> 求人広告に番地が載ることは稀です。<code>PostalAddress</code> にもっともらしいものを入れたくなります。求人広告のでっち上げの住所は、不完全な住所より悪い。だから地域と国だけを入れます。</li>
<li><strong>有効期限は任意ではない。</strong> Google は <code>validThrough</code> のない <code>JobPosting</code> を落とします。だから1つ導出し、古い出品が昨日期限切れになったと主張しないよう下限を置きます。</li>
</ol>
<p>そしてサイトマップについて4つ目。<strong>すべてに <code>changefreq: daily</code> を主張しない。</strong> 1,000件の出品を毎日再クロールしてくれという要求は、過負荷のホストとして読まれ、Google はサイト全体のクロールレートを下げることで応じます。実際にどれだけ最近変わったかから導出しましょう。</p>
<p>サイトマップには品質の下限もあります。タイトルと本文を合わせて40文字。Search Console が約1,000件を「クロール済み – インデックス未登録」と報告していて、ある中古品ページの固有コンテンツは、一語のタイトルと電話番号だけでした。40文字は意図的に低くしてあります。484件の出品のうち471件が韓国語で、韓国語は1文字あたりの情報量が多いからです。英語に合わせた閾値なら、マーケットプレイスの半分が落ちていたでしょう。薄い出品もインデックスページ経由でクロール可能なまま残り、誰かが編集すれば自動的にサイトマップへ戻ります。</p>

<h2 class="relative group">ここから持ち帰るもの
    <div id="ここから持ち帰るもの" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%93%e3%81%93%e3%81%8b%e3%82%89%e6%8c%81%e3%81%a1%e5%b8%b0%e3%82%8b%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li><strong>1つの URL で異なる本文を返すことは、何よりもまずキャッシュキーの問題です。</strong> 使っている CDN が何をキーにしているかを突き止め、そのコンテンツタイプで実証するまでは <code>Vary</code> を無視すると想定してください。</li>
<li><strong>2つのシステムが1つのリストで一致しなければならないなら、どちら向きの誤りが安全かを見極め</strong>、それを両方の写しの隣に書いてください。「ずれてよい、ただし許されるずれ方はこれだけ」のほうが、ずれない前提より優れています。</li>
<li><strong>ボットはベンダー名ではなくボット名でマッチさせること。</strong> 特に英語圏の外では、そのベンダーがユーザーの手元のブラウザも作っています。</li>
<li><strong>離れた定数のあいだの不変条件は、大きな音で失敗する場所に置くこと。</strong> インポート時はタダで、誰もスキップできません。</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
