<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mahiro Hirakawa</title>
    <description>The latest articles on DEV Community by Mahiro Hirakawa (@mahirhir).</description>
    <link>https://dev.to/mahirhir</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3937147%2F66fce836-aa25-43f0-bb5f-632fc17ebf44.jpeg</url>
      <title>DEV Community: Mahiro Hirakawa</title>
      <link>https://dev.to/mahirhir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahirhir"/>
    <language>en</language>
    <item>
      <title>日本のサイト248件中23件、robots.txtは許可でもAIクローラーには403</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Wed, 07 Oct 2026 00:20:09 +0000</pubDate>
      <link>https://dev.to/mahirhir/ri-ben-nosaito248jian-zhong-23jian-robotstxthaxu-ke-demoaikuroraniha403-fdo</link>
      <guid>https://dev.to/mahirhir/ri-ben-nosaito248jian-zhong-23jian-robotstxthaxu-ke-demoaikuroraniha403-fdo</guid>
      <description>&lt;p&gt;日本のサイトで、robots.txtはAIクローラーを許可しているのに、サーバーの手前(CDNやWAF)ではそのクローラーに403を返している、という食い違いがどれくらいあるのかを数えました。2026-10-07の朝、Trancoのリストに載っている日本のサイト300件を1件ずつ確認したところ、ブラウザとして普通にページが返ってきた248件のうち23件で、robots.txtでは許可されているAIクローラーのUser-Agentに403が返りました。&lt;/p&gt;

&lt;p&gt;この記事は、その数字と内訳、層ごとの例、そしてこの測り方でわからないことです。&lt;/p&gt;

&lt;h2&gt;
  
  
  先に限界(2026-10-07時点)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User-Agentを名乗っただけです。&lt;/strong&gt; 送ったのは各社が公開しているUser-Agentの文字列で、送り元は私の普通の回線です。本物のクローラーは各社のIPアドレスから来ます。CDNのボット対策には、IPで本物と確認できたクローラーと、名前を真似ただけのリクエストを別に扱うものがあります。ここでの403は「このUser-Agentを名乗ると断られる」という意味で、「本物のGPTBotが断られている」の証明ではありません。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;場所は1か所です。&lt;/strong&gt; 日本の家庭用回線1本から送りました。国や回線によって応答を変えるサイトもあります。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1回のスナップショットです。&lt;/strong&gt; 2026-10-07 08:19〜09:12(日本時間)に1回ずつ取得し、403になった23件だけを09:13〜09:15にもう一度確認しました。設定は日々変わります。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;トップページだけです。&lt;/strong&gt; 下層ページやAPIは見ていません。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;意図はわかりません。&lt;/strong&gt; 学習用クローラーを止めるのは正当な選択です。ここで数えているのは、robots.txtとサーバーの手前の層が別のことを言っている件数で、どちらが意図どおりかはサイトの方にしかわかりません。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  対象と測り方
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;リスト&lt;/strong&gt;: Trancoのリスト&lt;a href="https://tranco-list.eu/list/56WKN/1000000" rel="noopener noreferrer"&gt;56WKN&lt;/a&gt;(2026-10-05生成、100万件)。これまでの記事と同じリストです。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;対象300件&lt;/strong&gt;: リストの中の&lt;code&gt;.jp&lt;/code&gt;ドメインを順位の高いほうから288件(最下位は15,883位)。それに、日本向けのサイトを&lt;code&gt;.com&lt;/code&gt;で運営していると確認できた12件(nikkei.com、asahi.com、sankei.com、jiji.com、kakaku.com、tabelog.com、cookpad.com、mercari.com、dmm.com、fc2.com、livedoor.com、yodobashi.com)。日本企業でも海外向けの&lt;code&gt;.com&lt;/code&gt;は入れていません。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;リクエスト&lt;/strong&gt;: 1件ずつ順番に、間を0.3秒あけ、タイムアウト10秒、リダイレクトは追いました。robots.txtは自分の名前を名乗るUser-Agent(&lt;code&gt;glovrex-measure/1.0&lt;/code&gt;)で取得。トップページは、ブラウザ(Chrome)→ GPTBot → OAI-SearchBot → ClaudeBot → PerplexityBot → もう一度ブラウザ、の順に6回取得しました。apex(&lt;code&gt;example.jp&lt;/code&gt;)が応答しなかった97件は&lt;code&gt;www.&lt;/code&gt;付きで取り直しています。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google-Extended&lt;/strong&gt;はrobots.txtの中の名前だけで、それを名乗るクローラーは存在しません(取得はGooglebotが行います)。なのでrobots.txtの集計にだけ入れ、サーバーの応答は測っていません。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;「サーバーの手前で断る」の定義&lt;/strong&gt;: robots.txtがそのクローラーにトップページ(&lt;code&gt;/&lt;/code&gt;)を許可している、ブラウザの2回がどちらも200、そのクローラーのUser-Agentには401か403。robots.txtが404などの4xxのときは、RFC 9309にしたがって「制限なし」として扱いました。&lt;/li&gt;
&lt;li&gt;判定に使ったスクリプトは依存なしの&lt;code&gt;.mjs&lt;/code&gt;1本で、robots.txtの判定はRFC 9309の最長一致です。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  数字
&lt;/h2&gt;

&lt;p&gt;300件のうち、ブラウザの2回がどちらも200だったのは248件です。残り52件は、どちらの名前でも応答がない、ブラウザにもチャレンジページ(202など)や403を返す、といったサイトで、比較ができないので外しています。&lt;/p&gt;

&lt;p&gt;robots.txtの取れ方は次のとおりです。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;robots.txt&lt;/th&gt;
&lt;th&gt;件数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;テキストのファイルが返った&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200でHTMLページが返った&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4xx(ファイルなし扱い)&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;応答なし&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5xx&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;robots.txtでトップページを拒否している件数(読めた259件中)は、GPTBot 25、OAI-SearchBot 17、ClaudeBot 21、PerplexityBot 18、Google-Extended 24でした。&lt;/p&gt;

&lt;p&gt;そして本題です。&lt;strong&gt;robots.txtでは許可されているのに、そのUser-Agentに403が返ったサイトは248件中23件&lt;/strong&gt;でした。クローラーごとの件数はこうです。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;クローラー&lt;/th&gt;
&lt;th&gt;robots.txtは許可、サーバーは403&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;23件のうち14件は、4つのUser-Agentすべてに403を返しました。23件は09:13〜09:15の再確認でも、最初に403だったUser-Agentにすべて403のままでした。&lt;/p&gt;

&lt;h3&gt;
  
  
  23件のrobots.txtの状態
&lt;/h3&gt;

&lt;p&gt;「robots.txtが許可」の中身は均一ではありません。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;23件のrobots.txt&lt;/th&gt;
&lt;th&gt;件数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ファイルがあり、そのクローラーを許可している&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots.txt自体が私の計測用User-Agentに403&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots.txtが404&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots.txtの場所でHTMLが返った&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;13件は、robots.txtのファイルに「来ていい」と読める内容が書かれていて、その手前の層が断っている、いちばんはっきりした食い違いです。7件は、robots.txt自体が名乗った計測用のUser-Agentに403でした。規格上は「制限なし」扱いですが、実際にはボットらしいリクエスト全般を手前で止めている可能性が高い形です。&lt;/p&gt;

&lt;h3&gt;
  
  
  断っている対象で3つに分かれる
&lt;/h3&gt;

&lt;p&gt;23件に、AIではないUser-Agentを3つ(名乗った計測用、&lt;code&gt;curl&lt;/code&gt;、Googlebotの文字列)追加で送りました。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;形&lt;/th&gt;
&lt;th&gt;件数&lt;/th&gt;
&lt;th&gt;何が言えるか&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A. 計測用もGooglebotの文字列も200、AIのUser-Agentだけ403&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;AIクローラーの名前を指定したルールがある&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. 計測用は200、Googlebotの文字列も403&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;有名なボットの名前を、確認できないIPから名乗るリクエストを断っている可能性がある。この場合、本物のクローラーは通っているかもしれない&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C. 計測用のUser-Agentも403&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;ブラウザ以外のアクセス全般を断っている&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aの9件では、403だったのはGPTBotが9件、ClaudeBotが6件、PerplexityBotとOAI-SearchBotが2件ずつでした。学習用のクローラー(GPTBot、ClaudeBot)を名指しし、検索用のOAI-SearchBotは通している形が多いということです。robots.txtで学習用だけ止めたい場合とやりたいことは同じですが、robots.txtには書かれていません。&lt;/p&gt;

&lt;h3&gt;
  
  
  ドメインの種類別
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;種類&lt;/th&gt;
&lt;th&gt;普通に読めた件数&lt;/th&gt;
&lt;th&gt;うち食い違い&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.co.jp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.jp&lt;/code&gt;(汎用)&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.go.jp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.ne.jp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;日本向けの&lt;code&gt;.com&lt;/code&gt; 12件&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.ac.jp&lt;/code&gt;、&lt;code&gt;.or.jp&lt;/code&gt;、&lt;code&gt;.ad.jp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;レスポンスヘッダーから見たサーバーの手前の層は、Akamaiが8件、CloudFrontが6件、Cloudflareが1件、ヘッダーからは判別できないもの(nginxやAWSのロードバランサーなど)が8件でした。特定のCDNに偏った現象ではありません。&lt;/p&gt;

&lt;h2&gt;
  
  
  分野ごとの例
&lt;/h2&gt;

&lt;p&gt;以下は測定時点で観測した応答の記録で、設定の良し悪しの評価ではありません。どれも意図した設定である可能性があります。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;交通・旅行&lt;/strong&gt;: jorudan.co.jpは、robots.txtのファイルがあり、GPTBot、ClaudeBot、PerplexityBotに403、OAI-SearchBotには200でした(形A)。jtb.co.jpはGPTBotだけが403で、ほかの3つは200(形A)。jreast.co.jpは、robots.txtも計測用のUser-Agentに403で、ブラウザ以外を広く断っている形(形C)。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;メディア・出版&lt;/strong&gt;: yanmaga.jpはGPTBotだけが403(形A)。trafficnews.jpはGPTBotとClaudeBotが403で、検索用の2つは200(形A、Cloudflare)。kabutan.jpは4つとも403、Googlebotの文字列は200(形A)。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EC・小売&lt;/strong&gt;: creema.jpとtower.jpは、GPTBotとClaudeBotが403、OAI-SearchBotとPerplexityBotが200(形A)。melonbooks.co.jpはrobots.txtが404で、PerplexityBot以外の3つが403(形A)。buyee.jpは4つとも403で、Googlebotの文字列も403(形B)。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;行政&lt;/strong&gt;: moj.go.jpはGPTBotだけが403(形A)。e-gov.go.jpとmeti.go.jpは4つとも403で、Googlebotの文字列も403(形B)。mofa.go.jpは計測用のUser-Agentも403(形C)。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;金融ほか大手企業&lt;/strong&gt;: mizuhobank.co.jpとsuntory.co.jpは、どちらも計測用のUser-Agentも403(形C、Akamai)。smbcnikko.co.jpは4つとも403で、Googlebotの文字列も403(形B)。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  自分のサイトを確かめる
&lt;/h2&gt;

&lt;p&gt;bashで次のように確かめられます(WindowsならGit BashかWSL)。&lt;code&gt;URL&lt;/code&gt;をご自身のサイトに置き換えてください。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com/
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; NAME UA&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}  &lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
browser|Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36
GPTBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)
OAI-SearchBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot
ClaudeBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)
PerplexityBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;robots.txt"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ブラウザが200でボットが403なら、robots.txtの内容と見比べてください。食い違っていれば、勝つのはサーバーの手前の層です。ただし上の形Bのように、CDNが本物のクローラーをIPで見分けている場合は、curlの結果と本物の扱いが違うことがあります。いちばん確かなのはサーバーやCDNのアクセスログで、&lt;code&gt;GPTBot&lt;/code&gt;や&lt;code&gt;ClaudeBot&lt;/code&gt;で絞り込んでステータスを見てください。層ごとの見分け方は&lt;a href="https://glovrex.com/journal/your-cdn-can-overrule-robots-txt-ja" rel="noopener noreferrer"&gt;以前の記事&lt;/a&gt;に書きました。&lt;/p&gt;

&lt;p&gt;403が返っていないことは、AIの回答にサイトが出ることの約束にはなりません。この確認でわかるのは、読みに来たクローラーが入口で止まっていないかどうかだけです。&lt;/p&gt;




&lt;p&gt;1サイトを人の手で見てほしい場合は、&lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;GEO Mini Audit&lt;/a&gt;で、この確認を含むミニ診断を、そのまま貼れる修正案付きでお渡ししています(AIエンジンへの問い合わせや、引用・順位の測定はしません)。&lt;/p&gt;

</description>
      <category>geo</category>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>15 merged IME fixes: the recurring ways Japanese typing breaks web apps</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 22:32:23 +0000</pubDate>
      <link>https://dev.to/mahirhir/15-merged-ime-fixes-the-recurring-ways-japanese-typing-breaks-web-apps-55no</link>
      <guid>https://dev.to/mahirhir/15-merged-ime-fixes-the-recurring-ways-japanese-typing-breaks-web-apps-55no</guid>
      <description>&lt;p&gt;Between late June and early July 2026 I had 15 pull requests merged into open-source projects, all fixing the same family of bug: a keyboard handler that does not know an input method editor (IME) is in the middle of composing text. This post groups them into the bug classes that keep recurring, with a minimal repro and the fix pattern for each. Every fix links to the merged PR so you can read the real diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background in one paragraph
&lt;/h2&gt;

