<?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: danielgreid</title>
    <description>The latest articles on DEV Community by danielgreid (@danielgreid).</description>
    <link>https://dev.to/danielgreid</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%2F4046003%2F99bde4c6-25ee-476a-b0ee-c1e7f0377024.png</url>
      <title>DEV Community: danielgreid</title>
      <link>https://dev.to/danielgreid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danielgreid"/>
    <language>en</language>
    <item>
      <title>A 200 Response Does Not Prove an AI Crawler Can Read Your Site</title>
      <dc:creator>danielgreid</dc:creator>
      <pubDate>Tue, 28 Jul 2026 02:44:41 +0000</pubDate>
      <link>https://dev.to/danielgreid/a-200-response-does-not-prove-an-ai-crawler-can-read-your-site-10a8</link>
      <guid>https://dev.to/danielgreid/a-200-response-does-not-prove-an-ai-crawler-can-read-your-site-10a8</guid>
      <description>&lt;p&gt;A crawlability check often begins with a comforting result: request the homepage, receive HTTP 200, and declare the site open. That conclusion is too broad. The response only proves that one client, from one network, with one user agent and one request shape, reached one representation of the page at one moment.&lt;/p&gt;

&lt;p&gt;AI crawlers do not necessarily share any of those conditions. A CDN may classify their user agents differently. A robots rule may apply to one bot but not another. A challenge page may return 200 while replacing the actual document. Even when the HTML is real, the canonical URL or robots metadata can make the page unusable for discovery.&lt;/p&gt;

&lt;p&gt;The practical fix is to stop treating the status code as the verdict. Treat it as one item in an evidence chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same URL can represent different access paths
&lt;/h2&gt;

&lt;p&gt;Consider three requests to the same page. A normal browser user agent receives the product page. A generic script receives a managed challenge. A named crawler receives a 403 from a WAF rule. The URL has not changed, but the effective access policy has.&lt;/p&gt;

&lt;p&gt;This is why a single curl command is a poor proxy for crawler access. It compresses transport, policy, identity, and content into one number. When the number is 200, it also hides soft failures: consent walls, bot challenges, login interstitials, and branded not-found pages can all be successful HTTP responses.&lt;/p&gt;

&lt;p&gt;A useful probe records at least the requested user agent, status, final URL, content type, title, canonical URL, robots directives, and a small fingerprint of the returned body. The fingerprint does not need to retain the page. It only needs to show whether two clients received materially different documents.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;title&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/just a moment|verify you are human/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;The important property is not sophistication. It is comparability. Run the same probe with a baseline browser identity and each crawler identity, then explain the difference instead of guessing from one response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Probe policy, transport, and page identity separately
&lt;/h2&gt;

&lt;p&gt;Robots policy should be evaluated before interpreting a network response. Parse the groups for the exact bot token, apply the longest matching rule, and report which rule won. Do not assume that a wildcard group describes every named bot. Also keep robots decisions separate from firewall decisions: an allowed robots rule cannot override a CDN block, and a successful fetch does not erase a disallow rule.&lt;/p&gt;

&lt;p&gt;Next, classify the transport outcome. DNS failure, timeout, TLS failure, 429, 403, and a Cloudflare challenge are different root causes. Lumping all of them into “blocked” makes the suggested repair unreliable. A timeout suggests availability or routing work. A 429 suggests pacing. A hard WAF block requires a rule review. A challenge means the probe did not obtain the target document, even if the status is 200.&lt;/p&gt;

&lt;p&gt;Finally, verify page identity. Compare the final URL and canonical path with the requested page. Look for a plausible title and expected content markers. Detect obvious soft-404 titles. Read page-level robots metadata. Only after those checks should the system say that the crawler received the intended, indexable document.&lt;/p&gt;

&lt;p&gt;I use this layered approach in a free &lt;a href="https://aicrawlable.com/" rel="noopener noreferrer"&gt;AI crawler accessibility checker&lt;/a&gt; because a useful diagnostic should explain which gate failed, not merely display a red badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make uncertainty an explicit result
&lt;/h2&gt;

