<?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 · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/crawlers/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/crawlers/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>How to Get Cited by ChatGPT: What My Blog&#39;s Logs Actually Show</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>How to get cited by ChatGPT and other AI assistants (generative engine optimization, GEO): which bots to let in, what to serve them, and why llms.txt didn&#39;t help.</description>
      <content:encoded><![CDATA[<p>To get cited by ChatGPT and other AI assistants, first let their search bots fetch your pages. They are separate from the training crawlers, and blocking one does nothing to the other. Then serve HTML that reads without JavaScript, with structured data naming the author. llms.txt, as far as my logs show, does nothing, because nothing requests it; what matters most is writing so each section states its claim first and a paragraph can be quoted on its own.</p>
<p>Search traffic to this blog has been flat for a couple of years and I&rsquo;ve stopped expecting it to move. So I went looking at where visitors come from instead, and whether the AI assistants are among them. They are, barely. In the twelve months to 16 September 2026, my analytics counted 5,372 sessions here. Twenty-seven of them came from an AI assistant: twelve from chatgpt.com, nine from gemini.google.com, six from Perplexity, and none from claude.ai. Half a percent. The ChatGPT visitors were the good kind, though. They stayed five minutes on average, against 27 seconds for direct traffic and three minutes for Google, because they&rsquo;d already read the answer and wanted the detail.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Sessions by referring source over twelve months; AI assistants highlighted"
    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>Twelve months of sessions by source. The AI assistants are the blue bars: 27 sessions out of 5,372.</figcaption></figure>
</p>
<p>So I&rsquo;ve spent some time on how a small blog gets picked as the source for those answers. This post is what I&rsquo;ve done on this site and what the logs say about it. Where I have numbers I give them, and where I&rsquo;m guessing I say so. Three sentences in the first draft turned out to be wrong once I looked, and I&rsquo;ve left the corrections in.</p>

<h2 class="relative group">Two kinds of bot, and only one of them cites you
    <div id="two-kinds-of-bot-and-only-one-of-them-cites-you" 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="#two-kinds-of-bot-and-only-one-of-them-cites-you" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The first thing to get straight is that &ldquo;AI bots&rdquo; is two different jobs with different user agents, and blocking one does nothing to the other.</p>
<p>Training crawlers pull pages in bulk to build the next model. OpenAI&rsquo;s is GPTBot, Anthropic&rsquo;s is ClaudeBot, Google&rsquo;s is controlled by the Google-Extended token, Apple&rsquo;s by Applebot-Extended, and Common Crawl&rsquo;s CCBot feeds most of the open datasets. Whatever they take ends up as weights, with no link back to you. There&rsquo;s a case for blocking these and I don&rsquo;t think it&rsquo;s a silly one.</p>
<p>Retrieval bots are the other kind. When someone asks ChatGPT or Claude or Perplexity a question, the assistant runs a search, fetches a handful of pages right then, reads them, and writes an answer with citations. Those fetches arrive as OAI-SearchBot and ChatGPT-User, Claude-SearchBot and Claude-User, PerplexityBot and Perplexity-User. Google&rsquo;s AI Overviews use plain Googlebot, so there&rsquo;s no separate switch for them at all. These are the bots that send a visitor back. If the aim is to be cited, these are the ones you&rsquo;re optimising for, and it&rsquo;s worth knowing that blocking GPTBot does nothing to ChatGPT&rsquo;s search results in either direction.</p>
<p>This site&rsquo;s robots.txt is Hugo&rsquo;s default, which is allow everything:</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>I&rsquo;ve thought about splitting it, letting the retrieval bots in and keeping the training ones out, and decided it wasn&rsquo;t worth doing for a blog whose whole purpose is to be read. If I did want that, it would look like the block below. The reason to show it is the same lesson as in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare Served Googlebot an Empty Page</a>: the list has to name bots, not vendors. There is no line you can write that says &ldquo;OpenAI&rdquo;.</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>One thing to check if the site sits behind Cloudflare. Since the middle of 2025, new zones there block AI crawlers by default, and there&rsquo;s a one-click toggle under the bot settings that does the same to existing ones. It blocks both kinds. This blog moved to Cloudflare Pages last week, so that was on my list. If you turned it on because &ldquo;AI bots&rdquo; sounded like something to keep out, you&rsquo;ve also turned off the citations. Cloudflare has since started writing a <code>Content-Signal</code> line into the robots.txt files it manages, with separate <code>search</code>, <code>ai-input</code> and <code>ai-train</code> flags. Nobody is obliged to honour it, but it&rsquo;s the right vocabulary: the same split as above, expressed as a preference rather than a wall.</p>

<h2 class="relative group">What actually knocks on the door
    <div id="what-actually-knocks-on-the-door" 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="#what-actually-knocks-on-the-door" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The move to Cloudflare had a side effect: for the first time I can see every request by user agent. Here is what came in over the four days from 13 to 16 September 2026, after throwing out the fakes, which I&rsquo;ll get to.</p>
<table>
	<thead>
			<tr>
					<th>Bot</th>
					<th>Requests</th>
					<th>What it did</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot (training)</td>
					<td>1,777</td>
					<td>1,380 distinct pages in two days, all five languages, plus 37 reads of robots.txt</td>
			</tr>
			<tr>
					<td>meta-externalagent (training)</td>
					<td>1,748</td>
					<td>The same, a day behind</td>
			</tr>
			<tr>
					<td>OAI-SearchBot (ChatGPT&rsquo;s search index)</td>
					<td>59</td>
					<td>Pages in all five languages, and robots.txt sixteen times</td>
			</tr>
			<tr>
					<td>ChatGPT-User (fetched while answering)</td>
					<td>20</td>
					<td>The Japanese post on cold starts, the self-hosted update server post, the homepage</td>
			</tr>
			<tr>
					<td>Claude-User (fetched while answering)</td>
					<td>8</td>
					<td>The update server post, the contact page, the homepage</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>The iOS debugging post, in English and Vietnamese</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>Two things stand out. The training crawlers read everything, twice, in the time it took the retrieval bots to read a few dozen pages. And the retrieval bots are not fetching at random: ChatGPT-User and Claude-User both went for the update server post, which is the most specific thing on the site, and the Japanese cold-start post got fetched in Japanese.</p>
<p>Now the fakes. Of 20 requests claiming to be GPTBot, 18 came from addresses outside OpenAI&rsquo;s published ranges and asked for <code>/.env</code>, <code>/.git/HEAD</code> and <code>/firebase-adminsdk.json</code>. Fifteen of 36 &ldquo;ChatGPT-User&rdquo; requests were the same kind of thing. Most of the &ldquo;PerplexityBot&rdquo; traffic wanted <code>/id_ed25519</code>. A user agent is a string anyone can send, and vulnerability scanners have noticed that AI bot names get waved through. OpenAI, Anthropic and Perplexity all publish their IP ranges. If you&rsquo;re going to treat a bot specially, in either direction, check the address, not the name. The 20, 59 and 8 above are the requests that came from the right addresses.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Requests claiming an OpenAI user agent, split by whether they came from OpenAI&rsquo;s published IP ranges"
    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>The same user agent strings, split by source address. Most of the &lsquo;GPTBot&rsquo; traffic never came from OpenAI.</figcaption></figure>
</p>

<h2 class="relative group">The page has to be readable without a browser
    <div id="the-page-has-to-be-readable-without-a-browser" 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="#the-page-has-to-be-readable-without-a-browser" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Retrieval bots fetch at answer time, with a timeout, and most of them don&rsquo;t run JavaScript. A page that is an empty div until a bundle loads is a page they don&rsquo;t read. Hugo writes finished HTML, so this blog gets that for free, and it&rsquo;s the main reason I&rsquo;ve never been tempted to move it to anything cleverer. The marketplace I run is the opposite case, a React Native web app, and getting crawlers a real document there took a dynamic-rendering branch and then a caching bug that quietly undid it. The Cloudflare post above is that story.</p>
<p>Speed counts the same way, and here the logs corrected me. I&rsquo;d assumed a static site behind Cloudflare was served from the nearest edge. It isn&rsquo;t. This blog is on Cloudflare Pages, the HTML goes out uncached, and from Seoul the response comes back from Los Angeles in about half a second. That&rsquo;s still fine for a bot with a timeout of several seconds, but it isn&rsquo;t the tens of milliseconds I&rsquo;d have claimed, and I only know because I measured it. An assistant assembling an answer from six pages will drop the one that takes three seconds, and it doesn&rsquo;t tell you it did.</p>

<h2 class="relative group">Write so a paragraph can be lifted out
    <div id="write-so-a-paragraph-can-be-lifted-out" 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="#write-so-a-paragraph-can-be-lifted-out" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The way these systems use a page is not the way a reader does. They split it into chunks, score each chunk against the question, and quote the ones that score. A post that builds up to its point over eight hundred words scores badly on every chunk. A post where each section states its claim in the first sentence and then supports it scores on the section that matches.</p>
<p>That changed how I write here more than any technical thing did. The description in the front matter is now a summary of what the post says, not a hook to get you to read it, because the description is often the only part an assistant shows. Section headings say what the section concludes. Numbers go in the sentence with their units rather than in a chart. Dates are written out, because &ldquo;last week&rdquo; means nothing to a model reading the page in a year. And a post is about one thing.</p>
<p>The other half is having something worth quoting, and here the data is mixed. In the logs, the pages the answer-time bots fetch are the specific ones: cold starts, the update server, iOS debugging. Naver&rsquo;s crawler is called Yeti and Daum&rsquo;s is Daumoa, a restart on my host is a thirty-second window for 504s, Cloudflare&rsquo;s on-the-fly Brotli measured worse than gzip on my bundle. Those facts aren&rsquo;t written down in many places, so a question that needs one of them lands here. But the people who click through from an assistant mostly land on the 2023 and 2024 posts about management, one or two sessions each. The specific posts get read by the machine. The general ones get the click. I don&rsquo;t have a tidy explanation for that and I&rsquo;d rather report it than invent one. The lever, if there is one, is still to write down the specific thing you found out this week that you couldn&rsquo;t find when you searched for it, because that&rsquo;s what the machines are demonstrably reading.</p>

<h2 class="relative group">Say who wrote it, and keep saying the same thing
    <div id="say-who-wrote-it-and-keep-saying-the-same-thing" 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="#say-who-wrote-it-and-keep-saying-the-same-thing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>An assistant deciding whether to cite a page is doing something like what a careful reader does: who wrote this, when, and does it agree with everything else I&rsquo;ve found? The mechanics of that on this site are mostly the theme&rsquo;s doing. Every post carries Article structured data with an author, a published date and a modified date, and the author is a Person with links to the same public profiles. That&rsquo;s a few lines in a template, and it&rsquo;s the difference between &ldquo;a page&rdquo; and &ldquo;a page by a named person with a history&rdquo;.</p>
<p>The part that isn&rsquo;t the theme&rsquo;s doing is consistency. The about page, the llms.txt, my public profiles and the line at the top of every post all describe the same person with the same job history. When something changes, all of them change, or the one I forgot becomes the one that&rsquo;s wrong. That sounds fussy, but one page saying I&rsquo;m an engineering manager and another saying I&rsquo;m freelance is exactly the kind of contradiction that makes a model hedge, or pick someone else.</p>
<p>The modified date has to be honest too. It changes when the content changes and I don&rsquo;t touch it otherwise. Bumping it to look fresh is the kind of thing that works once and then gets your whole domain discounted.</p>

<h2 class="relative group">What llms.txt is, and the fact that nothing reads it
    <div id="what-llmstxt-is-and-the-fact-that-nothing-reads-it" 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="#what-llmstxt-is-and-the-fact-that-nothing-reads-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>llms.txt is a proposal from September 2024 for a Markdown file at the site root that gives a language model a summary of the site and a curated list of links. I added one in February this year. It&rsquo;s a paragraph about who I am, then sections for management, infrastructure, mobile and community, with one line per post: the title, the URL, and a sentence saying what the post concludes. It reads like a table of contents written for someone in a hurry.</p>
<p>The caveat is that nothing reads it. In the four days of logs I have, the only requests for <code>/llms.txt</code> were my own. Common Crawl has captured 76 pages of this site in its August 2026 crawl and never that file. No major assistant has said it reads it. So it is not why anything here gets cited, and I&rsquo;d stop short of recommending it to anyone on those grounds. I keep it for two reasons. It cost an hour, and writing one sentence per post that says what the post actually concludes was worth more than the file, because it showed me which posts didn&rsquo;t conclude anything. It&rsquo;s also the one page on the site that tells a machine what the site is for, and I&rsquo;d rather have that and not need it. It has drifted since, because I write it by hand and forget, which is the argument for generating it from the front matter as a Hugo output format instead. That&rsquo;s on the list.</p>

<h2 class="relative group">Feeds, sitemaps and five languages
    <div id="feeds-sitemaps-and-five-languages" 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="#feeds-sitemaps-and-five-languages" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The boring plumbing still counts. The sitemap is how a crawler finds a new post the day it goes up. RSS is how it learns a post changed. Both are Hugo defaults and I&rsquo;ve never had to think about them. ClaudeBot read the sitemap 36 times in two days, which is how it found 1,380 pages so quickly.</p>
<p>The languages are the part I didn&rsquo;t expect to matter. Everything here is in English, Japanese, Korean, Simplified Chinese and Vietnamese, with alternate links so a crawler knows they&rsquo;re the same post. The crawlers treat them as five sites: ClaudeBot&rsquo;s crawl split almost evenly across the five, and of the 73 post pages Common Crawl took in August, only four were the English ones. Assistants answer in the language they&rsquo;re asked in and lean towards sources in that language, so a Korean question about cold starts can be answered from the Korean page rather than from a translation of the English one done on the fly, and the Japanese page is the one ChatGPT actually fetched. The condition is that the translation reads as if it were written in that language. The pass I did on that earlier this year, with native speakers checking, was for human readers, but it&rsquo;s also what makes the Vietnamese page a source rather than a curiosity.</p>

<h2 class="relative group">What I can measure
    <div id="what-i-can-measure" 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="#what-i-can-measure" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>More than I thought, once I stopped guessing. Cloudflare&rsquo;s free plan gives request logs by user agent and path, one day per query, which is where the bot table came from; referrers need the paid plan. The analytics tool has the referrers, and pulling them through its reporting API rather than squinting at a dashboard is what produced the numbers at the top. Together they show the shape of it. In four days the retrieval bots fetched about ninety pages from OpenAI and Anthropic addresses. In the last month, two people arrived from ChatGPT. Read a lot, cited sometimes, clicked rarely. There&rsquo;s still no way I know of to see the answers that used a page and didn&rsquo;t get clicked. The most useful check is still the manual one: ask the assistant the question your post answers and see whose page it quotes.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Sessions referred by AI assistants per month over thirteen months, showing no trend"
    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>Referrals from AI assistants by month. There is no trend to speak of.</figcaption></figure>
</p>
<p>If I had to keep one thing from all of this, it&rsquo;s the writing rule. The robots.txt, the structured data and the llms.txt are an afternoon&rsquo;s work and then they&rsquo;re done. Writing so each section says its claim first, and writing about the specific thing rather than the general one, is a habit, and it&rsquo;s the habit that decides whether a page is worth quoting at all. The second thing is to measure before you claim. Being trusted by a machine turns out to look a lot like being trusted by a person: say who you are, say what you know, and say it the same way every time.</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: When Cloudflare Served Googlebot an Empty Page</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>How I made a React Native Web app visible to Google and Naver&#39;s crawler with dynamic rendering, and the Cloudflare cache rule that served Googlebot the empty SPA shell.</description>
      <content:encoded><![CDATA[<p>If your React or React Native Web site is a single-page app, Naver&rsquo;s crawler (Yeti) and Bing won&rsquo;t run its JavaScript, and Google only sometimes does. The fix is dynamic rendering: detect crawlers by user-agent and serve them server-rendered HTML with the same content the app would paint. The Cloudflare mistake to avoid is caching that URL for everyone. Cloudflare ignores <code>Vary</code> on HTML, so whichever body arrives first is served to every later visitor, and Googlebot can end up with the empty shell; cache only crawler requests, and purge after you narrow the rule.</p>
<p>My marketplace app is a single-page app built with React Native Web. Every listing page is one script tag and an empty div until JavaScript runs. That is fine for people and fatal for search: Google can sometimes execute the JS, Bing will not, and Naver&rsquo;s crawler — the one that matters most when nearly all your listings are in Korean — certainly will not.</p>
<p>This is the story of making those pages visible, and then of the caching rule that undid it in a way I did not see coming.</p>

<h2 class="relative group">Dynamic rendering, which is not cloaking
    <div id="dynamic-rendering-which-is-not-cloaking" 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="#dynamic-rendering-which-is-not-cloaking" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The fix is old and Google-sanctioned: detect a crawler, and serve it server-rendered HTML containing the same content the SPA would have painted. Not different content. The same content, earlier.</p>
<p>So a listing URL like <code>/listings/view?id=...</code> branches. A browser gets the SPA shell. A crawler gets a real document: a heading, the listing&rsquo;s key facts, an image per photo with alt text, a body excerpt, and links to a handful of related listings so the page is not an orphan.</p>
<p>That last part mattered more than I expected. Before those internal links existed, roughly a thousand listing pages sat with nothing pointing at them, and Search Console showed the crawl settling to seven to eleven requests a day. A crawler that cannot find your pages does not care how good they are.</p>

<h2 class="relative group">The trap: a vendor&rsquo;s name is not their bot&rsquo;s name
    <div id="the-trap-a-vendors-name-is-not-their-bots-name" 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="#the-trap-a-vendors-name-is-not-their-bots-name" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Crawler detection is a user-agent match, and here is where it bit.</p>
<p>Korean platforms crawl under names that look nothing like the company. Naver&rsquo;s crawler is <strong>Yeti</strong>. Daum&rsquo;s is <strong>Daumoa</strong>. Kakao&rsquo;s link scraper is <strong>kakaotalk-scrap</strong>. None of them is the vendor name.</p>
<p>Meanwhile those same vendors ship apps with embedded browsers, and those send a real browser user-agent with the app&rsquo;s name appended. KakaoTalk&rsquo;s in-app browser says <code>KAKAOTALK/10.5.1</code>. Kakao&rsquo;s scraper says <code>kakaotalk-scrap/1.0</code>.</p>
<p>Match on <code>kakao</code> and you match both. Which is exactly what I did, and so every KakaoTalk, Naver-app and Daum-app user who opened a link got the crawler page: no JavaScript, no navigation, no login. A perfectly good document, and completely useless to a human.</p>
<p>The fix is an ordering rule, and it is worth stating as one:</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"># In-app browsers append the app&#39;s name to an ordinary browser 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"># Name the bot, never the vendor: Naver crawls as Yeti, Daum as Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao&#39;s link-preview scraper as 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"># An in-app browser is never a crawler, so that check comes first.</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>In-app browsers are checked first and win. And every crawler token has to name a <em>bot</em>, never a vendor.</p>

<h2 class="relative group">Then I put Cloudflare in front of it
    <div id="then-i-put-cloudflare-in-front-of-it" 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="#then-i-put-cloudflare-in-front-of-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The origin is a single shared-cpu machine in Sydney that sustains twenty to thirty requests a second. Googlebot has about 995 URLs to walk. Every one of them was reaching Sydney, because <code>cf-cache-status</code> read <code>DYNAMIC</code> on all of them: <strong>Cloudflare does not cache HTML unless a rule tells it to.</strong> About 16% of the zone was cached, and none of the part that mattered.</p>
<p>So: a cache rule on the crawler pages. Straightforward. Except that a listing URL does not have one body. It has three.</p>
<ul>
<li>A crawler asking for HTML gets the server-rendered page.</li>
<li>A human gets the SPA shell.</li>
<li>An agent sending <code>Accept: text/markdown</code> gets markdown, built from the database, because converting an empty HTML shell would produce an empty document.</li>
</ul>
<p>The origin says <code>Vary: Accept</code> and <code>Vary</code> on the user-agent would be hopeless anyway. And here is the thing I did not know:</p>
<blockquote><p><strong>Cloudflare ignores <code>Vary</code> on HTML.</strong> All three bodies share one cache key.</p>
</blockquote><p>Whichever body arrives first wins that URL for every later visitor in that edge location, for the hour the origin&rsquo;s <code>s-maxage</code> allows, plus a day of <code>stale-while-revalidate</code>.</p>
<p>I got to observe this in production in both directions:</p>
<ul>
<li>A Naver-app user warmed a listing URL with the crawler page, and plain iPhone Safari then got that page on a HIT. A human staring at a JavaScript-less document.</li>
<li>A human warmed a URL with the SPA shell, and Googlebot got the empty shell. The exact failure the whole SEO effort existed to prevent, now served faster and from the edge.</li>
</ul>

<h2 class="relative group">The rule that fixes it by narrowing
    <div id="the-rule-that-fixes-it-by-narrowing" 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="#the-rule-that-fixes-it-by-narrowing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>You cannot make Cloudflare respect <code>Vary</code> on HTML. What you can do is make sure only one of those three bodies is ever eligible to enter the cache:</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>Match crawler user-agents only, and exclude markdown requests. Now a human request matches no rule at all, so it reads <code>DYNAMIC</code> and goes to the origin as before. Only crawler requests read or write the cache, and the only body that can ever be in there is the crawler one.</p>
<p>Edge TTL is <code>respect_origin</code>, so the lifetime stays owned by the code that renders the page rather than being pinned in a dashboard.</p>
<p>One operational detail that is easy to miss: <strong>narrowing a rule does not evict what is already in the cache.</strong> You have to purge, or you keep serving the poisoned entries you just stopped creating.</p>

<h2 class="relative group">The two lists, and why their errors are not symmetric
    <div id="the-two-lists-and-why-their-errors-are-not-symmetric" 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="#the-two-lists-and-why-their-errors-are-not-symmetric" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The rule needs a list of crawler user-agents, and Django already has one. Two copies of the same knowledge in two systems is usually a smell, but here the interesting part is what happens when they drift, because it is not symmetric:</p>
<ul>
<li>The <strong>bot list in Cloudflare must stay a subset</strong> of the one in Django. A token Cloudflare thinks is a bot but Django does not would cache an SPA shell under the crawler-only key, and then serve that to a real crawler.</li>
<li>The <strong>in-app browser exclusion list must stay a superset</strong> of Django&rsquo;s. Excluding too much only costs you a cache hit.</li>
</ul>
<p>Get either one wrong in the safe direction and you lose some caching. Get the first one wrong in the other direction and you are back to serving Googlebot an empty page. That asymmetry is why the lists use <code>contains</code> rather than a regex, incidentally: <code>matches</code> needs a Business plan, and <code>contains</code> is expressive enough for a list that is allowed to be approximate in a known-safe direction.</p>

<h2 class="relative group">An invariant that fails the build instead of the page
    <div id="an-invariant-that-fails-the-build-instead-of-the-page" 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="#an-invariant-that-fails-the-build-instead-of-the-page" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The crawler pages embed presigned image URLs, valid for seven days. The pages themselves are cached at the edge for an hour, plus a day of stale-while-revalidate.</p>
<p>Those two numbers are related, and if the cache lifetime ever exceeds the image lifetime, the edge starts serving pages whose every photo 403s. Nothing would alert on it; the HTML is fine.</p>
<p>So the code that defines those numbers refuses to import if the relationship breaks:</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"># presigned image URLs</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"># Fail at import time if the edge cache could outlive the image URLs.</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>A constraint between two constants in different files is invisible in code review and obvious at process start. Put it where the process starts.</p>

<h2 class="relative group">Two things Cloudflare did that I had to undo
    <div id="two-things-cloudflare-did-that-i-had-to-undo" 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="#two-things-cloudflare-did-that-i-had-to-undo" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> kept the bundle out of the cache.</strong> <code>django-cors-headers</code> stamps <code>Vary: Origin</code> on every response it touches, and Cloudflare declines to cache anything that varies on more than <code>Accept-Encoding</code>. That quietly excluded the 1.6 MB JavaScript bundle — the single biggest thing on the critical path — from the edge entirely. The fix is a middleware placed first in the list, so it runs last on the way out, rewriting <code>Vary</code> on the static asset paths.</p>
<p><strong>Compression at the edge was worse than compression at the origin.</strong> Letting Cloudflare do Brotli on the fly produced 2,041,742 bytes where the origin&rsquo;s own gzip produced 1,658,115. Twenty-three percent larger, for the privilege of not thinking about it. On-the-fly compression optimises for CPU, not for ratio; precompressed assets from the origin win.</p>

<h2 class="relative group">Telling search engines without waiting to be crawled
    <div id="telling-search-engines-without-waiting-to-be-crawled" 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="#telling-search-engines-without-waiting-to-be-crawled" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Two protocols, and they cover disappointingly little between them.</p>
<p><strong>IndexNow</strong> takes a ping and covers Bing, Yandex and Seznam. Google and Naver do not participate. I ping it in the background whenever a listing is saved, with a short timeout, and skip listings that are sold or closed. A network failure logs a warning and never fails the save that triggered it, which is the only acceptable behaviour for a best-effort side channel.</p>
<p><strong>Google&rsquo;s Indexing API</strong> accepts exactly two schema types, and one of them is <code>JobPosting</code>, which I have. Live jobs send <code>URL_UPDATED</code>, closed ones send <code>URL_DELETED</code>. That deletion is only honest because closed listings also emit <code>noindex</code>, and the code says so, so that whoever removes the noindex finds out what else they broke.</p>
<p>Two things cost me time there. The quota is 200 URLs a day, and a bulk import of listings would fire it hundreds of times — so imported listings are skipped, or one import spends the entire day&rsquo;s budget. And the endpoint is <code>urlNotifications:publish</code>, with a colon. Use a slash and Google returns a plain HTML 404, which looks exactly like a disabled API.</p>

<h2 class="relative group">What the structured data taught me
    <div id="what-the-structured-data-taught-me" 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="#what-the-structured-data-taught-me" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Three rules, each learned by having Search Console complain:</p>
<ol>
<li><strong>Omit what you cannot parse.</strong> Salaries arrive as free text. I used to emit the raw string as <code>baseSalary.value</code>, which is invalid, and flagged every job for a missing unit. Now an unparseable salary is simply absent.</li>
<li><strong>Never fabricate a field to satisfy a schema.</strong> Job ads rarely carry a street address. The temptation is to invent something plausible for <code>PostalAddress</code>. A fabricated address on a job ad is worse than an incomplete one, so only locality and country go in.</li>
<li><strong>Expiry is not optional.</strong> Google drops a <code>JobPosting</code> with no <code>validThrough</code>, so one is derived, with a floor so a stale listing does not claim to have expired yesterday.</li>
</ol>
<p>And a fourth, about sitemaps: <strong>do not claim <code>changefreq: daily</code> for everything.</strong> Asking Googlebot to re-crawl a thousand listings every day reads as an overloaded host, and it responds by cutting the crawl rate for the whole site. Derive it from how recently the thing actually changed.</p>
<p>The sitemap also has a quality floor: forty characters of title and body combined. Search Console had reported around a thousand listings as &ldquo;Crawled – currently not indexed,&rdquo; and one used-goods page&rsquo;s entire unique content was a one-word title and a phone number. Forty characters is deliberately low, because 471 of 484 listings are in Korean and Korean says more per character; an English-tuned threshold would have dropped half the marketplace. Thin listings stay crawlable through the index pages, and re-enter the sitemap automatically when someone edits them.</p>

<h2 class="relative group">What I would take from this
    <div id="what-i-would-take-from-this" 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="#what-i-would-take-from-this" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ul>
<li><strong>Serving different bodies at one URL is a cache-key problem before it is anything else.</strong> Work out what your CDN keys on, and assume it ignores <code>Vary</code> until you have proven otherwise on that exact content type.</li>
<li><strong>When two systems must agree on a list, work out which direction of error is safe</strong> and write that down next to both copies. &ldquo;These can drift, and here is the only way they are allowed to&rdquo; beats pretending they cannot.</li>
<li><strong>Match bots by bot name, never by vendor name.</strong> Especially outside the Anglosphere, where the vendor also ships the browser your users are holding.</li>
<li><strong>Put invariants between distant constants somewhere that fails loudly.</strong> Import time is free and nobody can skip it.</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>