&lt;p&gt;Japanese, Chinese and Korean are typed through an IME. In Japanese you type the reading (&lt;code&gt;toukyou&lt;/code&gt;), the IME shows candidates (&lt;code&gt;東京&lt;/code&gt;), and you press &lt;strong&gt;Enter&lt;/strong&gt; to confirm the candidate. That Enter is a real &lt;code&gt;keydown&lt;/code&gt; event. While composition is active, &lt;code&gt;KeyboardEvent.isComposing&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;), and keydowns handled by the IME carry the special &lt;code&gt;keyCode&lt;/code&gt; 229 (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event" rel="noopener noreferrer"&gt;MDN keydown&lt;/a&gt;, which shows the guard &lt;code&gt;if (event.isComposing || event.keyCode === 229) return;&lt;/code&gt;). Code that reads only &lt;code&gt;event.key === "Enter"&lt;/code&gt; cannot tell "confirm this kanji" from "send this message".&lt;/p&gt;

&lt;h2&gt;
  
  
  Class 1: Enter sends the message mid-composition
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the user sees:&lt;/strong&gt; they type a reading, press Enter to pick the kanji, and the chat sends half a sentence in romaji or unconverted kana.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal repro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix pattern:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;229&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Merged evidence:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/CopilotKit/CopilotKit/pull/5764" rel="noopener noreferrer"&gt;CopilotKit/CopilotKit #5764&lt;/a&gt;: Angular chat input submitted during composition.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/misskey-dev/misskey/pull/17646" rel="noopener noreferrer"&gt;misskey-dev/misskey #17646&lt;/a&gt;: chat sent while the IME was composing; the guard also checks &lt;code&gt;e.key === "Process"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/twentyhq/twenty/pull/22270" rel="noopener noreferrer"&gt;twentyhq/twenty #22270&lt;/a&gt;: chat-thread input and attachment rename input.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/enricoros/big-AGI/pull/1145" rel="noopener noreferrer"&gt;enricoros/big-AGI #1145&lt;/a&gt;: custom instruction field in the Diagrams view.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Class 2: Enter commits a partial value (tags, list items, renames, validated fields)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the user sees:&lt;/strong&gt; a tag chip, list item, card title or file name is saved as the unconverted reading, and the actual kanji they picked ends up as a second stray entry, or is lost. In a validated numeric or formatted field, the half-typed value is committed and validated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal repro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;addTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix pattern:&lt;/strong&gt; the same guard, placed before any commit or validation runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;229&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;addTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React, several of these PRs (onyx, payload, rsuite, LibreChat) read &lt;code&gt;e.nativeEvent.isComposing&lt;/code&gt;, because the handler receives React's synthetic event rather than the DOM &lt;code&gt;KeyboardEvent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merged evidence:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/tusen-ai/naive-ui/pull/8115" rel="noopener noreferrer"&gt;tusen-ai/naive-ui #8115&lt;/a&gt;: &lt;code&gt;n-dynamic-tags&lt;/code&gt; committed a tag on the confirm Enter; the PR includes a test that triggers &lt;code&gt;compositionstart&lt;/code&gt; and then Enter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/onyx-dot-app/onyx/pull/12511" rel="noopener noreferrer"&gt;onyx-dot-app/onyx #12511&lt;/a&gt;: &lt;code&gt;ListFieldInput&lt;/code&gt; added an item on the confirm Enter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/LibreChat-AI/LibreChat/pull/13996" rel="noopener noreferrer"&gt;LibreChat-AI/LibreChat #13996&lt;/a&gt;: prompt name, labels and dynamic tag inputs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/TriliumNext/Trilium/pull/10315" rel="noopener noreferrer"&gt;TriliumNext/Trilium #10315&lt;/a&gt;: board card and column titles.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/siyuan-note/siyuan/pull/17988" rel="noopener noreferrer"&gt;siyuan-note/siyuan #17988&lt;/a&gt;: agent session rename input.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/chakra-ui/zag/pull/3198" rel="noopener noreferrer"&gt;chakra-ui/zag #3198&lt;/a&gt;: the color picker channel input committed a partial value on the confirm Enter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Class 3: Enter selects or searches on unconfirmed text
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the user sees:&lt;/strong&gt; in an autocomplete, picker or search box, the confirm Enter picks whatever option is highlighted, or fires the search with the reading instead of the converted word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal repro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;combo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;selectHighlighted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix pattern:&lt;/strong&gt; guard first, then select. In component libraries the guard belongs in the shared keydown handler so every consumer gets it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merged evidence:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/payloadcms/payload/pull/17138" rel="noopener noreferrer"&gt;payloadcms/payload #17138&lt;/a&gt;: &lt;code&gt;SearchInput&lt;/code&gt; searched on the confirm Enter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/rsuite/rsuite/pull/4585" rel="noopener noreferrer"&gt;rsuite/rsuite #4585&lt;/a&gt;: &lt;code&gt;InputPicker&lt;/code&gt; selected an item; the PR adds a test that &lt;code&gt;onChange&lt;/code&gt; is not called for Enter with &lt;code&gt;isComposing: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuetifyjs/vuetify/pull/22974" rel="noopener noreferrer"&gt;vuetifyjs/vuetify #22974&lt;/a&gt;: &lt;code&gt;VAutocomplete&lt;/code&gt; keydown listener ran during composition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related, no PR of mine behind it:&lt;/strong&gt; search-as-you-type that fires on every &lt;code&gt;input&lt;/code&gt; event also queries the unconfirmed reading while the user is still choosing candidates. &lt;code&gt;InputEvent.isComposing&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; for those events (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/InputEvent/isComposing" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;), so a common pattern is to skip them and run the query from &lt;code&gt;compositionend&lt;/code&gt; instead. Whether that is what your users want is a product choice; I have not tested this pattern across browsers for this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Class 4: keyCode 229 where &lt;code&gt;isComposing&lt;/code&gt; is false
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the user sees:&lt;/strong&gt; the fix from Class 1 works in Chrome, but the bug is still there for some Safari users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; the plate PR below documents the Safari plus Japanese IME case where the confirm Enter arrives with &lt;code&gt;keyCode === 229&lt;/code&gt; and &lt;code&gt;isComposing === false&lt;/code&gt;. A guard that reads only &lt;code&gt;isComposing&lt;/code&gt; lets it through. &lt;strong&gt;I did not reproduce this in Safari myself&lt;/strong&gt;; the claim rests on that PR and on MDN's recommended guard checking both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix pattern:&lt;/strong&gt; always check both, &lt;code&gt;e.isComposing || e.keyCode === 229&lt;/code&gt;. &lt;code&gt;keyCode&lt;/code&gt; is deprecated, but this is the one place it still carries information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merged evidence:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/udecode/plate/pull/5048" rel="noopener noreferrer"&gt;udecode/plate #5048&lt;/a&gt;: the floating link editor submitted on the Safari confirm Enter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/misskey-dev/misskey/pull/17646" rel="noopener noreferrer"&gt;misskey-dev/misskey #17646&lt;/a&gt; (also in Class 1) checks &lt;code&gt;isComposing&lt;/code&gt;, &lt;code&gt;key === "Process"&lt;/code&gt; and &lt;code&gt;keyCode === 229&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Class 5: composition flags that depend on event order
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the user sees:&lt;/strong&gt; the same as Class 1 or 2, but only in one browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal repro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compositionstart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compositionend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it breaks:&lt;/strong&gt; the flag is only correct if the confirm keydown arrives before &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event" rel="noopener noreferrer"&gt;&lt;code&gt;compositionend&lt;/code&gt;&lt;/a&gt;. The &lt;a href="https://www.w3.org/TR/uievents/" rel="noopener noreferrer"&gt;UI Events spec&lt;/a&gt; (section 3.6.5, "Key Events During Composition") says keydown events sent during the composition session must have &lt;code&gt;isComposing&lt;/code&gt; set to true. The plate PR in Class 4 shows Safari delivering the confirm Enter with &lt;code&gt;isComposing&lt;/code&gt; false, which is consistent with that keydown landing outside the session, after &lt;code&gt;compositionend&lt;/code&gt; has already cleared your flag. &lt;strong&gt;I have not measured the event order in Safari or any other browser for this post&lt;/strong&gt;; treat browser-specific ordering as untested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix pattern:&lt;/strong&gt; read the state off the event itself (&lt;code&gt;e.isComposing || e.keyCode === 229&lt;/code&gt;) instead of, or in addition to, a flag you maintain. Where a library keeps an instance flag, as &lt;a href="https://github.com/tusen-ai/naive-ui/pull/8115" rel="noopener noreferrer"&gt;naive-ui #8115&lt;/a&gt; does with its input's composition state, add a test that fires Enter during an active composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bonus class: Escape during composition
&lt;/h2&gt;

&lt;p&gt;Pressing Escape to cancel the IME candidate list also fires &lt;code&gt;keydown&lt;/code&gt; with &lt;code&gt;isComposing: true&lt;/code&gt;. If a modal or drawer closes on Escape, it closes while the user only meant to cancel the candidates.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/Tencent/tdesign-vue-next/pull/6756" rel="noopener noreferrer"&gt;Tencent/tdesign-vue-next #6756&lt;/a&gt;: the drawer closed on the composition-cancel Escape; the fix is &lt;code&gt;if (e.key === "Escape" &amp;amp;&amp;amp; e.isComposing) return;&lt;/code&gt; plus a test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test your own app in 5 minutes
&lt;/h2&gt;

&lt;p&gt;You do not need to read Japanese. Add the Japanese IME in your OS settings (Windows: Settings, Time &amp;amp; language, Language, add Japanese; macOS: System Settings, Keyboard, Input Sources, add Japanese - Romaji).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Switch to Japanese input and focus your chat box. Type &lt;code&gt;toukyou&lt;/code&gt;, press Space to see candidates, press Enter once to confirm. &lt;strong&gt;Expected:&lt;/strong&gt; &lt;code&gt;東京&lt;/code&gt; stays in the box and nothing is sent. Press Enter again; now it sends.&lt;/li&gt;
&lt;li&gt;Repeat in every field where Enter does something: tag inputs, list editors, rename fields, inline title editing, search boxes, autocompletes, comboboxes.&lt;/li&gt;
&lt;li&gt;With candidates open, press Escape. &lt;strong&gt;Expected:&lt;/strong&gt; the candidate list closes and your modal or drawer stays open.&lt;/li&gt;
&lt;li&gt;In a search-as-you-type box, type a reading slowly and watch the network tab. Decide whether queries for the unconverted reading are acceptable.&lt;/li&gt;
&lt;li&gt;Grep your code for &lt;code&gt;key === "Enter"&lt;/code&gt;, &lt;code&gt;key === 'Enter'&lt;/code&gt;, &lt;code&gt;keyCode === 13&lt;/code&gt; and &lt;code&gt;"Escape"&lt;/code&gt;, and check each handler has &lt;code&gt;isComposing || keyCode === 229&lt;/code&gt; before it acts. If your handlers set flags in &lt;code&gt;compositionstart&lt;/code&gt; and &lt;code&gt;compositionend&lt;/code&gt;, check them against step 1 in every browser you support.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can only test on Windows, steps 1 to 4 do not cover the Safari cases in Classes 4 and 5; step 5 does, by reading the code.&lt;/p&gt;




&lt;p&gt;This article and the fixes it links were drafted with Claude Code and reviewed by the author.&lt;/p&gt;

&lt;p&gt;If you would rather have someone else run this check on your app, I offer it at &lt;a href="https://ime-check.vercel.app" rel="noopener noreferrer"&gt;ime-check.vercel.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>i18n</category>
      <category>a11y</category>
    </item>
    <item>
      <title>154 of 529 top homepages have no canonical tag. 15 point to a URL that redirects straight back.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 22:06:00 +0000</pubDate>
      <link>https://dev.to/mahirhir/154-of-529-top-homepages-have-no-canonical-tag-15-point-to-a-url-that-redirects-straight-back-3p28</link>
      <guid>https://dev.to/mahirhir/154-of-529-top-homepages-have-no-canonical-tag-15-point-to-a-url-that-redirects-straight-back-3p28</guid>
      <description>&lt;p&gt;I fetched the homepages of the Tranco top 1,000 once each and read two tags that tell crawlers which URL a page is: &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; and the &lt;code&gt;hreflang&lt;/code&gt; alternates. 529 homepages came back as real pages. &lt;strong&gt;154 of the 529 have no canonical tag at all.&lt;/strong&gt; Of the 375 that do, 317 point at the URL they were served from and 58 point somewhere else. I then fetched those 58 targets: 20 redirect, &lt;strong&gt;15 of them straight back to the page that named them&lt;/strong&gt;, one is a 404 and one is a 403.&lt;/p&gt;

&lt;p&gt;The hreflang side is in better shape than its reputation. 197 homepages declare language versions. I fetched up to three alternates for each and checked whether they link back. 479 of the 522 alternates I fetched do; 15 of the rest didn't answer 200.&lt;/p&gt;

&lt;p&gt;Below: the limits first, the numbers, the mistakes that repeat, and a short checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits first (measured 2026-10-06/07)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One fetch per domain, from Japan.&lt;/strong&gt; Homepages on 2026-10-06 between 21:30 and 21:54 UTC, then the canonical targets and alternates between 21:55 and 22:03 UTC, one request at a time, 10 second timeout, redirects followed, no JavaScript. Several sites serve a Japanese page to a Japanese IP, and their canonical changes with it. From another country you'd see other URLs for some of these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bot user agent.&lt;/strong&gt; I identified myself (&lt;code&gt;glovrex-measure/1.0&lt;/code&gt;). 96 domains answered 403 to that, which is fewer pages than my earlier runs with a browser user agent. Those sites are unknown here, not "no canonical".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List and set:&lt;/strong&gt; Tranco list &lt;a href="https://tranco-list.eu/list/56WKN/1000000" rel="noopener noreferrer"&gt;56WKN&lt;/a&gt;, top 1,000. Many entries are CDN, API or tracking domains with no homepage: 314 gave no HTTP answer (DNS, TLS, timeout), 148 answered with something other than 200, 9 answered 200 with no HTML head or an "Access Denied" page. n = 529 below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homepage only, &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; only.&lt;/strong&gt; I read canonical and hreflang from the HTML head and from the HTTP &lt;code&gt;Link&lt;/code&gt; header. hreflang in an XML sitemap isn't covered, so a site can be fine there and still show up as "no hreflang" here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return links are sampled.&lt;/strong&gt; At most three alternates per site, skipping &lt;code&gt;x-default&lt;/code&gt; and the page itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I don't know how AI crawlers use these tags.&lt;/strong&gt; Google and Bing document canonical and hreflang. OpenAI, Anthropic and Perplexity don't say whether their crawlers read them. Answer engines that build on a search index inherit whatever the index decided.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The canonical numbers
&lt;/h2&gt;

&lt;p&gt;n = 529 homepages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Homepages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No canonical (head or &lt;code&gt;Link&lt;/code&gt; header)&lt;/td&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical = the URL served&lt;/td&gt;
&lt;td&gt;317&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical points elsewhere&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More than one canonical tag&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two canonicals that disagree&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relative canonical (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/us/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;og:url&lt;/code&gt; present&lt;/td&gt;
&lt;td&gt;291&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;og:url&lt;/code&gt; disagrees with canonical&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 58 that point elsewhere, by what differs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;th&gt;Homepages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only the query string&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A different host&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A different path&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only a trailing slash&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only &lt;code&gt;www.&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The query-string group is mostly fine. bing.com, ya.ru and youtu.be redirect with a tracking or redirect parameter and the canonical drops it, which is what a canonical is for.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the 58 targets answered
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Canonical target&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200, and declares itself canonical&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirects to another URL&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No answer&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, no canonical of its own&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, canonical points on again&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 20 redirects are the interesting group. &lt;strong&gt;15 of them redirect to exactly the URL that declared them&lt;/strong&gt;, 3 more come back to it with a fresh query parameter (ya.ru twice, salesforce.com), and 2 go somewhere new (amp.dev, hihonorcloud.com). The loops look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slash, both ways.&lt;/strong&gt; huawei.com serves &lt;code&gt;/en/&lt;/code&gt; and names &lt;code&gt;/en&lt;/code&gt; as canonical, and &lt;code&gt;/en&lt;/code&gt; redirects to &lt;code&gt;/en/&lt;/code&gt;. agora.io does the same. ubisoft.com does it in the other direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;www.&lt;/code&gt; both ways.&lt;/strong&gt; go.com is served at &lt;code&gt;www.go.com&lt;/code&gt; with canonical &lt;code&gt;go.com&lt;/code&gt;, which redirects to &lt;code&gt;www.go.com&lt;/code&gt;. bugsnag.com does the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile and edition hosts.&lt;/strong&gt; vk.com sends my user agent to &lt;code&gt;m.vk.com&lt;/code&gt;, whose canonical is &lt;code&gt;vk.com&lt;/code&gt;, which redirects back to &lt;code&gt;m.vk.com&lt;/code&gt;. cnn.com lands on &lt;code&gt;edition.cnn.com&lt;/code&gt; with canonical &lt;code&gt;www.cnn.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A path the server won't serve.&lt;/strong&gt; epa.gov names &lt;code&gt;/home&lt;/code&gt;, which redirects to &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A canonical that redirects isn't fatal. Google calls &lt;code&gt;rel=canonical&lt;/code&gt; and redirects both signals and picks one. But here the two signals point in opposite directions, so the site has handed the choice to the crawler.&lt;/p&gt;

&lt;p&gt;The two hard failures are a stanford.edu homepage whose canonical is &lt;code&gt;https://www.stanford.edu/home&lt;/code&gt;, which answers 404 (rechecked with curl, same result), and synology.com, whose canonical is &lt;code&gt;https://www.synology.com//&lt;/code&gt; with a double slash, which answers 403.&lt;/p&gt;

&lt;h3&gt;
  
  
  The canonical depends on where you are
&lt;/h3&gt;

&lt;p&gt;Six of the "different host" and "different path" cases, from four companies, point at a regional page when seen from Japan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;facebook.com (and fb.com, fbcdn.net): canonical &lt;code&gt;https://ja-jp.facebook.com/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;android.com: canonical &lt;code&gt;https://www.android.com/intl/ja_jp/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;forbes.com: canonical &lt;code&gt;https://www.forbes.com/home_asia/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;intel.com: served from &lt;code&gt;www.intel.co.jp&lt;/code&gt;, canonical on &lt;code&gt;www.intel.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This can be intentional. It also means a crawler in the US and a crawler in Japan are told different things about the same URL, and a homepage can quietly be declared a copy of a regional page. If you serve by location, check the canonical from more than one country.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;og:url&lt;/code&gt; versus canonical
&lt;/h3&gt;

&lt;p&gt;19 of 291 disagree. Most are small (instagram.com has canonical &lt;code&gt;www.instagram.com&lt;/code&gt; and &lt;code&gt;og:url&lt;/code&gt; &lt;code&gt;instagram.com&lt;/code&gt;), but two are real: roblox.com's homepage says &lt;code&gt;og:url&lt;/code&gt; is &lt;code&gt;/CreateAccount&lt;/code&gt;, and gandi.net's canonical is &lt;code&gt;/en-US&lt;/code&gt; while &lt;code&gt;og:url&lt;/code&gt; is &lt;code&gt;/en&lt;/code&gt;. Link previews and some crawlers read &lt;code&gt;og:url&lt;/code&gt;, so it's worth keeping it in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hreflang numbers
&lt;/h2&gt;

&lt;p&gt;n = 197 homepages with hreflang (37% of 529). Median 13 alternates, maximum 270.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Homepages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Includes &lt;code&gt;x-default&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lists itself among its alternates&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doesn't list itself&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hreflang on a page whose canonical points elsewhere&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A code that isn't a valid language or region&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A 3-letter language code (&lt;code&gt;fil&lt;/code&gt;, &lt;code&gt;ceb&lt;/code&gt;, &lt;code&gt;skr&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A withdrawn code (&lt;code&gt;iw&lt;/code&gt;, &lt;code&gt;in&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same code, two different URLs&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relative or non-HTTP URL&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 7 invalid ones: weebly.com and amp.dev use underscores (&lt;code&gt;en_GB&lt;/code&gt;, &lt;code&gt;pt_BR&lt;/code&gt;); viber.com uses &lt;code&gt;ua&lt;/code&gt; and &lt;code&gt;gr&lt;/code&gt;, which are country codes, not languages; aliyun.com uses &lt;code&gt;tc&lt;/code&gt;; hp.com writes country first (&lt;code&gt;py-es&lt;/code&gt;, &lt;code&gt;us-en&lt;/code&gt;) plus internal names like &lt;code&gt;lamerica_nsc_carib-en&lt;/code&gt;; ezviz uses region &lt;code&gt;eu&lt;/code&gt;; shein.com uses &lt;code&gt;hr-eur&lt;/code&gt; and &lt;code&gt;zh-tw-sg&lt;/code&gt;. Google's &lt;a href="https://developers.google.com/search/docs/specialty/international/localized-versions" rel="noopener noreferrer"&gt;localized versions guide&lt;/a&gt; asks for an ISO 639-1 language with an optional ISO 3166-1 region. I count &lt;code&gt;fil&lt;/code&gt; or &lt;code&gt;iw&lt;/code&gt; separately because they are valid language tags, but &lt;code&gt;fil&lt;/code&gt; has no ISO 639-1 code and &lt;code&gt;iw&lt;/code&gt; was replaced by &lt;code&gt;he&lt;/code&gt; in 1989.&lt;/p&gt;

&lt;p&gt;"hreflang on a page whose canonical points elsewhere" is worth a look on your own site: the page says it is a copy of another URL and, at the same time, offers its own set of language versions. On the top 1,000 most of these are the harmless query-string cases above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Return links
&lt;/h3&gt;

&lt;p&gt;191 sites had at least one alternate I could fetch. 522 alternates in total.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alternate&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200 and links back&lt;/td&gt;
&lt;td&gt;479&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, has hreflang, but not back to the homepage&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, no hreflang at all&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not 200 (403, 404, 401, no answer)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;174 sites passed on every alternate I checked, 13 had at least one missing return link, and 4 couldn't be checked.&lt;/p&gt;

&lt;p&gt;Most of the 13 have the same cause: the homepage isn't part of its own set. atlassian.com, cisco.com, ea.com and name.com list &lt;code&gt;/ja/&lt;/code&gt;, &lt;code&gt;/de-de/&lt;/code&gt; or &lt;code&gt;/site/us/en/index.html&lt;/code&gt; from the root URL but not the root URL itself, so the alternates have nothing to link back to. That's the "doesn't list itself" row above. The 8 alternates with no hreflang at all were on aliexpress.com, squarespace.com, ikea.com and blackberry.com, whose localised pages carried no hreflang in the HTML I received.&lt;/p&gt;

&lt;p&gt;7 of the 15 non-200 alternates are on cloudflare.com: workers.dev and pages.dev land on product pages whose &lt;code&gt;/es-es/&lt;/code&gt;, &lt;code&gt;/fr-fr/&lt;/code&gt; and &lt;code&gt;/de-de/&lt;/code&gt; versions answered 404 to me, and the &lt;code&gt;/de-de/&lt;/code&gt; homepage answered 403 to my user agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist that catches all of the above
&lt;/h2&gt;

&lt;p&gt;For a homepage, and any page you care about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One canonical, absolute, in the head.&lt;/strong&gt; Not two, not relative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch the canonical URL with redirects off.&lt;/strong&gt; It should answer 200, and its own canonical should be itself. This one check finds the trailing-slash and &lt;code&gt;www.&lt;/code&gt; loops, the 404 and the 403.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you serve by location, fetch from two countries&lt;/strong&gt; and compare the canonical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hreflang: list the page itself,&lt;/strong&gt; add &lt;code&gt;x-default&lt;/code&gt;, use &lt;code&gt;language&lt;/code&gt; or &lt;code&gt;language-REGION&lt;/code&gt; with a hyphen, and point only at canonical URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch every alternate&lt;/strong&gt; and check it answers 200 and lists the others, including this page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make &lt;code&gt;og:url&lt;/code&gt; the same as the canonical.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In shell, step 2 is one line per page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{redirect_url}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;link[^&amp;gt;]*rel="canonical"[^&amp;gt;]*&amp;gt;'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'href="[^"]*"'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'"'&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200&lt;/code&gt; and an empty redirect is what you want.&lt;/p&gt;

&lt;p&gt;The probe, follow and analysis scripts, the raw JSON lines per domain, and the summary file that every number above comes from are kept with the date above.&lt;/p&gt;

&lt;p&gt;If you'd like one site checked by hand, the paid &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;GEO Mini Audit&lt;/a&gt; covers crawler access and machine-readability for one page you give (robots.txt per AI crawler, what your server answers each one, canonical, hreflang, meta robots and structured data) and adds paste-ready fixes (a corrected robots.txt, CDN/WAF steps for bot 403s, meta and canonical fixes, JSON-LD and llms.txt skeletons), all marked review before deploying. It queries no AI engine and measures no citations or rankings.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>html</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Your IMAP script stopped logging in: XOAUTH2 for Gmail and Microsoft 365</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:35:06 +0000</pubDate>
      <link>https://dev.to/mahirhir/your-imap-script-stopped-logging-in-xoauth2-for-gmail-and-microsoft-365-5480</link>
      <guid>https://dev.to/mahirhir/your-imap-script-stopped-logging-in-xoauth2-for-gmail-and-microsoft-365-5480</guid>
      <description>&lt;p&gt;If you have a small Python script that logs in to Gmail or Microsoft 365 over IMAP with a username and password, there's a good chance it already gets &lt;code&gt;AUTHENTICATE failed&lt;/code&gt; or &lt;code&gt;Invalid credentials&lt;/code&gt; back, even with the right password. Both providers have moved IMAP to OAuth 2.0, and the IMAP side of that is a SASL mechanism called &lt;code&gt;XOAUTH2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This post covers why the password path closed, what &lt;code&gt;XOAUTH2&lt;/code&gt; actually sends over the wire, where the access token comes from, and how long it lives. The worked example uses &lt;a href="https://github.com/mahirhir/mail-rule-digest" rel="noopener noreferrer"&gt;mail-rule-digest&lt;/a&gt;, a stdlib-only Python CLI I wrote that filters a mailbox with a TOML rule file and writes a Markdown digest. Every command and output below comes from a real run on 2026-10-07 against a fake IMAP server on localhost, with a fake token. No real mailbox or credential was involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the password stopped working
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Microsoft 365 / Exchange Online.&lt;/strong&gt; Microsoft turned off Basic authentication for IMAP and POP in Exchange Online. The deprecation page says "All other cloud environments were subject to the October 1, 2022, date" and "Basic authentication is now disabled in all tenants" (&lt;a href="https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online" rel="noopener noreferrer"&gt;learn.microsoft.com&lt;/a&gt;). For personal Outlook.com accounts the date was "September 16th, 2024 - Basic Authentication no longer available to access any Outlook account" (&lt;a href="https://support.microsoft.com/en-us/office/modern-authentication-methods-now-needed-to-continue-syncing-outlook-email-in-non-microsoft-email-apps-c5d65390-9676-4763-b41f-d7986499a90d" rel="noopener noreferrer"&gt;support.microsoft.com&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google.&lt;/strong&gt; Google wound down "less secure apps", meaning third-party apps that sign in with only a username and password. The Workspace Updates post first announced September 30, 2024, and its latest update reads: "Less Secure Apps will no longer be supported as of May 1, 2025" (&lt;a href="https://workspaceupdates.googleblog.com/2023/09/winding-down-google-sync-and-less-secure-apps-support.html" rel="noopener noreferrer"&gt;workspaceupdates.googleblog.com&lt;/a&gt;). Google's pages don't all agree on the final date; the admin help page that the old support link redirects to still says March 14, 2025. Either way, it's in the past.&lt;/p&gt;

&lt;p&gt;One exception remains for personal Google accounts: app passwords. Google's help page says "App passwords can only be used with accounts that have 2-Step Verification turned on", calls them not recommended, and excludes work and school accounts (&lt;a href="https://support.google.com/accounts/answer/185833" rel="noopener noreferrer"&gt;support.google.com&lt;/a&gt;). If you control a personal account and just need a script working tonight, that's the short path. For Workspace or Microsoft 365 mailboxes, OAuth is the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What XOAUTH2 sends
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;XOAUTH2&lt;/code&gt; is a SASL mechanism (SASL is &lt;a href="https://www.rfc-editor.org/rfc/rfc4422" rel="noopener noreferrer"&gt;RFC 4422&lt;/a&gt;; IMAP carries it with the &lt;code&gt;AUTHENTICATE&lt;/code&gt; command, &lt;a href="https://www.rfc-editor.org/rfc/rfc9051" rel="noopener noreferrer"&gt;RFC 9051 section 6.2.2&lt;/a&gt;). The client sends one string, base64-encoded. Google documents it as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;base64("user=" {User} "^Aauth=Bearer " {Access Token} "^A^A")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;^A&lt;/code&gt; is the byte &lt;code&gt;0x01&lt;/code&gt; (&lt;a href="https://developers.google.com/gmail/imap/xoauth2-protocol" rel="noopener noreferrer"&gt;developers.google.com&lt;/a&gt;). Microsoft's page has the same format: &lt;code&gt;base64("user=" + userName + "^Aauth=Bearer " + accessToken + "^A^A")&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth" rel="noopener noreferrer"&gt;learn.microsoft.com&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;That's the whole credential. There is no signature and no nonce: the bearer token is the secret, so anything that logs this line logs the token. In Python it's one line, and &lt;code&gt;imaplib&lt;/code&gt; does the base64 step for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;xoauth2_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\x01&lt;/span&gt;&lt;span class="s"&gt;auth=Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\x01\x01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;XOAUTH2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;xoauth2_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what my fake server received during the demo below, decoded with &lt;code&gt;^A&lt;/code&gt; for &lt;code&gt;0x01&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server: C: JGLC1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRkFLRS1MT0NBTC1ERU1PLVRPS0VOAQE=
server:    decoded: user=demo@example.com^Aauth=Bearer ya29.FAKE-LOCAL-DEMO-TOKEN^A^A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure path has one quirk worth knowing. Gmail doesn't answer a bad token with &lt;code&gt;NO&lt;/code&gt; straight away. It sends another &lt;code&gt;+&lt;/code&gt; continuation carrying a base64 JSON error, and "The client sends an empty response ("\r\n") to the challenge containing the error message" (Google's XOAUTH2 page). Only then does the server send the tagged &lt;code&gt;NO&lt;/code&gt;. If your callback blindly returns the token again, you resend the secret into an error path. The callback I use returns the token on the first challenge and &lt;code&gt;b""&lt;/code&gt; on any later one. Microsoft's page shows only a plain &lt;code&gt;A01 NO AUTHENTICATE failed.&lt;/code&gt; for IMAP, so handling both shapes is the safe choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the token comes from, and how long it lives
&lt;/h2&gt;

&lt;p&gt;The tool never runs a consent flow. You register an OAuth client with the provider, do the consent once with whatever library or CLI you like, and hand the script an access token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gmail.&lt;/strong&gt; The scope "for IMAP, POP, and SMTP access is &lt;a href="https://mail.google.com/" rel="noopener noreferrer"&gt;https://mail.google.com/&lt;/a&gt;" (Google's XOAUTH2 page). On the Gmail API scopes page it sits under "Restricted scopes", which require Google's restricted-scope app verification before a public app can use them. Two expiry details matter for a scheduled job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Access tokens: Google's OAuth overview says "Access tokens have limited lifetimes" and the token response carries &lt;code&gt;expires_in&lt;/code&gt;, the remaining lifetime in seconds. Read that field; don't hard-code an hour (&lt;a href="https://developers.google.com/identity/protocols/oauth2" rel="noopener noreferrer"&gt;developers.google.com&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Refresh tokens: a project with an external user type and "a publishing status of 'Testing' is issued a refresh token expiring in 7 days" (same page). If your cron job dies every week, this is usually why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Microsoft 365 / Outlook.com.&lt;/strong&gt; The delegated scope string on Microsoft's IMAP OAuth page is &lt;code&gt;https://outlook.office.com/IMAP.AccessAsUser.All&lt;/code&gt;, and you add &lt;code&gt;offline_access&lt;/code&gt; to get a refresh token. The IMAP host is &lt;code&gt;outlook.office365.com&lt;/code&gt;, port 993 (&lt;a href="https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/pop3-and-imap4/pop3-and-imap4" rel="noopener noreferrer"&gt;learn.microsoft.com&lt;/a&gt;). For an unattended service there's an app-only route instead: the &lt;code&gt;IMAP.AccessAsApp&lt;/code&gt; permission, a token requested with &lt;code&gt;https://outlook.office365.com/.default&lt;/code&gt;, a service principal registered in Exchange, and an explicit mailbox permission grant. That one needs tenant admin consent.&lt;/p&gt;

&lt;p&gt;Access tokens from the Microsoft identity platform get "a random value ranging between 60-90 minutes (75 minutes on average) as the default lifetime" (&lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens" rel="noopener noreferrer"&gt;learn.microsoft.com&lt;/a&gt;). Also check IMAP itself: it's on by default in Exchange Online, but if security defaults are enabled, "POP3 and IMAP4 are automatically disabled" (the POP3/IMAP4 page above).&lt;/p&gt;

&lt;p&gt;The practical consequence for both providers: a token pasted into an environment variable works for about an hour. A daily job needs something that refreshes. mail-rule-digest takes either &lt;code&gt;IMAP_ACCESS_TOKEN&lt;/code&gt; (the token itself) or &lt;code&gt;IMAP_TOKEN_COMMAND&lt;/code&gt;, a command whose stdout is a fresh access token, run at the start of every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: a local fake server, a fake token
&lt;/h2&gt;

&lt;p&gt;To get real output without touching a real mailbox, I wrote a ~100-line IMAP-over-TLS server in Python that only speaks &lt;code&gt;XOAUTH2&lt;/code&gt;, accepts exactly one fake token, serves the five &lt;code&gt;.eml&lt;/code&gt; test fixtures from the repo, and logs every command it receives. It refuses &lt;code&gt;LOGIN&lt;/code&gt; the way a provider that disabled Basic auth would. Its certificate is signed by a throwaway local CA. All of this ran on Ubuntu under WSL2, Python 3.12.3, with the package installed from the repo at commit &lt;code&gt;698f960&lt;/code&gt; (version 0.2.0).&lt;/p&gt;

&lt;p&gt;The core of the fake server's &lt;code&gt;AUTHENTICATE&lt;/code&gt; handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+ &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readline&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;USER&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\x01&lt;/span&gt;&lt;span class="s"&gt;auth=Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\x01\x01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; OK XOAUTH2 authentication successful&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schemes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mail.google.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+ &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# Gmail-style error challenge
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                               &lt;span class="c1"&gt;# the client's empty response
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Config: a rule file and an env file
&lt;/h3&gt;

&lt;p&gt;The rules are the repo's example file, &lt;code&gt;examples/rules.toml&lt;/code&gt;, unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[rule]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Invoices over $100"&lt;/span&gt;
&lt;span class="py"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"billing@acme.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*@invoices.example"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# glob, case-insensitive&lt;/span&gt;
&lt;span class="py"&gt;subject&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'(?i)\binvoice\b'&lt;/span&gt;                           &lt;span class="c"&gt;# Python regex, searched&lt;/span&gt;
&lt;span class="py"&gt;body_any&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"due"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"overdue"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                          &lt;span class="c"&gt;# case-insensitive substring&lt;/span&gt;

  &lt;span class="nn"&gt;[rule.numbers.amount]&lt;/span&gt;
  &lt;span class="c"&gt;# The named group must have the same name as the field.&lt;/span&gt;
  &lt;span class="py"&gt;pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'Total:\s*\$(?P&amp;lt;amount&amp;gt;[\d,]+(?:\.\d+)?)'&lt;/span&gt;
  &lt;span class="py"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="nn"&gt;[[rule]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Disk alerts at 90% or more"&lt;/span&gt;
&lt;span class="py"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"alerts@monitor.example"&lt;/span&gt;

  &lt;span class="nn"&gt;[rule.numbers.disk]&lt;/span&gt;
  &lt;span class="py"&gt;pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'(?i)disk usage:?\s*(?P&amp;lt;disk&amp;gt;\d+)%'&lt;/span&gt;
  &lt;span class="py"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
  &lt;span class="py"&gt;max&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="nn"&gt;[[rule]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Parcels out for delivery"&lt;/span&gt;
&lt;span class="py"&gt;body_all&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tracking number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"out for delivery"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connection settings live in the environment, never in that file. &lt;code&gt;fake-token-cli&lt;/code&gt; is a tiny shell script standing in for a real OAuth helper that refreshes and prints a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;mail.env
&lt;span class="go"&gt;export IMAP_HOST=localhost IMAP_PORT=9993 IMAP_USER=demo@example.com
export IMAP_TOKEN_COMMAND=./fake-token-cli
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./fake-token-cli
&lt;span class="go"&gt;ya29.FAKE-LOCAL-DEMO-TOKEN
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; ./mail.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  First run: the certificate is checked
&lt;/h3&gt;

&lt;p&gt;Before trusting the local CA, the run fails, which is what you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="go"&gt;error: cannot connect to localhost:9993: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 2, and the server log stays empty: the token was never sent. For the rest of the demo I point Python at the throwaway CA with &lt;code&gt;export SSL_CERT_FILE=$PWD/ca.pem&lt;/code&gt;. Don't do that against real servers; it replaces the system trust store for that process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dry run
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--days 3&lt;/code&gt; because the fixtures are dated two days before the run, and IMAP &lt;code&gt;SINCE&lt;/code&gt; works in whole days. &lt;code&gt;--dry-run&lt;/code&gt; prints the digest and writes nothing. The &lt;code&gt;[dry-run]&lt;/code&gt; line goes to stderr, which is why it shows up first here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;[dry-run] 5 scanned, 3 matched;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nothing written, nothing posted
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Mail digest 2026-10-07
&lt;span class="go"&gt;
3 matching message(s) across 3 rule(s).

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="c"&gt;# Invoices over $100 (1)&lt;/span&gt;
&lt;span class="go"&gt;
- **Invoice INV-1042 is ready** - billing@acme.example, 2026-10-05 09:15 - amount: 1,240.50

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="c"&gt;# Disk alerts at 90% or more (1)&lt;/span&gt;
&lt;span class="go"&gt;
- **\[ALERT\] disk usage 93% on web-1 @everyone** - alerts@monitor.example, 2026-10-05 12:05 - disk: 93

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="c"&gt;# Parcels out for delivery (1)&lt;/span&gt;
&lt;span class="go"&gt;
- **Your parcel is out for delivery 📦** - noreply@ship.example, 2026-10-05 11:45
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other two fixtures are an invoice under $100 and a newsletter, and neither matched. The server's view of the same run is the more interesting part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server: C: JGLC0 CAPABILITY
server: C: JGLC1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRkFLRS1MT0NBTC1ERU1PLVRPS0VOAQE=
server:    decoded: user=demo@example.com^Aauth=Bearer ya29.FAKE-LOCAL-DEMO-TOKEN^A^A
server: C: JGLC2 EXAMINE "INBOX"
server: C: JGLC3 SEARCH SINCE 05-Oct-2026
server: C: JGLC4 FETCH 1 (BODY.PEEK[])
server: C: JGLC5 FETCH 2 (BODY.PEEK[])
server: C: JGLC6 FETCH 3 (BODY.PEEK[])
server: C: JGLC7 FETCH 4 (BODY.PEEK[])
server: C: JGLC8 FETCH 5 (BODY.PEEK[])
server: C: JGLC9 LOGOUT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EXAMINE&lt;/code&gt; opens the folder read-only, and &lt;code&gt;BODY.PEEK[]&lt;/code&gt; fetches a message without setting &lt;code&gt;\Seen&lt;/code&gt;. No &lt;code&gt;STORE&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;MOVE&lt;/code&gt; or &lt;code&gt;EXPUNGE&lt;/code&gt; appears, because the code has none.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bad token
&lt;/h3&gt;

&lt;p&gt;An expired or wrong token goes through the error-challenge path described earlier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;IMAP_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ya29.EXPIRED-TOKEN mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="go"&gt;error: IMAP error: [AUTHENTICATIONFAILED] Invalid credentials (Failure)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server: C: EAIP1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRVhQSVJFRC1UT0tFTgEB
server:    decoded: user=demo@example.com^Aauth=Bearer ya29.EXPIRED-TOKEN^A^A
server: C: '' (empty response to the error challenge)
server: C: EAIP2 LOGOUT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token went out once, the client answered the JSON challenge with an empty line, and the error message the user sees doesn't contain the token. When &lt;code&gt;IMAP_ACCESS_TOKEN&lt;/code&gt; is set it wins over &lt;code&gt;IMAP_TOKEN_COMMAND&lt;/code&gt;, which is why this run used the bad token.&lt;/p&gt;

&lt;h3&gt;
  
  
  The old password path, for comparison
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; IMAP_TOKEN_COMMAND &lt;span class="nv"&gt;IMAP_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hunter2 mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="go"&gt;error: IMAP error: b'[AUTHENTICATIONFAILED] password login disabled, use XOAUTH2'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server log shows &lt;code&gt;LOGIN demo@example.com "hunter2"&lt;/code&gt;. Plain &lt;code&gt;LOGIN&lt;/code&gt; puts the password on the wire inside TLS, and a server that has switched Basic auth off just says no. (The &lt;code&gt;b'...'&lt;/code&gt; in that message is &lt;code&gt;imaplib&lt;/code&gt; handing back bytes on the &lt;code&gt;LOGIN&lt;/code&gt; path; cosmetic, but it's a real difference between the two paths.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Digest file and webhook
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;--dry-run&lt;/code&gt; the tool writes &lt;code&gt;digest-YYYY-MM-DD.md&lt;/code&gt;, and &lt;code&gt;--webhook&lt;/code&gt; also posts it to &lt;code&gt;$WEBHOOK_URL&lt;/code&gt;. Here the webhook is a second local HTTPS server that prints what it receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://localhost:8443/hook
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--webhook&lt;/span&gt;
&lt;span class="gp"&gt;5 scanned, 3 matched -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;digest-2026-10-07.md
&lt;span class="go"&gt;webhook: HTTP 200
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;digest-&lt;span class="k"&gt;*&lt;/span&gt;.md
&lt;span class="go"&gt;digest-2026-10-07.md
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver got this (the &lt;code&gt;text&lt;/code&gt; value is truncated here; it's the same Markdown as above):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hook: POST /hook application/json
{
  "text": "# Mail digest 2026-10-07\n\n3 matching message(s) across 3 rule(s).\n\n## Invoices over $100 (1)\n\n- **Invoice INV-1042 is ready** - billing@acme.example, 2026-10-05 09:15 - amount: 1,240.50\n\n..."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{"text": ...}&lt;/code&gt; is the Slack incoming-webhook shape. For a &lt;code&gt;discord.com&lt;/code&gt; URL the tool sends &lt;code&gt;{"content": ..., "allowed_mentions": {"parse": []}}&lt;/code&gt; instead, cut to Discord's 2,000-character limit. Mentions are disabled on purpose: look at the disk alert subject above. It contains &lt;code&gt;@everyone&lt;/code&gt;, and without that setting a forwarded email subject could ping a whole Discord channel. I didn't run the Discord branch here because it needs the real host; the repo's tests cover it.&lt;/p&gt;

&lt;p&gt;A plain-HTTP webhook URL is refused. The digest file is still written first, so the exit code is 1, not 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8443/hook mail-rule-digest &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.toml &lt;span class="nt"&gt;--days&lt;/span&gt; 3 &lt;span class="nt"&gt;--webhook&lt;/span&gt;
&lt;span class="gp"&gt;5 scanned, 3 matched -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;digest-2026-10-07.md
&lt;span class="go"&gt;error: webhook failed: webhook URL must use https
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Safety notes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never put tokens in the config file.&lt;/strong&gt; The rule file holds rules only. Credentials come from &lt;code&gt;IMAP_PASSWORD&lt;/code&gt;, &lt;code&gt;IMAP_ACCESS_TOKEN&lt;/code&gt; or &lt;code&gt;IMAP_TOKEN_COMMAND&lt;/code&gt;, and there are no command-line flags for them, since flags end up in shell history and in &lt;code&gt;ps&lt;/code&gt; output. The token command runs without a shell (split like a POSIX command line), with a 30-second timeout. If it fails, the error names the exit status only; neither its stdout nor its stderr is echoed, because either could contain the token. The refresh token itself should live wherever your OAuth helper keeps it, ideally the OS keychain, not next to the cron entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only fetch.&lt;/strong&gt; &lt;code&gt;EXAMINE&lt;/code&gt; plus &lt;code&gt;BODY.PEEK[]&lt;/code&gt;, as the server log showed. The tool never sends, deletes, moves or flags mail. The scope is another matter: Gmail's &lt;code&gt;https://mail.google.com/&lt;/code&gt; and Microsoft's &lt;code&gt;IMAP.AccessAsUser.All&lt;/code&gt; both grant full mailbox access. Read-only is a property of this code, not of the token, so treat the token as a full-access credential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Certificate verification.&lt;/strong&gt; This one surprised me when I first checked it. &lt;code&gt;imaplib.IMAP4_SSL(host, port)&lt;/code&gt; with no &lt;code&gt;ssl_context&lt;/code&gt; does not verify the server certificate. On the same Python 3.12.3, against the same fake server and with no trust for its CA, the bare call connected, while &lt;code&gt;ssl_context=ssl.create_default_context()&lt;/code&gt; failed with &lt;code&gt;CERTIFICATE_VERIFY_FAILED&lt;/code&gt;. An unverified connection hands your bearer token to whoever answers on port 993. Always pass a verifying context, as the first demo run showed the tool doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The digest is as private as the mailbox.&lt;/strong&gt; It contains subjects and sender addresses. A webhook URL is also a secret, since anyone holding it can post to your channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the tool does not do.&lt;/strong&gt; It doesn't run the OAuth consent flow, store or refresh tokens, or ship a client ID. It only speaks IMAP over TLS on port 993: no STARTTLS, no POP3, no Gmail API. It keeps no state between runs, so running twice in a day produces the same digest twice. And it has not been run against live Gmail or Microsoft servers by its own test suite; the 38 tests use fake servers and fixtures, as this post does. If a provider rejects your token, you'll see the server's &lt;code&gt;IMAP error: ...&lt;/code&gt; text, without the token in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;All fetched 2026-10-07.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google, "OAuth 2.0 Mechanism" (XOAUTH2 format, error challenge, scope): &lt;a href="https://developers.google.com/gmail/imap/xoauth2-protocol" rel="noopener noreferrer"&gt;https://developers.google.com/gmail/imap/xoauth2-protocol&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google, "Using OAuth 2.0 to Access Google APIs" (token lifetime, 7-day Testing refresh tokens): &lt;a href="https://developers.google.com/identity/protocols/oauth2" rel="noopener noreferrer"&gt;https://developers.google.com/identity/protocols/oauth2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google, Gmail API scopes (restricted scopes): &lt;a href="https://developers.google.com/workspace/gmail/api/auth/scopes" rel="noopener noreferrer"&gt;https://developers.google.com/workspace/gmail/api/auth/scopes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Workspace Updates, "Winding down Google Sync and Less Secure Apps support": &lt;a href="https://workspaceupdates.googleblog.com/2023/09/winding-down-google-sync-and-less-secure-apps-support.html" rel="noopener noreferrer"&gt;https://workspaceupdates.googleblog.com/2023/09/winding-down-google-sync-and-less-secure-apps-support.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google, "Sign in with app passwords": &lt;a href="https://support.google.com/accounts/answer/185833" rel="noopener noreferrer"&gt;https://support.google.com/accounts/answer/185833&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft, "Authenticate an IMAP, POP or SMTP connection using OAuth": &lt;a href="https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft, "Deprecation of Basic authentication in Exchange Online": &lt;a href="https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft, "POP3 and IMAP4 in Exchange Online": &lt;a href="https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/pop3-and-imap4/pop3-and-imap4" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/pop3-and-imap4/pop3-and-imap4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft, "Access tokens in the Microsoft identity platform": &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Support, "Modern authentication methods now needed to continue syncing Outlook Email in non-Microsoft email apps": &lt;a href="https://support.microsoft.com/en-us/office/modern-authentication-methods-now-needed-to-continue-syncing-outlook-email-in-non-microsoft-email-apps-c5d65390-9676-4763-b41f-d7986499a90d" rel="noopener noreferrer"&gt;https://support.microsoft.com/en-us/office/modern-authentication-methods-now-needed-to-continue-syncing-outlook-email-in-non-microsoft-email-apps-c5d65390-9676-4763-b41f-d7986499a90d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 4422 (SASL): &lt;a href="https://www.rfc-editor.org/rfc/rfc4422" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc4422&lt;/a&gt; and RFC 9051 (IMAP4rev2): &lt;a href="https://www.rfc-editor.org/rfc/rfc9051" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc9051&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool, its tests and the example rules: &lt;a href="https://github.com/mahirhir/mail-rule-digest" rel="noopener noreferrer"&gt;https://github.com/mahirhir/mail-rule-digest&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>email</category>
      <category>oauth</category>
      <category>automation</category>
    </item>
    <item>
      <title>0 of 550 top homepages send noai. 21 send Bing's noarchive.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:06:59 +0000</pubDate>
      <link>https://dev.to/mahirhir/0-of-550-top-homepages-send-noai-21-send-bings-noarchive-38p9</link>
      <guid>https://dev.to/mahirhir/0-of-550-top-homepages-send-noai-21-send-bings-noarchive-38p9</guid>
      <description>&lt;p&gt;I fetched the homepages of the Tranco top 1,000 and read every meta robots tag and every &lt;code&gt;X-Robots-Tag&lt;/code&gt; header they sent. 550 homepages came back as real pages. &lt;strong&gt;None of the 550 sends &lt;code&gt;noai&lt;/code&gt; or &lt;code&gt;noimageai&lt;/code&gt;.&lt;/strong&gt; None sends &lt;code&gt;nosnippet&lt;/code&gt; either, and the 47 that send &lt;code&gt;max-snippet&lt;/code&gt; all set it to &lt;code&gt;-1&lt;/code&gt;, which means "no limit". The page-level AI rule that big sites actually use is Bing's: 21 of the 550 send &lt;code&gt;noarchive&lt;/code&gt; or &lt;code&gt;nocache&lt;/code&gt;, the two values Microsoft says keep a page out of Copilot answers or cut it down to a link and a snippet.&lt;/p&gt;

&lt;p&gt;So the AI policy of the top 1,000 lives almost entirely in robots.txt. That's fine, as long as you know what each lever reaches. Most of the confusion I see comes from mixing them up: a &lt;code&gt;noai&lt;/code&gt; tag nobody major reads, a &lt;code&gt;noindex&lt;/code&gt; behind a robots.txt block that stops the crawler from ever seeing it, or a header and a meta tag that say different things.&lt;/p&gt;

&lt;p&gt;Below: what each lever reaches, the numbers, three real sites with three different setups, and how to write one policy that says the same thing everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;noai&lt;/code&gt; and &lt;code&gt;noimageai&lt;/code&gt; are not a standard.&lt;/strong&gt; They aren't in Google's list of supported robots rules, and the crawler pages from OpenAI, Anthropic and Perplexity describe robots.txt tokens as the opt-out, not page tags. Some tools honour them. img2dataset, which builds image-text training sets, skips images whose own HTTP response carries &lt;code&gt;X-Robots-Tag: noai&lt;/code&gt; or &lt;code&gt;noimageai&lt;/code&gt;. It reads the header on the image, not a meta tag on the page around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homepage only.&lt;/strong&gt; One URL per site. A site can put directives on articles, images or PDFs and leave the homepage clean, and I wouldn't see it. "0 of 550" is about homepages, not about whole sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One network location, one user agent.&lt;/strong&gt; One GET per domain on 2026-10-06, 15:59 to 16:01 UTC, from one connection in Japan, with a desktop Chrome user agent, redirects followed. A server can send different headers to GPTBot than to Chrome. I didn't test that. I also didn't run JavaScript, so a meta tag injected client-side is invisible here (and to most crawlers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List and set:&lt;/strong&gt; Tranco list &lt;a href="https://tranco-list.eu/list/56WKN/1000000" rel="noopener noreferrer"&gt;56WKN&lt;/a&gt;, top 1,000, the same 742 domains that answered HTTPS in my &lt;a href="https://dev.to/mahirhir/246-of-the-top-1000-sites-answer-llmstxt-with-200-only-114-serve-the-file-1o9n"&gt;llms.txt count&lt;/a&gt; and my &lt;a href="https://dev.to/mahirhir/111-of-the-top-1000-sites-block-gptbot-35-never-mention-the-bot-behind-chatgpt-search-19oa"&gt;robots.txt count&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;192 of the 742 didn't give me a homepage.&lt;/strong&gt; 114 answered 401 or 403 to a browser user agent, 8 answered 200 with a bot wall or an error page (microsoft.com's said "Your request has been blocked"), and 70 answered 404, 400, 5xx, 429, something that wasn't HTML, or nothing. Those 192 are unknown, not "no directives". n = 550 below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What each lever reaches
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lever&lt;/th&gt;
&lt;th&gt;Who reads it&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;robots.txt &lt;code&gt;User-agent: GPTBot&lt;/code&gt; etc.&lt;/td&gt;
&lt;td&gt;Crawlers that name that token&lt;/td&gt;
&lt;td&gt;Whether the bot may fetch the URL at all. Details per token in the &lt;a href="https://dev.to/mahirhir/111-of-the-top-1000-sites-block-gptbot-35-never-mention-the-bot-behind-chatgpt-search-19oa"&gt;robots.txt post&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nosnippet&lt;/code&gt;, &lt;code&gt;max-snippet:N&lt;/code&gt; (meta or header)&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Google: &lt;code&gt;nosnippet&lt;/code&gt; "will also prevent the content from being used as a direct input for AI Overviews and AI Mode". &lt;code&gt;max-snippet&lt;/code&gt; limits how much can be used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;noindex&lt;/code&gt;, &lt;code&gt;none&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Search engines&lt;/td&gt;
&lt;td&gt;Out of the index, and so out of everything built on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noarchive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bing&lt;/td&gt;
&lt;td&gt;Microsoft: "will not be included in Bing Chat answers, not be linked to in the answers", and not used for training. Google no longer lists it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nocache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bing&lt;/td&gt;
&lt;td&gt;Microsoft: only "URL/Snippet/Title" in answers; only those used for training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;noai&lt;/code&gt;, &lt;code&gt;noimageai&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Some dataset tools&lt;/td&gt;
&lt;td&gt;Non-standard. See the limits above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: Google's &lt;a href="https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag" rel="noopener noreferrer"&gt;robots meta tag reference&lt;/a&gt; and &lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;AI features page&lt;/a&gt;, Microsoft's &lt;a href="https://blogs.bing.com/webmaster/september-2023/Announcing-new-options-for-webmasters-to-control-usage-of-their-content-in-Bing-Chat" rel="noopener noreferrer"&gt;Bing webmaster blog, 2023-09-22&lt;/a&gt;, &lt;a href="https://github.com/rom1504/img2dataset" rel="noopener noreferrer"&gt;img2dataset&lt;/a&gt;. All read 2026-10-07.&lt;/p&gt;

&lt;p&gt;Two rules hold across all of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A crawler that robots.txt keeps out never reads your page-level rules.&lt;/strong&gt; Google says it directly: if a page is disallowed in robots.txt, "any information about indexing or serving rules will not be found and will therefore be ignored." A &lt;code&gt;noindex&lt;/code&gt; on a page Googlebot can't fetch does nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meta tag and header are the same instruction in two places.&lt;/strong&gt; Google treats them as one set, and "the more restrictive rule applies". The header is the only option for PDFs and images. The meta tag is easier to change in a CMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;n = 550 homepages, 2026-10-06.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Homepages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Any meta robots tag&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any &lt;code&gt;X-Robots-Tag&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;noai&lt;/code&gt; or &lt;code&gt;noimageai&lt;/code&gt; (meta or header)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nosnippet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max-snippet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;47, all &lt;code&gt;-1&lt;/code&gt; (no limit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;noindex&lt;/code&gt; for every crawler&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;noarchive&lt;/code&gt; or &lt;code&gt;nocache&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directive aimed at a named AI crawler (&lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;ClaudeBot&lt;/code&gt;, ...)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 7 &lt;code&gt;noindex&lt;/code&gt; homepages aren't AI policy. They're login apps (outlook.com and live.com land on Outlook's mail login), two domain registrars, a cloud login, an ad-tech company and a parked domain.&lt;/p&gt;

&lt;p&gt;The 21 Bing signals split in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 aim only at Bing&lt;/strong&gt;: &lt;code&gt;&amp;lt;meta name="bingbot" content="noarchive"&amp;gt;&lt;/code&gt; or &lt;code&gt;X-Robots-Tag: bingbot: noarchive&lt;/code&gt;. instagram.com, threads.com, theguardian.com, bbc.com, bbc.co.uk, elpais.com, cnet.com and nikkei.com send &lt;code&gt;noarchive&lt;/code&gt;; tumblr.com and theverge.com send &lt;code&gt;nocache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 send &lt;code&gt;noarchive&lt;/code&gt; to every crawler&lt;/strong&gt;, among them linkedin.com, npr.org, lemonde.fr, dailymail.co.uk, usatoday.com and zoho.com.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;max-snippet:-1&lt;/code&gt; on 47 homepages is mostly SEO plugin boilerplate (it comes with &lt;code&gt;max-image-preview:large&lt;/code&gt; and &lt;code&gt;max-video-preview:-1&lt;/code&gt;). It says "use as much as you like". Nobody in the top 1,000 uses &lt;code&gt;max-snippet&lt;/code&gt; to limit AI Overviews on the homepage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Meta versus header
&lt;/h3&gt;

&lt;p&gt;3 sites send rules in both places for the same crawler, and 2 of them send different sets. usatoday.com sends &lt;code&gt;noarchive, nocache&lt;/code&gt; in the header and only old &lt;code&gt;noodp, noydir&lt;/code&gt; in the meta tag. repubblica.it sends &lt;code&gt;noarchive&lt;/code&gt; in the header and not in the meta tag. Neither is a contradiction, since the stricter value wins. But anyone reading only the HTML would get the policy wrong, and that includes the person who maintains the template.&lt;/p&gt;

&lt;p&gt;10 sites have more than one meta robots tag on the homepage. I found no page that says &lt;code&gt;index&lt;/code&gt; in one place and &lt;code&gt;noindex&lt;/code&gt; in another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Against robots.txt
&lt;/h3&gt;

&lt;p&gt;I compared every homepage with the same site's robots.txt from the earlier run (545 files, RFC 9309 matching).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;noai&lt;/code&gt; against robots.txt that lets training crawlers in:&lt;/strong&gt; 0, because there's no &lt;code&gt;noai&lt;/code&gt; to compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A directive aimed at an AI crawler that robots.txt already keeps out:&lt;/strong&gt; 0. The only named targets were &lt;code&gt;bingbot&lt;/code&gt; (10) and &lt;code&gt;googlebot-news&lt;/code&gt; (1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page-wide &lt;code&gt;noindex&lt;/code&gt; that some AI crawlers can't see:&lt;/strong&gt; 1. sharethrough.com puts &lt;code&gt;noindex&lt;/code&gt; on its homepage and blocks all nine AI tokens in robots.txt. Both say no, so nothing breaks. The tag just never reaches those bots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of the 21 Bing-signal sites, 15 served a robots.txt file. 8 of the 15 fully block GPTBot and 10 fully block Google-Extended. 2 fully block none of the six AI tokens I compared: rubiconproject.com and zoho.com.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sites, three setups
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;bbc.com: every layer says the same thing.&lt;/strong&gt; robots.txt fully blocks GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended and CCBot. The homepage adds &lt;code&gt;X-Robots-Tag: bingbot: noarchive&lt;/code&gt;. Bingbot can't be blocked in robots.txt without leaving Bing search, so for Copilot the page-level switch is the only lever. That's the pattern I'd copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;rubiconproject.com: page-level only.&lt;/strong&gt; The meta tag says &lt;code&gt;noarchive&lt;/code&gt; to every crawler. robots.txt allows all six AI tokens. If the intent is "no generative use", it has been said only to Microsoft. If the intent was only "no cached copy", then it's a leftover that now also keeps them out of Copilot answers. I can't tell which from the outside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;theverge.com: mixed, and maybe on purpose.&lt;/strong&gt; &lt;code&gt;&amp;lt;meta name="bingbot" content="nocache"&amp;gt;&lt;/code&gt; (Copilot may show a link and a snippet), robots.txt blocks ClaudeBot, PerplexityBot, Google-Extended and CCBot fully, and doesn't block GPTBot at all. That reads like a policy decided per company. It could also be a deal. The file doesn't say, and that's the problem with a policy spread over three places: the next person to edit it can't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write one policy
&lt;/h2&gt;

&lt;p&gt;Decide per purpose first, then pick the lever the target actually reads.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Training.&lt;/strong&gt; Use robots.txt tokens: GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot, Bytespider. For Bing, &lt;code&gt;noarchive&lt;/code&gt;. If you also want &lt;code&gt;noai&lt;/code&gt;, send it as a header on images, where the tools that honour it look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI answers and search.&lt;/strong&gt; OAI-SearchBot, Claude-SearchBot and PerplexityBot in robots.txt. For Google's AI Overviews, robots.txt can't help without leaving Google Search; &lt;code&gt;nosnippet&lt;/code&gt; or a &lt;code&gt;max-snippet&lt;/code&gt; value is the lever. For Copilot, &lt;code&gt;nocache&lt;/code&gt; (link and snippet only) or &lt;code&gt;noarchive&lt;/code&gt; (out).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gone completely.&lt;/strong&gt; &lt;code&gt;noindex&lt;/code&gt;, and then let crawlers fetch the page so they can read it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"No training, answers with a link only" for a news-style site, written out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# robots.txt
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: Google-Extended
User-agent: Applebot-Extended
User-agent: CCBot
User-agent: Bytespider
Disallow: /

User-agent: *
Disallow: /search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- every HTML page --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"bingbot"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"nocache"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# images and PDFs, where a meta tag can't go&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(jpg|jpeg|png|webp|pdf)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Robots-Tag&lt;/span&gt; &lt;span class="s"&gt;"noai,&lt;/span&gt; &lt;span class="s"&gt;noimageai"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check for the four contradictions I looked for above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a page-level rule for a bot that robots.txt keeps out (it's never read),&lt;/li&gt;
&lt;li&gt;a meta tag and a header for the same crawler with different values,&lt;/li&gt;
&lt;li&gt;two meta robots tags that disagree,&lt;/li&gt;
&lt;li&gt;a page-wide &lt;code&gt;noarchive&lt;/code&gt; or &lt;code&gt;noindex&lt;/code&gt; left over from an old SEO setup that now also decides your AI policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write the policy down in a comment at the top of robots.txt, even if it's only one line. robots.txt is the one place every crawler reads, and the comment is the only part a person reads.&lt;/p&gt;

&lt;p&gt;The raw data (one JSON line per homepage with the raw &lt;code&gt;X-Robots-Tag&lt;/code&gt; values and meta tags, the saved &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of each page, the probe and analysis scripts, and a control file that plants each directive to check the counts) is kept with the date above.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; shows, for GPTBot, ClaudeBot and PerplexityBot, whether robots.txt allows them and what your server actually returns, with a short preview of what else it found on the page. It flags &lt;code&gt;noai&lt;/code&gt;, &lt;code&gt;noimageai&lt;/code&gt;, &lt;code&gt;nosnippet&lt;/code&gt;, low &lt;code&gt;max-snippet&lt;/code&gt;, &lt;code&gt;noindex&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt;; it doesn't read Bing's &lt;code&gt;noarchive&lt;/code&gt; or &lt;code&gt;nocache&lt;/code&gt; yet.&lt;/p&gt;

&lt;p&gt;If you want one site gone through end to end, the paid audit on the same page covers crawler access and machine-readability: robots.txt per bot, what your server answers each crawler, page-level directives, structured data, sitemap and llms.txt. It doesn't query any AI engine, so it can't tell you whether you appear in their answers. It tells you whether they can read you, and whether your rules say what you meant.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>111 of the top 1,000 sites block GPTBot. 35 never mention the bot behind ChatGPT search.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:06:03 +0000</pubDate>
      <link>https://dev.to/mahirhir/111-of-the-top-1000-sites-block-gptbot-35-never-mention-the-bot-behind-chatgpt-search-19oa</link>
      <guid>https://dev.to/mahirhir/111-of-the-top-1000-sites-block-gptbot-35-never-mention-the-bot-behind-chatgpt-search-19oa</guid>
      <description>&lt;p&gt;111 of the top 1,000 sites fully block GPTBot in robots.txt. 51 of those 111 don't fully block OAI-SearchBot, and 35 of the 51 never mention OAI-SearchBot at all. GPTBot is OpenAI's training crawler. OAI-SearchBot is the one that decides whether a page can show up in ChatGPT search. So a third of the sites that said "no" to OpenAI said it only to training, and most of them probably didn't decide that on purpose. I can't tell from the file. Nobody can, which is the point of this post.&lt;/p&gt;

&lt;p&gt;Below: what each AI token actually controls, the numbers from the top 1,000 sites as of today, the mistakes I found in real files, and how to write your own policy so it says what you mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;robots.txt is a request.&lt;/strong&gt; It doesn't block anything. A crawler that ignores it gets the page. And it says nothing about what your server or CDN does: in &lt;a href="https://dev.to/mahirhir/your-cdn-can-overrule-robotstxt-finding-the-layer-that-refuses-ai-crawlers-34kg"&gt;an earlier measurement&lt;/a&gt;, 19 of 79 small-business sites refused at least one AI crawler at the server, mostly against their own robots.txt. Everything below is about the file, not about what a bot can actually fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List:&lt;/strong&gt; Tranco list &lt;a href="https://tranco-list.eu/list/56WKN/1000000" rel="noopener noreferrer"&gt;56WKN&lt;/a&gt;, generated 2026-10-05, top 1,000. It's the same list and the same reachable set as my &lt;a href="https://dev.to/mahirhir/246-of-the-top-1000-sites-answer-llmstxt-with-200-only-114-serve-the-file-1o9n"&gt;llms.txt count&lt;/a&gt;: 742 of the 1,000 domains answered HTTPS. The rest are mostly infrastructure hostnames with no website.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When and how:&lt;/strong&gt; one GET of &lt;code&gt;https://&amp;lt;domain&amp;gt;/robots.txt&lt;/code&gt; per domain on 2026-10-06, 09:59 to 10:00 UTC, from one connection in Japan, with a desktop Chrome user agent, redirects followed. A server can hand a different robots.txt to a different user agent. I didn't test that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One snapshot.&lt;/strong&gt; These files change, and AI sections change more often than the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching:&lt;/strong&gt; RFC 9309 rules. A bot uses the group that names it; only if no group names it does it fall back to &lt;code&gt;User-agent: *&lt;/code&gt;. Blank lines are ignored. "Fully blocked" below means disallowed at both &lt;code&gt;/&lt;/code&gt; and a deep path. "Partially" means allowed at &lt;code&gt;/&lt;/code&gt; but at least one Disallow applies to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of the 742, &lt;strong&gt;545 served a robots.txt file&lt;/strong&gt;. 144 had none (76 answered 404 or similar, 68 answered 200 with an HTML page, which a parser reads as a file with no rules). 53 I couldn't read: 45 answered 401 or 403, 8 returned 5xx, 202 or 429. RFC 9309 says a 4xx means "no restrictions" and a 5xx means "assume everything is disallowed". I count all 53 as unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each token controls
&lt;/h2&gt;

&lt;p&gt;This is the part most robots.txt files get wrong, so here it is from each vendor's own page, read today.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;What disallowing it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GPTBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Opts out of crawling for model training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OAI-SearchBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Opts out of ChatGPT search results. OpenAI: "Each setting is independent of the others"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChatGPT-User&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;User-triggered visits. OpenAI says robots.txt rules "may not apply"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClaudeBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Opts out of crawling for training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Claude-SearchBot&lt;/code&gt;, &lt;code&gt;Claude-User&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Search quality, and visits on a user's request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic-ai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(Anthropic, older)&lt;/td&gt;
&lt;td&gt;Not on Anthropic's current crawler page, which lists only the three above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PerplexityBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;Opts out of Perplexity's search index. Perplexity says it is not used for model training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Perplexity-User&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;User-triggered fetches. Perplexity says it "generally ignores robots.txt rules"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google-Extended&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Not a crawler. Controls whether content Google already crawls can train Gemini. Google: it "does not impact a site's inclusion in Google Search"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Applebot-Extended&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apple&lt;/td&gt;
&lt;td&gt;Not a crawler either. Controls training use of what Applebot fetched; such pages "can still be included in search results"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CCBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Common Crawl&lt;/td&gt;
&lt;td&gt;Opts out of the open crawl that many training sets are built from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bytespider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ByteDance&lt;/td&gt;
&lt;td&gt;Training crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://developers.openai.com/api/docs/bots" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;, &lt;a href="https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;, &lt;a href="https://docs.perplexity.ai/guides/bots" rel="noopener noreferrer"&gt;Perplexity&lt;/a&gt;, &lt;a href="https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers" rel="noopener noreferrer"&gt;Google&lt;/a&gt;, &lt;a href="https://support.apple.com/en-us/119829" rel="noopener noreferrer"&gt;Apple&lt;/a&gt;, &lt;a href="https://commoncrawl.org/ccbot" rel="noopener noreferrer"&gt;Common Crawl&lt;/a&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc9309.html" rel="noopener noreferrer"&gt;RFC 9309&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The split that matters: &lt;strong&gt;training tokens&lt;/strong&gt; (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot, Bytespider) versus &lt;strong&gt;answer and search tokens&lt;/strong&gt; (OAI-SearchBot, Claude-SearchBot, PerplexityBot). Blocking a training token doesn't take you out of that company's answers. Blocking a search token does, or at least asks to. And the user-triggered agents mostly don't read robots.txt at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;n = 545 robots.txt files, 2026-10-06.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Fully blocked&lt;/th&gt;
&lt;th&gt;of which by name&lt;/th&gt;
&lt;th&gt;of which via &lt;code&gt;*&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Partially&lt;/th&gt;
&lt;th&gt;Allowed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bytespider&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;350&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CCBot&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;348&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;111&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;351&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;353&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google-Extended&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;365&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;373&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applebot-Extended&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anthropic-ai&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;385&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Partially" is mostly noise.&lt;/strong&gt; For GPTBot, 322 of the 351 come from the &lt;code&gt;*&lt;/code&gt; group: ordinary rules like &lt;code&gt;Disallow: /search&lt;/code&gt; or &lt;code&gt;/cart&lt;/code&gt; that apply to every crawler. Those sites have no AI policy at all. They just have a robots.txt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Via &lt;code&gt;*&lt;/code&gt;" means no AI policy either.&lt;/strong&gt; 49 files have &lt;code&gt;Disallow: /&lt;/code&gt; in their &lt;code&gt;*&lt;/code&gt; group. Some are ad and CDN hostnames that block everyone. Others are allowlists from big platforms (facebook.com, x.com, reddit.com, netflix.com, yelp.com): block everyone, then name who gets in. For a bot those files don't name, "via &lt;code&gt;*&lt;/code&gt;" is still a decision, just not one about AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;169 of 545 files (31%) name at least one of the nine tokens.&lt;/strong&gt; That's the real count of sites with any written AI policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAI-SearchBot is blocked about half as often as GPTBot&lt;/strong&gt; (61 vs 111). Some of that is a choice. Some of it isn't, as the next section shows.&lt;/li&gt;
&lt;li&gt;In the top 100, 55 domains served a file and 13 of those block GPTBot fully, among them amazon.com, x.com, pinterest.com, tiktok.com, yahoo.com and baidu.com.&lt;/li&gt;
&lt;li&gt;40 files block all nine tokens fully. 13 block all four training tokens I grouped (GPTBot, Google-Extended, Applebot-Extended, CCBot) and leave both OAI-SearchBot and PerplexityBot open. That second group is the clean "train on nothing, but answer with my pages" policy. It's rarer than I expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mistakes in real files
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Blocking training and, maybe by accident, leaving answers open
&lt;/h3&gt;

&lt;p&gt;Of the 111 sites that fully block GPTBot, 51 don't fully block OAI-SearchBot.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16 name OAI-SearchBot and leave it open.&lt;/strong&gt; yahoo.com, ebay.com, reuters.com, bloomberg.com, airbnb.com, forbes.com, tripadvisor.com, figma.com and others. That's a decision, and a coherent one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;35 never mention it.&lt;/strong&gt; medium.com, nature.com, apnews.com, techcrunch.com, latimes.com, webmd.com, goodreads.com, pixiv.net and more. OAI-SearchBot falls through to their &lt;code&gt;*&lt;/code&gt; group, which doesn't block it. Maybe that's what they want. But GPTBot and OAI-SearchBot are both from OpenAI and are often listed together, so a site that blocks one by name and doesn't mention the other most likely never decided about the second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reverse happened once. facebook.com ends its file with &lt;code&gt;User-agent: *&lt;/code&gt; / &lt;code&gt;Disallow: /&lt;/code&gt; and names the bots it allows. GPTBot is named, with path rules. OAI-SearchBot isn't, so it falls into the block-everything group. Result: training crawler partly in, search crawler out. When your file is an allowlist, every token you forget is blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blocking a token nobody sends anymore
&lt;/h3&gt;

&lt;p&gt;4 files block &lt;code&gt;anthropic-ai&lt;/code&gt; fully and never mention &lt;code&gt;ClaudeBot&lt;/code&gt;: chatgpt.com, espn.com, repubblica.it and elmundo.es. ClaudeBot falls back to their &lt;code&gt;*&lt;/code&gt; group and is only partly restricted. If the intent was "no Anthropic training", the file doesn't say so for the crawler Anthropic currently lists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naming a bot replaces the &lt;code&gt;*&lt;/code&gt; group
&lt;/h3&gt;

&lt;p&gt;This one surprised me most. Once a group names GPTBot, GPTBot ignores the &lt;code&gt;*&lt;/code&gt; group completely. Whatever you disallowed for everyone no longer applies to it.&lt;/p&gt;

&lt;p&gt;23 sites give GPTBot its own group without a full block, and that group lacks Disallow rules their &lt;code&gt;*&lt;/code&gt; group has. hp.com has &lt;code&gt;User-agent: GPTBot&lt;/code&gt; / &lt;code&gt;Allow: /&lt;/code&gt;, and with it 170 paths from its &lt;code&gt;*&lt;/code&gt; group (order status pages, site search, login forms, &lt;code&gt;/api/&lt;/code&gt;) no longer apply to GPTBot. vimeo.com loses 325, kleinanzeigen.de 215, cisco.com 175, wordpress.org 4 (including &lt;code&gt;/wp-admin/&lt;/code&gt;). Same pattern for OAI-SearchBot (28 sites) and PerplexityBot (25).&lt;/p&gt;

&lt;p&gt;Maybe some of them want exactly that. Most &lt;code&gt;*&lt;/code&gt; rules exist to keep crawlers out of cart and search pages, though, and I doubt anyone meant to let AI crawlers in there and nobody else. If you add a named group, copy your &lt;code&gt;*&lt;/code&gt; rules into it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tokens that match no crawler
&lt;/h3&gt;

&lt;p&gt;6 files had a user-agent value that is a near-miss of one of the nine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;lemonde.fr: &lt;code&gt;User-agent: CCBo&lt;/code&gt; (it also has a correct &lt;code&gt;CCBot&lt;/code&gt; group earlier, so no harm)&lt;/li&gt;
&lt;li&gt;usatoday.com: &lt;code&gt;User-agent: Claude&lt;/code&gt;, which no Anthropic crawler sends as its token&lt;/li&gt;
&lt;li&gt;linktr.ee: &lt;code&gt;User-agent: Google&lt;/code&gt; with a &lt;code&gt;Content-Signal&lt;/code&gt; line. Google's tokens are &lt;code&gt;Googlebot&lt;/code&gt; and &lt;code&gt;Google-Extended&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bestbuy.com: &lt;code&gt;GPTBot-Web&lt;/code&gt;; nbcnews.com: &lt;code&gt;ByteSpider Desktop&lt;/code&gt;; bloomberg.com: &lt;code&gt;Bytespider-Image&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Matching is on the whole product token, case-insensitive. &lt;code&gt;Claude&lt;/code&gt; doesn't match &lt;code&gt;ClaudeBot&lt;/code&gt;. These lines do nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blank lines inside a group
&lt;/h3&gt;

&lt;p&gt;58 files put a blank line between a &lt;code&gt;User-agent&lt;/code&gt; line and its rules. chatgpt.com does it for &lt;code&gt;*&lt;/code&gt;, hp.com for every AI token it names. RFC 9309 ignores blank lines, so a compliant parser reads these files the way the authors meant. The old 1994 convention ended a record at a blank line, and a parser written to it would see those rules as belonging to no one. I didn't test which crawlers still parse that way. Deleting the blank lines costs nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  A rule above every group
&lt;/h3&gt;

&lt;p&gt;sophos.com commented out the &lt;code&gt;User-agent: *&lt;/code&gt; line at the top of its file and left &lt;code&gt;Allow: /&lt;/code&gt; under it, above the first real group. A rule before any &lt;code&gt;User-agent&lt;/code&gt; line belongs to no group and applies to nobody. Harmless here, since it was an Allow, but the same edit on a Disallow would silently drop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide your own policy
&lt;/h2&gt;

&lt;p&gt;Two questions, answered separately.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can my content train models?&lt;/strong&gt; Then decide on GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot and Bytespider. Blocking Google-Extended doesn't remove you from Google Search, and blocking Applebot-Extended doesn't remove you from Apple's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can AI search products cite and link me?&lt;/strong&gt; Then decide on OAI-SearchBot, Claude-SearchBot and PerplexityBot. Blocking these takes you out of those answers, or asks to.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;User-triggered agents (ChatGPT-User, Claude-User, Perplexity-User) are a third thing, and by their own vendors' account robots.txt mostly doesn't govern them. If you need them stopped, that's a server rule, not a robots.txt line.&lt;/p&gt;

&lt;p&gt;"No training, yes to answers" written out, with the &lt;code&gt;*&lt;/code&gt; rules repeated where a named group would otherwise drop them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: GPTBot
User-agent: ClaudeBot
User-agent: Google-Extended
User-agent: Applebot-Extended
User-agent: CCBot
User-agent: Bytespider
Disallow: /

User-agent: OAI-SearchBot
User-agent: Claude-SearchBot
User-agent: PerplexityBot
Disallow: /cart
Disallow: /search

User-agent: *
Disallow: /cart
Disallow: /search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several &lt;code&gt;User-agent&lt;/code&gt; lines in a row form one group. Name every token you made a decision about, even when the decision is "allow", so the next person to edit the file can see it was decided.&lt;/p&gt;

&lt;p&gt;Then check what your server actually answers each crawler. robots.txt is only the request; a CDN rule can refuse a bot your file allows, or let in one it disallows. The &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; shows, for GPTBot, ClaudeBot and PerplexityBot, whether your robots.txt allows them and what status your server actually returns to each.&lt;/p&gt;

&lt;p&gt;The raw data (one JSON line per domain, every robots.txt I fetched, the probe and analysis scripts) is kept with the date above. Ask in the comments if you want the per-domain table.&lt;/p&gt;

&lt;p&gt;If you want someone to go through one site end to end, the paid audit on the same page covers crawler access and machine-readability (robots.txt per bot, server answers, structured data, sitemap and llms.txt) and includes paste-ready fixes (a corrected robots.txt, CDN/WAF steps for bot 403s, meta and canonical fixes, JSON-LD and llms.txt skeletons), all marked review before deploying. It queries no AI engine and measures no citations or rankings. It tells you whether they can read you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>robotstxt</category>
    </item>
    <item>
      <title>246 of the top 1,000 sites answer /llms.txt with 200. Only 114 serve the file.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Tue, 06 Oct 2026 06:42:03 +0000</pubDate>
      <link>https://dev.to/mahirhir/246-of-the-top-1000-sites-answer-llmstxt-with-200-only-114-serve-the-file-1o9n</link>
      <guid>https://dev.to/mahirhir/246-of-the-top-1000-sites-answer-llmstxt-with-200-only-114-serve-the-file-1o9n</guid>
      <description>&lt;p&gt;If you count llms.txt adoption by asking which sites return 200 for &lt;code&gt;/llms.txt&lt;/code&gt;, you'll more than double the real number. I checked the top 1,000 domains today. 246 answered 200. 114 of them actually served an llms.txt file. The other 132 sent a login page, a "not found" page with a 200 status, their home page, or a single-page app shell.&lt;/p&gt;

&lt;p&gt;This post is the measurement, the three ways a naive check goes wrong, and a 20-line script that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and the limits first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;List:&lt;/strong&gt; Tranco list &lt;a href="https://tranco-list.eu/list/56WKN/1000000" rel="noopener noreferrer"&gt;56WKN&lt;/a&gt;, generated 2026-10-05, top 1,000 entries. Tranco ranks registrable domains by combined traffic signals, so it includes infrastructure domains (CDN hostnames, API domains, ad servers) that have no website at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When and from where:&lt;/strong&gt; 2026-10-06, 06:30 to 06:37 UTC for the files, with the dead-link re-check in the following hour, from one ordinary connection in Japan. Some sites answer differently by country (a few redirected me to &lt;code&gt;/jp/&lt;/code&gt; or &lt;code&gt;/ja-jp/&lt;/code&gt; pages), so a run from elsewhere will differ at the edges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requests:&lt;/strong&gt; one GET each to &lt;code&gt;https://&amp;lt;domain&amp;gt;/&lt;/code&gt;, &lt;code&gt;/llms.txt&lt;/code&gt; and &lt;code&gt;/llms-full.txt&lt;/code&gt;, with a desktop Chrome user agent and &lt;code&gt;Accept: text/plain,text/markdown,*/*&lt;/code&gt;. If &lt;code&gt;https://&amp;lt;domain&amp;gt;&lt;/code&gt; didn't answer, I tried &lt;code&gt;https://www.&amp;lt;domain&amp;gt;&lt;/code&gt;. Redirects were followed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not counted as "no file":&lt;/strong&gt; 70 domains answered &lt;code&gt;/llms.txt&lt;/code&gt; with 401 or 403. Some of those may have a file that a bot-protection layer kept from me. I count them as unknown, not as absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One snapshot.&lt;/strong&gt; No second pass on the presence check. Status codes for a static file are much steadier than for home pages, but a single run is a single run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;742 of the 1,000 domains answered HTTPS at all. That's the denominator for everything below unless I say otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What &lt;code&gt;/llms.txt&lt;/code&gt; returned&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An llms.txt-shaped text file&lt;/td&gt;
&lt;td&gt;114&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, but an HTML page&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, but a redirect to some other page served as text (my &lt;code&gt;Accept&lt;/code&gt; header asked for text, and a few servers converted the page to markdown)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, but not a file at all (an IP echo service, a JSON error)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 or 410&lt;/td&gt;
&lt;td&gt;388&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401 or 403 (unknown)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything else (400, 5xx, 202, timeouts)&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So &lt;strong&gt;114 of 742 reachable domains (15%) serve an llms.txt&lt;/strong&gt;. In the top 100 it's 8 of the 75 that answered: cloudflare.com, github.com, fastly.net, wordpress.org, digicert.com, adobe.com, opera.com and sentry.io. 16 of the 114 get there by redirecting to a sibling domain's file, for example unity3d.com to unity.com and macromedia.com to adobe.com.&lt;/p&gt;

&lt;p&gt;17 domains also serve &lt;code&gt;llms-full.txt&lt;/code&gt;, and every one of them has an &lt;code&gt;llms.txt&lt;/code&gt; too. Worth knowing: &lt;code&gt;llms-full.txt&lt;/code&gt; is not in the &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llms.txt proposal&lt;/a&gt; at all. It's a convention that documentation platforms popularised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a naive check lies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A 200 that is an HTML page
&lt;/h3&gt;

&lt;p&gt;122 domains answered with an HTML page and status 200. 37 of them serve that HTML at &lt;code&gt;/llms.txt&lt;/code&gt; itself, which is what a single-page app does when it routes every unknown path to the same shell. instagram.com, linkedin.com, pinterest.com and chatgpt.com are in this group. The other 85 redirected first: 38 to a home page, the rest to login screens or to a "not found" page that returns 200 (netflix.com lands on &lt;code&gt;/NotFound&lt;/code&gt;, tiktok.com on &lt;code&gt;/404&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Any tool that reports "llms.txt found" on status alone counts all 122. Check the &lt;code&gt;Content-Type&lt;/code&gt;, check that the first non-blank byte isn't &lt;code&gt;&amp;lt;&lt;/code&gt;, and check where you ended up after redirects.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A text file that isn't an llms.txt
&lt;/h3&gt;

&lt;p&gt;The proposal requires exactly one thing: the file starts with an H1, the name of the site or project. Everything else (a blockquote summary, H2 sections holding lists of &lt;code&gt;[name](url)&lt;/code&gt; links) is the recommended shape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;108 of the 114 start with an H1.&lt;/li&gt;
&lt;li&gt;97 have the full shape: H1 first, at least one H2 section, at least one list item that is a link.&lt;/li&gt;
&lt;li&gt;The 6 that don't start with an H1 open with a "Generated by ..." line (playstation.com, moloco.com), a bare URL (cisco.com), an H2 (agora.io, trendyol.com), or straight into a link list (salesforce.com). A generator tool wrote the first two, and nobody read the first line afterwards.&lt;/li&gt;
&lt;li&gt;11 have the H1 but no linked list sections. porkbun.com has 14 H2 sections and no links at all; it's a prose summary of the site. That's allowed by the letter of the proposal, but an agent reading it has nowhere to go next.&lt;/li&gt;
&lt;li&gt;9 have more than one H1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Size is the other surprise. The median file is 11,378 bytes, which is fine. 12 are over 100 KB and the largest, salesforce.com's, is 1,021,703 bytes. At the rough rate of four bytes per token that's about a quarter of a million tokens. Version 2 of the proposal, published in August 2026, says the file "stays small enough to fit in context" and that the detail belongs behind the links. A 1 MB index is a sitemap with extra steps.&lt;/p&gt;

&lt;p&gt;The same version says links should point to LLM-friendly content, such as a markdown copy of each page at &lt;code&gt;page.md&lt;/code&gt; or &lt;code&gt;page.html.md&lt;/code&gt;. Of the 107 files that contain links, 74 link to no &lt;code&gt;.md&lt;/code&gt; or &lt;code&gt;.txt&lt;/code&gt; URL at all and 8 point mostly to them. I only looked at the extension, so a site that serves markdown by content negotiation on a normal URL is undercounted here. Still, most files today are a list of ordinary HTML pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Dead links, and HEAD requests that lie about them
&lt;/h3&gt;

&lt;p&gt;For every file with links I took up to 20 of them, spread evenly through the file, 2,043 links from 107 files in total. I sent HEAD first and fell back to GET only on errors.&lt;/p&gt;

&lt;p&gt;HEAD said 81 of them were 404 or 410. When I re-fetched those 81 with GET, only 42 were actually gone. dynatrace.com answers HEAD with 404 on pages that GET returns with 200, which accounts for 15 of the 39 false alarms on its own. If your link checker uses HEAD to save bandwidth, it will report rot that isn't there.&lt;/p&gt;

&lt;p&gt;The 42 confirmed dead links (2.1% of the sample) sit in 13 files, and they bunch up: 10 of 20 sampled links on moloco.com and 9 of 20 on weather.com are gone. 40 of the 42 point at the site's own host. These files were written once and the site moved on underneath them.&lt;/p&gt;

&lt;p&gt;I wanted a staleness number from &lt;code&gt;Last-Modified&lt;/code&gt; too, and it doesn't work. 74 of the 114 files send the header. 24 of those are dated the same day I fetched them, because the file is generated on each request, and one says 1 February 1980. Dead internal links are a better staleness signal than any date the server gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own
&lt;/h2&gt;

&lt;p&gt;This prints one verdict per host. It needs bash and curl.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check_llms&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; tmp
  &lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; code ctype final &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'Mozilla/5.0'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\t%{content_type}\t%{url_effective}\n'&lt;/span&gt; &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;/llms.txt"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;final&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;final&lt;/span&gt;&lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;first
  &lt;span class="nv"&gt;first&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'1s/^\xEF\xBB\xBF//'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^[[:space:]]*$'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-22s %s %-28s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ctype&lt;/span&gt;&lt;span class="p"&gt;%%;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$final&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; 200 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; no file (&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ctype&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;html&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="se"&gt;\&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; HTML page answering 200, not an llms.txt"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;final&lt;/span&gt;&lt;span class="p"&gt;%%\?*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;/llms.txt &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; redirected to &lt;/span&gt;&lt;span class="nv"&gt;$final&lt;/span&gt;&lt;span class="s2"&gt;, not an llms.txt"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"# "&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; text, but the first line is not an H1: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;first&lt;/span&gt;:0:60&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; llms.txt, &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bytes, &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^## '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; H2 sections, &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*[-*] \['&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; list links"&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
check_llms vercel.com&lt;span class="p"&gt;;&lt;/span&gt; check_llms netflix.com&lt;span class="p"&gt;;&lt;/span&gt; check_llms moloco.com&lt;span class="p"&gt;;&lt;/span&gt; check_llms pages.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it printed for me today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vercel.com             200 text/plain                   https://vercel.com/llms.txt
  -&amp;gt; llms.txt, 4868 bytes, 9 H2 sections, 25 list links
netflix.com            200 text/html                    https://www.netflix.com/NotFound?prev=https%3A%2F%2Fwww.netflix.com%2Fllms.txt
  -&amp;gt; HTML page answering 200, not an llms.txt
moloco.com             200 text/plain                   https://www.moloco.com/llms.txt
  -&amp;gt; text, but the first line is not an H1: Generated by Moloco, this is an llms.txt file designed to he
pages.dev              200 text/html                    https://www.cloudflare.com/products/pages/
  -&amp;gt; HTML page answering 200, not an llms.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script doesn't check links. For that, take the URLs out of the file and GET them; don't trust a HEAD 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change on a site today
&lt;/h2&gt;

&lt;p&gt;If you don't have an llms.txt, the first thing is to make sure &lt;code&gt;/llms.txt&lt;/code&gt; returns a real 404. A 200 HTML page there is worse than nothing, because a careless checker will tell you you're done. If you do have one: start it with &lt;code&gt;# Your Site&lt;/code&gt;, keep it under a few tens of kilobytes, and re-run a link check whenever you restructure URLs, since that's what killed the dead links above.&lt;/p&gt;

&lt;p&gt;And an llms.txt only helps if crawlers can fetch your pages in the first place. In my &lt;a href="https://dev.to/mahirhir/your-cdn-can-overrule-robotstxt-finding-the-layer-that-refuses-ai-crawlers-34kg"&gt;earlier measurement&lt;/a&gt;, 19 of 79 Amsterdam bike-shop sites refused at least one AI crawler at the server, mostly against their own robots.txt. The &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; tests that half: robots.txt against what your server actually answers GPTBot, ClaudeBot and PerplexityBot. It doesn't look at llms.txt.&lt;/p&gt;

&lt;p&gt;The raw data (one JSON line per domain, the files I fetched, the probe and analysis scripts) is kept with the date above; ask in the comments if you want the per-domain table.&lt;/p&gt;

&lt;p&gt;If you'd rather have a person go through one site end to end, the paid mini audit is on the same page as the checker. It checks AI-crawler access and machine-readability and includes paste-ready fixes (a corrected robots.txt, CDN/WAF steps for bot 403s, meta and canonical fixes, JSON-LD and llms.txt skeletons), all marked review before deploying. It queries no AI engine and measures no citations or rankings.&lt;/p&gt;

&lt;p&gt;Correction 2026-10-06: an earlier version said the paid audit measures what AI engines answer; it checks crawler access and machine-readability only.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>llm</category>
    </item>
    <item>
      <title>Your CDN can overrule robots.txt: finding the layer that refuses AI crawlers</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Sun, 04 Oct 2026 13:22:33 +0000</pubDate>
      <link>https://dev.to/mahirhir/your-cdn-can-overrule-robotstxt-finding-the-layer-that-refuses-ai-crawlers-34kg</link>
      <guid>https://dev.to/mahirhir/your-cdn-can-overrule-robotstxt-finding-the-layer-that-refuses-ai-crawlers-34kg</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/mahirhir/your-robotstxt-allows-gptbot-your-server-may-still-answer-403-5h5d"&gt;the first post&lt;/a&gt; I found Japanese tourism sites whose robots.txt welcomes GPTBot while the server answers it with 403. This one is about the next question: which layer is saying no, did anyone mean it, and what do you change.&lt;/p&gt;

&lt;p&gt;I ran the same kind of check on a different set of sites first, to see whether the pattern was a Japanese hosting quirk. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Amsterdam bike shops, measured on 2026-10-04
&lt;/h2&gt;

&lt;p&gt;Source: every OpenStreetMap object tagged &lt;code&gt;shop=bicycle&lt;/code&gt; with a &lt;code&gt;website&lt;/code&gt; tag inside the Amsterdam municipality boundary, pulled from the Overpass API on 2026-10-04. That gave 108 objects and 92 unique hostnames. It is mostly independent repair shops and rental places, with a few chain stores and a couple of oddly tagged entries mixed in. I didn't hand-pick anything.&lt;/p&gt;

&lt;p&gt;Each home page got one request with a browser user agent and one each with the published GPTBot, ClaudeBot and PerplexityBot user agents, from a single ordinary connection. Every site that refused a crawler got a second pass a few minutes later, and I only count a refusal if the same crawler got 403 both times. That rule threw out a handful of 429s, 503s and one timeout that looked like rate limiting or flaky hosting rather than a policy.&lt;/p&gt;

&lt;p&gt;13 of the 92 hosts didn't return 200 to the browser at all (dead domains, timeouts, server errors), which leaves 79. Of those 79, 19 returned 403 to at least one AI crawler user agent on both passes while the browser got 200. That's 24%, close to the quarter I saw in the Japanese sample (6 of 23).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On 15 of the 19, robots.txt allows the crawler that was refused. 1 disallows it, so robots.txt and the server agree there, and 3 have no robots.txt.&lt;/li&gt;
&lt;li&gt;ClaudeBot was refused on all 19, GPTBot on 14, PerplexityBot on 5. Five sites refuse ClaudeBot and nothing else. 12 of the 19 also refuse &lt;code&gt;/robots.txt&lt;/code&gt; itself when asked as GPTBot.&lt;/li&gt;
&lt;li&gt;A made-up user agent (&lt;code&gt;ExampleFetchBot/1.0&lt;/code&gt;) got 200 on 18 of the 19. These rules name AI crawlers; they aren't generic "block anything with bot in it" filters.&lt;/li&gt;
&lt;li&gt;5 refusals come from Cloudflare's edge (out of 21 sites served through Cloudflare). The other 14 come from the origin: 8 answer as nginx, 3 as Apache, 1 as LiteSpeed, 1 as OpenResty and 1 sends no Server header.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To rule out my own connection as the cause, I ran 6 of the 19 through a second checker hosted on Vercel, so a different network. All 6 returned the same status codes.&lt;/p&gt;

&lt;p&gt;n is 79 reachable sites in one city and one trade, so don't read 24% as a rate for small businesses in general. The useful part is that two unrelated samples on two continents show the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the layer before you change anything
&lt;/h2&gt;

&lt;p&gt;A request passes through up to four places that can refuse it: a CDN or WAF at the edge, a reverse proxy, the web server, and the application (a CMS security plugin, for example). The response usually tells you which one answered. Run this against your own site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com/
probe&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-9s '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP/|server:|cf-mitigated:)'&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; -
&lt;span class="o"&gt;}&lt;/span&gt;
probe browser   &lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36'&lt;/span&gt;
probe GPTBot    &lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)'&lt;/span&gt;
probe ClaudeBot &lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)'&lt;/span&gt;
probe made-up   &lt;span class="s1"&gt;'Mozilla/5.0 (compatible; ExampleFetchBot/1.0; +https://example.com/bot)'&lt;/span&gt;
&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;robots.txt probe robots-as-GPTBot &lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of the nginx sites from the sample prints this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;browser   &lt;/span&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK Server: nginx&lt;/span&gt;
&lt;span class="s"&gt;GPTBot    HTTP/1.1 403 Forbidden Server: nginx&lt;/span&gt;
&lt;span class="s"&gt;ClaudeBot HTTP/1.1 403 Forbidden Server: nginx&lt;/span&gt;
&lt;span class="s"&gt;made-up   HTTP/1.1 200 OK Server: nginx&lt;/span&gt;
&lt;span class="s"&gt;robots-as-GPTBot HTTP/1.1 403 Forbidden Server: nginx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to read it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see on the 403&lt;/th&gt;
&lt;th&gt;Who refused&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Server: cloudflare&lt;/code&gt;, page titled "Attention Required! | Cloudflare" with "Sorry, you have been blocked"&lt;/td&gt;
&lt;td&gt;A Cloudflare block: a WAF custom rule, a managed rule, or the AI bot setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Server: cloudflare&lt;/code&gt; plus &lt;code&gt;cf-mitigated: challenge&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A Cloudflare challenge (Bot Fight Mode or a rule with a challenge action). A crawler can't solve it, so it's a refusal in practice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Server: nginx&lt;/code&gt;, &lt;code&gt;Apache&lt;/code&gt; or &lt;code&gt;LiteSpeed&lt;/code&gt;, small plain error page&lt;/td&gt;
&lt;td&gt;The web server or something behind it. &lt;code&gt;/robots.txt&lt;/code&gt; refused too means the rule runs before any per-path logic, typically a server-wide user-agent match or an &lt;code&gt;.htaccess&lt;/code&gt; rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 from the server, but a CMS error or login page in the body&lt;/td&gt;
&lt;td&gt;An application plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Server header names the outermost server that answered, not necessarily the one that made the decision. One of the "nginx" sites returns Apache's stock "You don't have permission to access this resource" page. That's the usual shared-hosting setup: nginx in front, Apache behind it, and the rule sits in Apache config or &lt;code&gt;.htaccess&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intentional, or two layers disagreeing?
&lt;/h2&gt;

&lt;p&gt;Blocking AI crawlers is a reasonable choice and plenty of owners make it on purpose. What you're checking for is whether the site says one thing and does another. Three questions settle most cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What does robots.txt say about this crawler? If it disallows the bot and the server refuses it, the layers agree. That was 1 of the 19. If robots.txt allows it and the server refuses, someone made one decision and something else made the opposite one.&lt;/li&gt;
&lt;li&gt;Does a made-up bot get through? If &lt;code&gt;ExampleFetchBot&lt;/code&gt; gets 200 and GPTBot gets 403, the rule names AI crawlers. Someone or something wrote that list. If the made-up bot also gets 403, you're looking at a generic bot filter that probably never had AI crawlers in mind.&lt;/li&gt;
&lt;li&gt;Who controls the layer that answered? Shared hosts and site builders ship their own user-agent blocklists, and security plugins add them on update. The owner of the site often never saw the rule. Ask the host, or search the config: &lt;code&gt;grep -rn -i 'gptbot\|claudebot' /etc/nginx/ .htaccess&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you run the server, the access log answers the question for the real crawler, not just my imitation of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'GPTBot|ClaudeBot|PerplexityBot'&lt;/span&gt; /var/log/nginx/access.log &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{ match($0, /GPTBot|ClaudeBot|PerplexityBot/); print substr($0, RSTART, RLENGTH), $9 }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints a count per crawler and status code. It assumes the default &lt;code&gt;combined&lt;/code&gt; log format, where field 9 is the status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes, layer by layer
&lt;/h2&gt;

&lt;p&gt;Decide what you want first, then write it in robots.txt, because that's the layer the crawlers read and the one you can show to anyone who asks. To let the three in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;-agent: GPTBot
&lt;span class="nc"&gt;User&lt;/span&gt;-agent: ClaudeBot
&lt;span class="nc"&gt;User&lt;/span&gt;-agent: PerplexityBot
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /

&lt;span class="nc"&gt;User&lt;/span&gt;-agent: *
Disallow: /wp-admin/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To keep training crawlers out but stay visible to Perplexity's search crawler, swap the first group for &lt;code&gt;User-agent: GPTBot&lt;/code&gt;, &lt;code&gt;User-agent: ClaudeBot&lt;/code&gt;, &lt;code&gt;Disallow: /&lt;/code&gt;. Then make every other layer agree with that file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare
&lt;/h3&gt;

&lt;p&gt;Three settings can produce these refusals, and they behave differently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bot Fight Mode (Free plan, Security settings). Cloudflare's docs say it "cannot be bypassed or skipped using WAF custom rules or Page Rules". On the Free plan you either leave it on and accept the challenges, or turn it off.&lt;/li&gt;
&lt;li&gt;Super Bot Fight Mode (Pro, Business and Enterprise) can be skipped. Add a WAF custom rule with the Skip action, pick Super Bot Fight Mode as the thing to skip, and use an expression that only matches crawlers Cloudflare has verified:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cfscript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cf.verified_bot_category&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"AI Crawler"&lt;/span&gt; &lt;span class="s2"&gt;"AI Search"&lt;/span&gt; &lt;span class="s2"&gt;"AI Assistant"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put it above any rule that blocks. Cloudflare verifies these bots by where the request comes from, not by the user agent string, so this doesn't open the door to anyone who copies GPTBot's user agent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI bot policies (Security settings, "Configure AI bot policies", which replaced the older "Block AI bots" toggle). Per Cloudflare's docs, from 2026-09-15 new domains block Training and Agent bots on pages that show ads by default, and "mixed-purpose crawlers that combine Search and Training" are blocked by every configuration that blocks training. If you added a domain recently and never opened this page, check it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather write your own block for impostors, this expression matches requests that claim to be GPTBot but don't come from a verified bot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.user_agent contains "GPTBot" and not cf.client.bot)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked both expressions with Cloudflare's open-source rules parser (&lt;a href="https://github.com/cloudflare/wirefilter" rel="noopener noreferrer"&gt;wirefilter&lt;/a&gt;). Two deliberately broken versions, one with a missing closing brace and one with a typo in &lt;code&gt;and&lt;/code&gt;, fail with a parse error, so the check does reject bad input. The parser checks syntax against field types I declared. The dashboard's expression editor is still the final word.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx
&lt;/h3&gt;

&lt;p&gt;The accidental version usually looks like this, copied from a "block bad bots" list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_user_agent&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;"(bot|crawl|spider)")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At server level it refuses every crawler, &lt;code&gt;/robots.txt&lt;/code&gt; included. I ran it locally and GPTBot, ClaudeBot and a made-up scraper all got 403 on both &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/robots.txt&lt;/code&gt;. This replacement lets the named AI crawlers through, keeps the generic filter for everything else, and always serves robots.txt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_user_agent&lt;/span&gt; &lt;span class="nv"&gt;$block_ua&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt;                                            &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;~*&lt;/span&gt;&lt;span class="s"&gt;(GPTBot|ClaudeBot|PerplexityBot|OAI-SearchBot)&lt;/span&gt;   &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;~*&lt;/span&gt;&lt;span class="s"&gt;(bot|crawl|spider|scrape)&lt;/span&gt;                        &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/robots.txt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$block_ua&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;map&lt;/code&gt; goes in the &lt;code&gt;http&lt;/code&gt; block. Regex entries are checked in order and the first match wins, so the allow line has to come before the generic one. I tested this with nginx 1.28.0: &lt;code&gt;nginx -t&lt;/code&gt; passes, and against a running instance the browser, GPTBot and ClaudeBot got 200 while &lt;code&gt;SomeScraperBot/2.0&lt;/code&gt; got 403 on &lt;code&gt;/&lt;/code&gt; and 200 on &lt;code&gt;/robots.txt&lt;/code&gt;. Deleting the semicolon on the &lt;code&gt;default&lt;/code&gt; line makes &lt;code&gt;nginx -t&lt;/code&gt; fail, which is how I know the test isn't just saying yes. If you want the crawlers blocked instead, move them to the &lt;code&gt;1&lt;/code&gt; side and keep the robots.txt location, so they can still read the file that tells them so.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared hosting, Apache and plugins
&lt;/h3&gt;

&lt;p&gt;When the 403 comes from a host you don't control, look for the crawler names in &lt;code&gt;.htaccess&lt;/code&gt; (a &lt;code&gt;RewriteCond %{HTTP_USER_AGENT}&lt;/code&gt; line followed by a rule with &lt;code&gt;[F]&lt;/code&gt;), then in your security plugin's bot or firewall settings. If you find nothing, ask the host's support whether they block AI crawlers by user agent at the server level. On 12 of the 19 sites above even robots.txt is refused, which suggests a rule that runs before the site's own files are consulted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this check can't tell you
&lt;/h2&gt;

&lt;p&gt;My probes send the crawler's user agent from an ordinary connection. The real crawlers come from their operators' published IP ranges. Origin rules that match on the user agent (the 14 nginx, Apache and LiteSpeed cases) treat both the same way, so those results should hold for the real crawler. Cloudflare can tell the two apart. Its AI bot setting covers verified crawlers "as well as a number of unverified bots that behave similarly", so a 403 to my imitation may be Cloudflare stopping an impostor while the real GPTBot gets through. For the 5 Cloudflare cases, Security Events in the dashboard or the origin access log is the only reliable answer.&lt;/p&gt;

&lt;p&gt;A 200 also doesn't promise anything shows up in AI answers. This only removes one reason a site might be missing.&lt;/p&gt;

&lt;p&gt;If you want to test a site without the shell, the &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;free AI crawler check&lt;/a&gt; runs the same robots.txt-versus-server comparison for GPTBot, ClaudeBot and PerplexityBot and stores nothing. If you'd like a person to go through one site end to end, the paid mini audit is on that same page. It checks AI-crawler access and machine-readability and includes paste-ready fixes (a corrected robots.txt, CDN/WAF steps for bot 403s, meta and canonical fixes, JSON-LD and llms.txt skeletons), all marked review before deploying. It queries no AI engine and measures no citations or rankings.&lt;/p&gt;

&lt;p&gt;Correction 2026-10-06: an earlier version said the paid audit measures what AI engines answer; it checks crawler access and machine-readability only.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Your robots.txt allows GPTBot. Your server may still answer 403.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:32:35 +0000</pubDate>
      <link>https://dev.to/mahirhir/your-robotstxt-allows-gptbot-your-server-may-still-answer-403-5h5d</link>
      <guid>https://dev.to/mahirhir/your-robotstxt-allows-gptbot-your-server-may-still-answer-403-5h5d</guid>
      <description>&lt;p&gt;I was checking small business websites for how readable they are to AI answer engines, and kept running into the same thing: robots.txt allows GPTBot and ClaudeBot, and the server answers them with 403 anyway.&lt;/p&gt;

&lt;p&gt;Nobody reading robots.txt would notice. A robots.txt checker would report "AI crawlers allowed". The block sits one layer lower, in the web server or a security plugin, and it matches on the user-agent string.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;p&gt;On 2026-10-04 I probed 26 websites of small Japanese tourism businesses (guesthouses, cooking classes, kimono rental, pottery and tea-ceremony studios). I found them through search and AI-answer queries for those categories, so this is a convenience sample, not a random one. Each site got one request per user agent, from one residential connection in Japan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 of the 26 did not answer at all (timeout or connection error), which leaves 23.&lt;/li&gt;
&lt;li&gt;5 of those 23 returned 403 to both the GPTBot and the ClaudeBot user agent while a browser user agent got 200. All 5 have a robots.txt that allows both crawlers.&lt;/li&gt;
&lt;li&gt;1 more returned 403 to ClaudeBot only. That site has no readable robots.txt.&lt;/li&gt;
&lt;li&gt;The same 5 sites returned 200 to OAI-SearchBot and PerplexityBot. The rule targets the training crawlers by name, not every bot.&lt;/li&gt;
&lt;li&gt;4 of the 5 send &lt;code&gt;Server: nginx&lt;/code&gt;, and on those 4 even &lt;code&gt;/robots.txt&lt;/code&gt; returns 403 when requested as GPTBot. The crawler is refused the file that says it is welcome.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So in this sample, 6 of 23 reachable sites (about a quarter) refuse at least one of the two training crawlers at the server, and none of them say so in robots.txt. I would not stretch that ratio to "small business sites" in general. n is 23 and the sample leans toward Japanese tourism. The pattern is the useful part, and you can check your own site in under a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it yourself with curl
&lt;/h2&gt;

&lt;p&gt;These are the user-agent strings the crawlers publish. Replace &lt;code&gt;https://example.com/&lt;/code&gt; with your own site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com/
&lt;span class="nv"&gt;BROWSER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36'&lt;/span&gt;
&lt;span class="nv"&gt;GPTBOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)'&lt;/span&gt;
&lt;span class="nv"&gt;CLAUDEBOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)'&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'browser   %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BROWSER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'GPTBot    %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPTBOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'ClaudeBot %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDEBOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt as GPTBot %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPTBOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;robots.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On glovrex.com, a site I run (hosted on Vercel, robots.txt allows everyone, no user-agent rules on the server), all four lines print 200:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser   200
GPTBot    200
ClaudeBot 200
robots.txt as GPTBot 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the four nginx sites from the sample the output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser   200
GPTBot    403
ClaudeBot 403
robots.txt as GPTBot 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see that second shape, also run &lt;code&gt;curl -s "${URL}robots.txt"&lt;/code&gt; to read what your robots.txt actually says. If it allows the crawler and the server refuses it, the two layers disagree, and the server wins. A 404 on the robots.txt line is fine, it only means you have no robots.txt. Sending just &lt;code&gt;-A GPTBot&lt;/code&gt; gave the same 403 on all 4 nginx sites, which points to a plain substring match on the user agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters for AI answers
&lt;/h2&gt;

&lt;p&gt;There are two ways an AI assistant ends up saying something about a business. It can search the web at question time, using crawlers like OAI-SearchBot or Claude-SearchBot. Or it can answer from what the model already absorbed in training, which is where GPTBot and ClaudeBot come in.&lt;/p&gt;

&lt;p&gt;The sites above still let the search-time crawlers in, so a browsing assistant can read them. What they lose is the second path. When a model answers without searching, it knows the business only through other pages that mention it, usually booking platforms, listicles and review sites. In one sample check I ran on a guesthouse from this group, an AI answer about the business cited only booking platforms, and the prices it quoted were the platforms' prices, not the ones on the official site. That is one answer from one engine, not proof of cause, but it is what you would expect if the official site is missing from training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats before you change anything
&lt;/h2&gt;

&lt;p&gt;Blocking training crawlers is a legitimate choice. Plenty of site owners do it on purpose, and the crawler operators document how to opt out for exactly that reason. The problem I am pointing at is only the case where nobody chose it: robots.txt was written to allow the crawlers, and a hosting default or a security plugin overrode it without anyone noticing.&lt;/p&gt;

&lt;p&gt;My probe sends the published user-agent string from an ordinary IP. The real crawlers come from their operators' IP ranges, and some setups (Cloudflare's bot settings, for example) treat a verified crawler differently from someone who just copied its user agent. One of the 5 sites in the sample is behind Cloudflare, so for that one my result may not match what the real crawler sees. The way to be sure is your server's access log: search it for &lt;code&gt;GPTBot&lt;/code&gt; and &lt;code&gt;ClaudeBot&lt;/code&gt; and look at the status codes.&lt;/p&gt;

&lt;p&gt;A 200 from your server is also not a promise that anything will show up in AI answers. Off-site reputation still decides most of who gets named. This check only removes one reason you might be missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to go further
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/mahirhir/geo-checker" rel="noopener noreferrer"&gt;geo-checker&lt;/a&gt; is a free MIT tool I wrote that scores a page for structured data, headings and robots.txt rules. It reads robots.txt only, so it would not have caught any of the 6 sites above. The repository is archived, so take it as it is. If you would rather have a person run the full check, I do a paid &lt;a href="https://geo-audit-mini.vercel.app" rel="noopener noreferrer"&gt;GEO mini audit&lt;/a&gt; for one site: AI-crawler access and machine-readability, plus paste-ready fixes (a corrected robots.txt, CDN/WAF steps for bot 403s, meta and canonical fixes, JSON-LD and llms.txt skeletons), all marked review before deploying. It queries no AI engine and measures no citations or rankings.&lt;/p&gt;

&lt;p&gt;Follow-up: &lt;a href="https://dev.to/mahirhir/your-cdn-can-overrule-robotstxt-finding-the-layer-that-refuses-ai-crawlers-34kg"&gt;how to find which layer (CDN, web server or plugin) is refusing the crawler, and how to fix each one&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Correction 2026-10-06: an earlier version said the paid audit measures what AI engines answer; it checks crawler access and machine-readability only.&lt;/p&gt;

</description>
      <category>geo</category>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A six-line edit made fifteen units stale. The freshness check was working correctly.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Mon, 14 Sep 2026 19:32:21 +0000</pubDate>
      <link>https://dev.to/mahirhir/a-six-line-edit-made-fifteen-units-stale-the-freshness-check-was-working-correctly-1en6</link>
      <guid>https://dev.to/mahirhir/a-six-line-edit-made-fifteen-units-stale-the-freshness-check-was-working-correctly-1en6</guid>
      <description>&lt;p&gt;Every unit of this project carries a record of its last verification, and each record pins the configuration it was checked against by hashing it. If the configuration changes, the pin no longer reproduces and the record goes stale, which is exactly what you want: a verification result that quietly outlives its inputs is worse than no result.&lt;/p&gt;

&lt;p&gt;One afternoon I added a six-line block to that configuration file. It described a new component and touched nothing else.&lt;/p&gt;

&lt;p&gt;Fifteen units went stale. Every one of them owed a full re-run of its checks and a fresh record appended afterwards, which is most of an hour of machine time and a boundary ceremony, for six lines that concerned one of them.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The check did precisely what it promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pin was over the whole file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pin = sha256(entire configuration file)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any byte anywhere changes that hash. So the pin does not mean "the configuration this unit depends on is unchanged". It means "nobody has edited this file at all", which is a far stronger statement and one that becomes false constantly in a file every component writes to.&lt;/p&gt;

&lt;p&gt;The gap between what a pin means and what everyone reads it to mean is the whole bug, and it is invisible while the file is small.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what the pin says&lt;/th&gt;
&lt;th&gt;what everyone reads it as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;this file is byte-identical&lt;/td&gt;
&lt;td&gt;my inputs are unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;any edit invalidates me&lt;/td&gt;
&lt;td&gt;edits to my section invalidate me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The failure mode is people, not machines
&lt;/h2&gt;

&lt;p&gt;An hour of machine time is affordable. What is not affordable is what it does to behaviour.&lt;/p&gt;

&lt;p&gt;When correcting a six-line block costs fifteen re-runs, three things start happening. I watched all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;edits get batched until there are enough to justify the ceremony, so the configuration is wrong for longer&lt;/li&gt;
&lt;li&gt;the staleness line gets read past, because it is almost always red for reasons unrelated to what you are doing&lt;/li&gt;
&lt;li&gt;someone proposes re-pinning without re-running, which is the point where the check stops meaning anything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A check whose cost is out of proportion to the change gets routed around. It does not get fixed, because it is not broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair is a declared partition
&lt;/h2&gt;

&lt;p&gt;The pin became the hash of the unit's own block plus the sections genuinely shared between units, rather than the whole file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pin = sha256(block for this unit) + sha256(shared sections)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties matter more than the saving. The partition is &lt;strong&gt;declared&lt;/strong&gt;, as rows in the file itself rather than inferred by a parser guessing at section boundaries, so it is readable and arguable. And it is &lt;strong&gt;recomputable&lt;/strong&gt; by hand, since the recipe is printed next to the hash and anyone can rerun it with &lt;code&gt;sha256sum&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now an edit to one unit's block stales one unit. An edit to a shared section stales everything, and that is correct, because everything does depend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check earlier next time
&lt;/h2&gt;

&lt;p&gt;There is a cheap diagnostic I did not run for months. Take the last N edits to the file and ask, for each, how many units the pin invalidated and how many actually depended on the change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;edits sampled          20
units invalidated      15 each time
units actually affected 1, 1, 1, 2, 1, 15, 1, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row genuinely needed all fifteen. The rest were a partition problem visible in five minutes of arithmetic over the git log, and the ratio is the argument for fixing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I keep
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hash granularity is an interface, not an implementation detail.&lt;/strong&gt; Choosing the whole file is choosing "any edit invalidates every dependant", and that choice deserves the same scrutiny as any other contract, especially in a file that many components share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Correct but disproportionate" is a defect class of its own.&lt;/strong&gt; It never shows up as a failing check, so nothing surfaces it. It shows up as people batching their edits, skimming past a red line, and eventually proposing to weaken the check, and by then the conversation is about the check rather than about its granularity.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>programming</category>
      <category>testing</category>
    </item>
    <item>
      <title>The OOM killer stopped my acceptance check four times in two days. It was rebuilding the project to read a number.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:58:29 +0000</pubDate>
      <link>https://dev.to/mahirhir/the-oom-killer-stopped-my-acceptance-check-four-times-in-two-days-it-was-rebuilding-the-project-to-42f6</link>
      <guid>https://dev.to/mahirhir/the-oom-killer-stopped-my-acceptance-check-four-times-in-two-days-it-was-rebuilding-the-project-to-42f6</guid>
      <description>&lt;p&gt;There is a step at the end of each work session that decides whether each unit of the project is accepted. It gathers, for every unit, whether its test suite passed, whether its checks are current, whether its documentation was regenerated, and prints one line per unit.&lt;/p&gt;

&lt;p&gt;It kept dying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;acceptance read: killed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four times in two days. No error, no stack, just gone. The machine's memory limit, with nothing else running. On the fourth kill I stopped restarting it and read what it does.&lt;/p&gt;

&lt;p&gt;It was compiling the project. To find out whether the tests passed, it ran them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word "read" was doing a lot of work
&lt;/h2&gt;

&lt;p&gt;The step is called a read, and I had thought of it as one for months. It sounded like gathering. What it actually did, for each of fifteen units, was invoke the build and wait.&lt;/p&gt;

&lt;p&gt;That is not a read. It is a second full build whose output nobody looks at except to extract a boolean.&lt;/p&gt;

&lt;p&gt;The memory limit was not the problem. It was the only thing telling me anything. A build is the most expensive operation in the repository, and the acceptance step ran fifteen of them in sequence, on a machine that can comfortably run one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why re-running looks like the rigorous choice
&lt;/h2&gt;

&lt;p&gt;Here is the argument I would have made for the old design, and it is not stupid:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A recorded result is a claim about the past. The tree may have changed since. Running it now is the only way to know it holds now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is correct in general and wrong here, because of what the recording already is. Every control run appends a block to a permanent record, and that block carries the commit it ran at plus its verbatim output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;control block   unit=projection  commit=26ef4cc  OK_ALL controls=13  run=2026-09-13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the record is not someone's summary. It is the control's own print, pinned to a commit. Asking "did this pass at this commit" is answerable from it exactly, and re-running the build answers the same question by doing the same work again.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;question&lt;/th&gt;
&lt;th&gt;how it should answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the control&lt;/td&gt;
&lt;td&gt;does this hold right now&lt;/td&gt;
&lt;td&gt;run it, append the print&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the acceptance read&lt;/td&gt;
&lt;td&gt;what do the controls say at this commit&lt;/td&gt;
&lt;td&gt;read the record&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two steps, two questions. The acceptance step had been answering the first question while being named for the second, so it duplicated the control's work and added nothing except a way to run out of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The staleness question, which is the real one
&lt;/h2&gt;

&lt;p&gt;"What if the record is from an older commit?" is the objection that matters, and it is answerable without building anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ACCEPT_GATE  current=S  next=face  recorded=9/15  stale=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The read compares each block's commit against the commit being accepted. If they differ, that unit is stale and says so. Staleness becomes a printed number rather than something the design tries to make impossible by brute force.&lt;/p&gt;

&lt;p&gt;That is the useful move: &lt;strong&gt;the cost of re-deriving was buying a property that a comparison provides for free.&lt;/strong&gt; Freshness is a question about two identifiers. It does not need a compiler.&lt;/p&gt;

&lt;h2&gt;
  
  
  A general form I keep coming back to
&lt;/h2&gt;

&lt;p&gt;Any step whose name is a verb of observation, read, check, report, gather, audit, that takes minutes and consumes gigabytes, is probably not observing. Something inside it is re-deriving a fact that already exists somewhere.&lt;/p&gt;

&lt;p&gt;The tell is resource usage that does not match the verb. An observation's cost should scale with how much there is to observe, not with how expensive the thing observed was to produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I keep
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A read that builds is not a read.&lt;/strong&gt; Separating "produce the fact" from "collect the facts" is not tidiness; it is what makes the collecting step cheap enough to run at every boundary instead of once a day when someone is brave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a step keeps being killed, read it before restarting it.&lt;/strong&gt; I restarted three times and reduced parallelism twice, both of which are treatments for a resource problem. The fourth kill was the first time I asked what the step was actually doing, and the answer took two minutes to find.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>architecture</category>
      <category>testing</category>
      <category>programming</category>
    </item>
    <item>
      <title>My coverage check did arithmetic instead of looking. All 23 pointers were wrong.</title>
      <dc:creator>Mahiro Hirakawa</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:03:19 +0000</pubDate>
      <link>https://dev.to/mahirhir/my-coverage-check-did-arithmetic-instead-of-looking-all-23-pointers-were-wrong-502e</link>
      <guid>https://dev.to/mahirhir/my-coverage-check-did-arithmetic-instead-of-looking-all-23-pointers-were-wrong-502e</guid>
      <description>&lt;p&gt;A table in one document points into another. Each of its 24 rows carries a source pointer saying where the thing it describes actually lives, so a reader can go and check.&lt;/p&gt;

&lt;p&gt;The coverage check for that table reported complete, always. Here is what it did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows in the table        24
rows carrying a pointer  23
rows that are a vessel    1
24 - 23 - 1 = 0 uncovered  -&amp;gt;  complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is arithmetic over counts. It never opens the file the pointers point into. A pointer can name a line that does not exist, or the wrong line, or a line in a file that was deleted, and the subtraction still comes out to zero.&lt;/p&gt;

&lt;p&gt;When something finally resolved them, all 23 were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong by one or two lines, which is the informative part
&lt;/h2&gt;

&lt;p&gt;Not wrong by hundreds. Every pointer was off by one or two lines against the target.&lt;/p&gt;

&lt;p&gt;That number tells you which failure this is. A pointer that drifts as a file grows ends up off by however many lines were inserted above it, which after a year is a large and varied number. Off by one or two, uniformly, across every row, is not drift. It is an off-by-one in whatever produced them, present from the first commit.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;symptom&lt;/th&gt;
&lt;th&gt;drift&lt;/th&gt;
&lt;th&gt;never worked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;offsets&lt;/td&gt;
&lt;td&gt;large, varied&lt;/td&gt;
&lt;td&gt;small, uniform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;when introduced&lt;/td&gt;
&lt;td&gt;gradually&lt;/td&gt;
&lt;td&gt;at creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repair&lt;/td&gt;
&lt;td&gt;re-point and add a freshness check&lt;/td&gt;
&lt;td&gt;fix the generator, then re-point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;how it reads in a log&lt;/td&gt;
&lt;td&gt;"these have gone stale"&lt;/td&gt;
&lt;td&gt;"these have gone stale"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two look identical in the report. They want different repairs, and only the distribution of the offsets tells you which one you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same defect in four tools in one week
&lt;/h2&gt;

&lt;p&gt;While fixing it I found that four separate readers were joining two tables the same way: by row order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="nx"&gt;here&lt;/span&gt; &lt;span class="nx"&gt;describes&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="nx"&gt;there&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Position is a key that every insertion invalidates. Add a row to one side and every pair after it is silently wrong, which is exactly what had happened. Nothing errors, because both sides still have rows at those indices.&lt;/p&gt;

&lt;p&gt;All four became keyed joins, matching on an identifier that travels with the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;unmatched&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;either&lt;/span&gt; &lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;named&lt;/span&gt; &lt;span class="nx"&gt;finding&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference that matters is not correctness in the happy case. It is that a keyed join can report &lt;code&gt;MISSING&lt;/code&gt;, and a positional join has no way to express it. A positional join cannot fail; it can only be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced the subtraction
&lt;/h2&gt;

&lt;p&gt;Each row now carries the pointer plus a verbatim quote of what it points at. Coverage is the count of rows whose quote still matches the bytes at that location, which is a question about the world rather than about the table's own shape.&lt;/p&gt;

&lt;p&gt;Retired rows moved out of the denominator entirely, as pointer lines that no counter reads.&lt;/p&gt;

&lt;p&gt;The satisfying part: after the repair, no count moved. The table had said complete and, once every pointer was actually resolved, it was complete. Every one of those 23 pointers had been wrong, and the number the check reported was right. It was right for no reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I keep
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A coverage number computed from counts is a statement about your table, not about your code.&lt;/strong&gt; If the check never opens the thing being pointed at, it cannot go red for the reason you built it, and the correct total it prints is a coincidence you should not spend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the distribution of the errors, not just how many.&lt;/strong&gt; "All 23 are stale" and "all 23 are off by one" are the same count and different bugs. One asks for a freshness check. The other asks you to fix the thing that wrote them, or you will re-point 23 rows and generate 23 more.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>architecture</category>
      <category>programming</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