&lt;p&gt;Some sites verify crawler IP ranges or signed requests in addition to the user agent. A public diagnostic cannot honestly impersonate those network identities. In that case, the correct result is not “allowed” or “blocked.” It is “the user-agent path passed, but origin verification was not tested.”&lt;/p&gt;

&lt;p&gt;That distinction matters operationally. False confidence can leave important pages invisible, while false alarms encourage site owners to weaken WAF rules unnecessarily. Both are worse than a bounded answer.&lt;/p&gt;

&lt;p&gt;The same principle applies to remediation. Recommend the smallest layer-specific change and preserve the evidence that justified it. If a robots group blocks a bot, show the matching lines. If a CDN returns a hard block, capture the classification and request identifier without storing cookies. If the page is a soft 404, show the title and canonical mismatch. A future recheck can then prove whether the specific failure changed.&lt;/p&gt;

&lt;p&gt;A status code is still useful. It is simply not a conclusion. Crawlability is the conjunction of policy permission, network reachability, document identity, and page-level indexability. Measure those gates independently, preserve the first real cause, and leave uncertainty visible when the probe cannot test a provider-specific identity.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Inferring Site Structure When the Site Doesn't Have Any</title>
      <dc:creator>danielgreid</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:07:41 +0000</pubDate>
      <link>https://dev.to/danielgreid/inferring-site-structure-when-the-site-doesnt-have-any-ji3</link>
      <guid>https://dev.to/danielgreid/inferring-site-structure-when-the-site-doesnt-have-any-ji3</guid>
      <description>&lt;p&gt;Most sites do not have an information hierarchy. They have a navigation menu, a sitemap a plugin generated three years ago, and a pile of pages whose relationships exist only in the head of whoever built it. If you are writing a crawler that has to emit a &lt;em&gt;structured&lt;/em&gt; summary of a site — a table of contents, an llms.txt, a doc index — you cannot read the structure. You have to infer it.&lt;/p&gt;

&lt;p&gt;I have written this crawler more than once now. Here is what turns out to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three structure signals, and how much to trust each
&lt;/h2&gt;

&lt;p&gt;There are three sources of hierarchy on a normal website, and they disagree constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sitemap&lt;/strong&gt; is the most complete and the least meaningful. &lt;code&gt;sitemap.xml&lt;/code&gt; gives you every URL, but it is a flat bag, and &lt;code&gt;&amp;lt;priority&amp;gt;&lt;/code&gt; is noise almost nobody sets deliberately. What it &lt;em&gt;is&lt;/em&gt; good for is a denominator: if your crawl found 40 pages and the sitemap lists 900, your crawl is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nav&lt;/strong&gt; is the most meaningful and the least complete. Whatever a human put in the header or sidebar is a curated statement of "these are the things that matter," and it gives you grouping for free — a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; under a heading in a sidebar is a section. The catch is that nav typically covers 5–20% of the URL space, and on marketing sites it is often pure funnel (Pricing, Book a Demo) rather than content structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL path depth&lt;/strong&gt; is the cheap fallback, and right more often than it deserves to be. &lt;code&gt;/docs/api/auth/tokens&lt;/code&gt; really does usually mean tokens is under auth is under api. The failure mode is flat-by-design sites where everything is &lt;code&gt;/p/&amp;lt;slug&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My rule: nav is ground truth where it exists, URL path fills gaps, sitemap validates coverage. Never let path depth override an explicit nav grouping.&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;infer_parent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nav_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url_tree&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;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nav_index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# human-curated, trust it
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nav_index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;

    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;path&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="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="nf"&gt;split&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&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="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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;depth&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;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url_tree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;# ancestor we actually crawled
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt;   &lt;span class="c1"&gt;# give up honestly rather than invent a parent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last branch matters more than it looks. The temptation is to always produce a tidy tree, so you invent a parent from a URL segment that was never a real page — and now your output claims &lt;code&gt;/blog/2024/&lt;/code&gt; is a section when it 404s. A flat node attached to root is worse-looking and more correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPA nav: don't render the whole site
&lt;/h2&gt;

&lt;p&gt;A lot of nav is rendered client-side, so &lt;code&gt;fetch()&lt;/code&gt; returns a shell with an empty &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;. The obvious fix is to run everything through headless Chrome. That fix costs you roughly two orders of magnitude in throughput, and you do not need it for most pages.&lt;/p&gt;

&lt;p&gt;The approach that has held up is a &lt;strong&gt;two-tier crawl with escalation&lt;/strong&gt;. Fetch plain HTML first; escalate to a rendered fetch only when a heuristic says the static HTML is content-free:&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;needs_render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_visible_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;                  &lt;span class="c1"&gt;# shell, no content
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;count_internal_links&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# no nav at all
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;has_root_mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;div id="root"&amp;gt;&amp;lt;/div&amp;gt; plus a spinner
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critically, you usually only need to render &lt;em&gt;one&lt;/em&gt; page to recover the nav, because the nav is the same everywhere. I render the homepage, one deep page (to catch section-only sidebar nav), and anything that trips the heuristic. Three renders instead of nine hundred.&lt;/p&gt;

&lt;p&gt;Also: check for an embedded state blob before reaching for a browser. Next.js dumps &lt;code&gt;__NEXT_DATA__&lt;/code&gt;, Nuxt dumps &lt;code&gt;__NUXT__&lt;/code&gt;, and plenty of frameworks inline their route manifest. Parsing JSON out of a script tag is free and gives cleaner structure than scraping rendered DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduping near-identical URLs
&lt;/h2&gt;

&lt;p&gt;This is where naive crawlers produce garbage. The same document is reachable as &lt;code&gt;/docs/auth&lt;/code&gt;, &lt;code&gt;/docs/auth/&lt;/code&gt;, &lt;code&gt;/docs/auth/index.html&lt;/code&gt;, &lt;code&gt;/docs/auth?utm_source=twitter&lt;/code&gt;, and &lt;code&gt;/docs/auth#tokens&lt;/code&gt; — while &lt;code&gt;/docs/auth?page=2&lt;/code&gt; looks identical to all of those and is &lt;em&gt;not&lt;/em&gt; a duplicate.&lt;/p&gt;

&lt;p&gt;Cheap normalization handles the first tier: lowercase host, strip fragment and trailing slash, sort query params, drop a denylist of tracking params (&lt;code&gt;utm_*&lt;/code&gt;, &lt;code&gt;fbclid&lt;/code&gt;, &lt;code&gt;gclid&lt;/code&gt;, &lt;code&gt;ref&lt;/code&gt;). Do &lt;strong&gt;not&lt;/strong&gt; strip all query params — you will collapse paginated and filtered pages carrying real distinct content.&lt;/p&gt;

&lt;p&gt;Then honor what the site tells you. If &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; points elsewhere, the owner is explicitly declaring the duplicate; follow it. Same for &lt;code&gt;rel="alternate" hreflang&lt;/code&gt; — translations are language variants, not duplicates, and must never be flattened together.&lt;/p&gt;

&lt;p&gt;For what survives, content-level dedup catches the rest. Full-text hashing fails on one differing footer timestamp, so hash the &lt;em&gt;shape&lt;/em&gt;: normalized H1 + ordered H2s + rounded body length.&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;structure_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headings&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# tolerate small diffs
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sha1&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical fingerprints mean pick one — prefer the shortest URL, then the one appearing in nav, then the sitemap. Deterministic tie-breaks matter: if the winner flips between runs, your diffs become useless.&lt;/p&gt;

&lt;p&gt;The whole pipeline is roughly: sitemap for coverage → static fetch with selective render escalation → normalize and canonicalize → structure-fingerprint dedup → merge nav grouping over the path-derived tree → emit. When I finally wrapped this into something usable rather than a folder of scripts, it became &lt;a href="https://aicrawlable.com" rel="noopener noreferrer"&gt;aicrawlable.com&lt;/a&gt;, which crawls a URL and emits spec-shaped llms.txt out the other end — but the interesting part was always the inference, not the file format.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Hierarchy inference degrades badly and silently. A crawler that produced a beautiful nested tree last month produces a flat list this month because the site moved its nav into a client-rendered mega-menu. Nothing errors. It still exits 0.&lt;/p&gt;

&lt;p&gt;So instrument the &lt;em&gt;shape&lt;/em&gt; of your own output: tree depth, orphan ratio, dedup collapse rate, render escalation rate. Alert on deltas, not absolutes. A jump from 4% orphans to 60% is the signal that your nav extraction broke — and it is the only signal you get.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>seo</category>
      <category>programming</category>
    </item>
    <item>
      <title>llms.txt: What It Actually Does, and Why It Rots</title>
      <dc:creator>danielgreid</dc:creator>
      <pubDate>Fri, 24 Jul 2026 18:46:10 +0000</pubDate>
      <link>https://dev.to/danielgreid/llmstxt-what-it-actually-does-and-why-it-rots-hh</link>
      <guid>https://dev.to/danielgreid/llmstxt-what-it-actually-does-and-why-it-rots-hh</guid>
      <description>&lt;p&gt;When ChatGPT, Claude or Perplexity answers a question about your product, it is not consulting a decade of PageRank. It fetches a handful of pages and tries to work out what your site is. That is a very different retrieval problem from classic search, and most sites are accidentally hostile to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sitemaps are the wrong mental model
&lt;/h2&gt;

&lt;p&gt;A sitemap optimises for &lt;strong&gt;coverage&lt;/strong&gt; — every URL, every paginated archive, every tag page. That is correct for a crawler with a huge budget and a ranking model to sort the noise afterwards.&lt;/p&gt;

&lt;p&gt;An LLM landing on your site has neither. It has a limited context window and one shot. If the first thing it ingests is 400 URLs of &lt;code&gt;?page=17&lt;/code&gt; and &lt;code&gt;/tag/misc&lt;/code&gt;, your three genuinely useful guides are buried.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://llmstxt.org" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt; inverts this. It is a small markdown file at your root that optimises for &lt;strong&gt;priority&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Your Product&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; One-line description of what this actually does.&lt;/span&gt;

&lt;span class="gu"&gt;## Docs&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Quickstart&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/docs/quickstart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Install and first request in 5 minutes
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;API Reference&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/docs/api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Every endpoint with request/response examples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules make or break it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every link carries a description.&lt;/strong&gt; The colon-suffix annotation is what lets a model decide whether to fetch a page. A bare link list is barely better than a sitemap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Omit aggressively.&lt;/strong&gt; If a page does not answer a question someone would ask, it does not belong.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  llms-full.txt and the context tradeoff
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llms-full.txt&lt;/code&gt; inlines expanded content rather than linking out, so a model can ingest everything in one request. This is genuinely useful for compact docs — and actively harmful for large sites, where you will blow the context window and get truncated mid-document.&lt;/p&gt;

&lt;p&gt;Rough heuristic: if your docs exceed roughly 50k tokens, ship &lt;code&gt;llms.txt&lt;/code&gt; alone and let models fetch selectively.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody mentions: it rots
&lt;/h2&gt;

&lt;p&gt;This is where most implementations quietly fail. You write the file, ship it, and three months later half the descriptions describe features you renamed and two links 404. Nothing in your build catches it, because nothing validates it.&lt;/p&gt;

&lt;p&gt;Two things worth automating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Link liveness&lt;/strong&gt; — CI check that every URL in &lt;code&gt;llms.txt&lt;/code&gt; still returns 200&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regeneration on docs change&lt;/strong&gt; — treat it as a build artifact, not a hand-maintained file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up building a free generator for exactly this loop, after my own docs got cited with a description I had rewritten months earlier. Paste a URL, it crawls the structure, and produces spec-compliant &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; you can edit before exporting — reorder, drop low-value pages, rewrite descriptions. No signup, nothing stored: &lt;a href="https://aicrawlable.com" rel="noopener noreferrer"&gt;aicrawlable.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth being honest about
&lt;/h2&gt;

&lt;p&gt;No search engine has committed to llms.txt as a ranking signal, and it may never become one. What it does today is cheap insurance: when a model &lt;em&gt;does&lt;/em&gt; fetch your site, it reads a curated map instead of guessing. That asymmetry — near-zero cost, meaningful upside — is the whole argument.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
