<?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: DEUS Automations</title>
    <description>The latest articles on DEV Community by DEUS Automations (@deusautomations).</description>
    <link>https://dev.to/deusautomations</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%2F4100149%2Fb32ff895-0ef9-4d13-ad64-94ce62e1050f.png</url>
      <title>DEV Community: DEUS Automations</title>
      <link>https://dev.to/deusautomations</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deusautomations"/>
    <language>en</language>
    <item>
      <title>Your CDN has an opinion about AI crawlers, and it is not the one you set</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 19 Sep 2026 22:31:36 +0000</pubDate>
      <link>https://dev.to/deusautomations/your-cdn-has-an-opinion-about-ai-crawlers-and-it-is-not-the-one-you-set-2cdg</link>
      <guid>https://dev.to/deusautomations/your-cdn-has-an-opinion-about-ai-crawlers-and-it-is-not-the-one-you-set-2cdg</guid>
      <description>&lt;p&gt;Everyone reads the headline as "Cloudflare blocks AI crawlers." The measurement says something&lt;br&gt;
narrower and more interesting: on the sites whose own &lt;code&gt;robots.txt&lt;/code&gt; says yes, Cloudflare refuses&lt;br&gt;
Amazon's and Meta's crawlers far more often than it refuses OpenAI's or Anthropic's — and for&lt;br&gt;
OAI-SearchBot it refuses slightly &lt;em&gt;less&lt;/em&gt; often than sites with no CDN at all.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All figures re-derived from the live &lt;a href="https://shop.lumnika.com/ai-readiness/?src=devto2" rel="noopener noreferrer"&gt;Agent Web Index&lt;/a&gt;&lt;br&gt;
aggregate on 2026-09-19: 47,315 domains from the Tranco list with a measured verdict, 46,868 of them&lt;br&gt;
with the edge vendor read from the response headers. Dataset CC BY 4.0 (CSV on the hub).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is being counted
&lt;/h2&gt;

&lt;p&gt;One unit: a &lt;strong&gt;domain × crawler pair whose &lt;code&gt;robots.txt&lt;/code&gt; allows that crawler&lt;/strong&gt;. For each pair we made&lt;br&gt;
a real GET with that crawler's published user-agent. If &lt;code&gt;robots.txt&lt;/code&gt; says yes and the server says no&lt;br&gt;
— 403, challenge, block page — that refusal contradicts the site's own stated policy, and somebody&lt;br&gt;
other than the site owner made it.&lt;/p&gt;

&lt;p&gt;The edge vendor comes from the headers of the same response (&lt;code&gt;cf-ray&lt;/code&gt;, &lt;code&gt;akamai-grn&lt;/code&gt;, &lt;code&gt;x-amz-cf-id&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;x-fastly-request-id&lt;/code&gt;…), so it costs zero extra requests. Sites with no recognisable signature form&lt;br&gt;
the control group: &lt;strong&gt;"no known edge", 12,384 domains&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare, per crawler
&lt;/h2&gt;

&lt;p&gt;26,543 domains behind Cloudflare. 13.1% of the 119,112 robots-allowed pairs are refused anyway. But&lt;br&gt;
that single number hides the whole story:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crawler&lt;/th&gt;
&lt;th&gt;Cloudflare&lt;/th&gt;
&lt;th&gt;No known edge&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazonbot&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;29.9%&lt;/strong&gt; (2,115/7,062)&lt;/td&gt;
&lt;td&gt;13.8% (922/6,677)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.17×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta-ExternalAgent&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;20.7%&lt;/strong&gt; (1,471/7,120)&lt;/td&gt;
&lt;td&gt;12.4% (832/6,727)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.67×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytespider&lt;/td&gt;
&lt;td&gt;26.1% (80/306)&lt;/td&gt;
&lt;td&gt;20.1% (37/184)&lt;/td&gt;
&lt;td&gt;1.30×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;14.0% (3,593/25,736)&lt;/td&gt;
&lt;td&gt;11.9% (1,357/11,448)&lt;/td&gt;
&lt;td&gt;1.18×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;14.6% (3,768/25,873)&lt;/td&gt;
&lt;td&gt;13.0% (1,511/11,644)&lt;/td&gt;
&lt;td&gt;1.12×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applebot&lt;/td&gt;
&lt;td&gt;9.1% (29/320)&lt;/td&gt;
&lt;td&gt;7.3% (14/192)&lt;/td&gt;
&lt;td&gt;1.24×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;8.8% (2,314/26,302)&lt;/td&gt;
&lt;td&gt;8.2% (973/11,885)&lt;/td&gt;
&lt;td&gt;1.07×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;8.5% (2,239/26,393)&lt;/td&gt;
&lt;td&gt;9.0% (1,087/12,040)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.94×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the right-hand column, not the left. Sites block AI crawlers for their own reasons everywhere,&lt;br&gt;
CDN or not; the ratio isolates what being &lt;em&gt;behind Cloudflare&lt;/em&gt; adds. For ChatGPT's search crawler it&lt;br&gt;
adds nothing. For Anthropic and Perplexity it adds a few percent. For Amazon's crawler it roughly&lt;br&gt;
doubles the chance of being turned away from a page the site said it could have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape tells you whether a human decided
&lt;/h2&gt;

&lt;p&gt;Run the same breakdown on the other vendors and the contrast is the finding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;th&gt;Refusal rate&lt;/th&gt;
&lt;th&gt;Widest gap between crawlers&lt;/th&gt;
&lt;th&gt;Every crawler, vs control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Akamai&lt;/td&gt;
&lt;td&gt;327&lt;/td&gt;
&lt;td&gt;42.2%&lt;/td&gt;
&lt;td&gt;1.1×&lt;/td&gt;
&lt;td&gt;3.2–4.9×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google (Cloud/GFE)&lt;/td&gt;
&lt;td&gt;763&lt;/td&gt;
&lt;td&gt;36.6%&lt;/td&gt;
&lt;td&gt;1.3×&lt;/td&gt;
&lt;td&gt;2.2–4.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sucuri&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;21.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.0×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2–3.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CloudFront&lt;/td&gt;
&lt;td&gt;3,111&lt;/td&gt;
&lt;td&gt;16.5%&lt;/td&gt;
&lt;td&gt;1.2×&lt;/td&gt;
&lt;td&gt;1.3–1.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;26,543&lt;/td&gt;
&lt;td&gt;13.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9–2.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastly&lt;/td&gt;
&lt;td&gt;1,454&lt;/td&gt;
&lt;td&gt;8.3%&lt;/td&gt;
&lt;td&gt;1.7×&lt;/td&gt;
&lt;td&gt;0.6–0.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;570&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;1.4×&lt;/td&gt;
&lt;td&gt;0.4–0.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netlify&lt;/td&gt;
&lt;td&gt;197&lt;/td&gt;
&lt;td&gt;2.3%&lt;/td&gt;
&lt;td&gt;3.2×&lt;/td&gt;
&lt;td&gt;0.1–0.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last two columns together. Akamai refuses far more traffic than Cloudflare — and refuses&lt;br&gt;
every AI crawler at almost exactly the same rate, roughly three to five times the control on &lt;em&gt;all&lt;/em&gt; of&lt;br&gt;
them. That is a wall nobody aimed: a bot-management default that never learned these user-agents&lt;br&gt;
apart, and it is by far the most punishing thing that can sit in front of a site that said yes.&lt;br&gt;
Cloudflare refuses less overall, but selectively, and selectivity is the signature of a &lt;em&gt;list&lt;/em&gt; — a&lt;br&gt;
set of names, maintained by someone, applied on behalf of 26,543 sites that mostly never opened the&lt;br&gt;
setting.&lt;/p&gt;

&lt;p&gt;Sucuri is the cleanest example of a list there is: ClaudeBot 35%, GPTBot 29%, OAI-SearchBot 30%,&lt;br&gt;
PerplexityBot &lt;strong&gt;2%&lt;/strong&gt;. Twenty-one to one, across 61 domains whose own &lt;code&gt;robots.txt&lt;/code&gt; allows all four.&lt;br&gt;
Nobody writes that shape by accident.&lt;/p&gt;

&lt;p&gt;And the bottom of the table is a finding in its own right: behind Fastly, Vercel, Netlify, Imperva,&lt;br&gt;
Azure Front Door or Varnish, a robots-allowed crawler is refused &lt;em&gt;less&lt;/em&gt; often than on a site with no&lt;br&gt;
recognisable edge at all — down to 0.1× for Netlify. The edge is not one thing. Two vendors apart on&lt;br&gt;
this table differ more, for a site owner who wanted to be readable, than most of the choices that&lt;br&gt;
owner actually agonises over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard to see
&lt;/h2&gt;

&lt;p&gt;You cannot get these numbers from &lt;code&gt;robots.txt&lt;/code&gt;, and you cannot get them from a CDN's own&lt;br&gt;
transparency page, which only covers its own customers and never compares them to a control. You&lt;br&gt;
have to make the request as each crawler, from outside, at scale, and keep the response headers. The&lt;br&gt;
index does exactly that, continuously; every cell above is a live aggregate, not a one-off study.&lt;/p&gt;

&lt;p&gt;The per-vendor × per-crawler table is on the hub at&lt;br&gt;
&lt;a href="https://shop.lumnika.com/ai-readiness/?src=devto2#edge" rel="noopener noreferrer"&gt;/ai-readiness/#edge&lt;/a&gt;, with the pair count&lt;br&gt;
behind every cell. There is also an MCP endpoint, no key, if you would rather have your assistant&lt;br&gt;
pull it: &lt;code&gt;POST https://shop.lumnika.com/ai-readiness/mcp&lt;/code&gt;, tool &lt;code&gt;edge_blocking&lt;/code&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One observation point&lt;/strong&gt; (Europe) and &lt;strong&gt;the homepage only&lt;/strong&gt;. A vendor whose rules vary by region
or by path will read differently from elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Columns do not share a denominator.&lt;/strong&gt; Amazonbot and Meta-ExternalAgent were added to the probe
later, so their columns cover the domains measured since — a more recent slice of the same list,
not a different list. Compare within a row first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small vendors, small denominators.&lt;/strong&gt; Sucuri's 21× gap rests on 321 robots-allowed pairs across
61 domains, Akamai's rates on 1,582 pairs across 327. The direction is unambiguous at that size;
the second decimal is not. Cloudflare (119,112 pairs) is the only cell where precision is cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"No known edge" is an upper bound, not a vendor.&lt;/strong&gt; A domain measured before a signature was
added to the table stays in that bucket until it is re-requested; a re-request of 250 of them found
14% already carrying a recognised signature. So the named vendors are undercounts.&lt;/li&gt;
&lt;li&gt;For part of the domains the edge was read in a &lt;strong&gt;later pass&lt;/strong&gt; than the crawler verdicts, so a site
that changed CDN in between appears under its current one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Google-Extended&lt;/code&gt; and &lt;code&gt;Applebot-Extended&lt;/code&gt; are &lt;strong&gt;robots.txt tokens, not crawlers&lt;/strong&gt; — they make no
HTTP request of their own and are excluded from every number here about server behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your site is in the table and you did not choose this, the per-domain page shows exactly which&lt;br&gt;
crawler got which answer: &lt;code&gt;https://shop.lumnika.com/ai-readiness/&amp;lt;your-domain&amp;gt;&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>seo</category>
      <category>data</category>
    </item>
    <item>
      <title>Three PWA decisions that survived a real dining room</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 19 Sep 2026 21:22:11 +0000</pubDate>
      <link>https://dev.to/deusautomations/three-pwa-decisions-that-survived-a-real-dining-room-5950</link>
      <guid>https://dev.to/deusautomations/three-pwa-decisions-that-survived-a-real-dining-room-5950</guid>
      <description>&lt;p&gt;A point-of-sale web app in a busy restaurant is a hostile environment for the&lt;br&gt;
assumptions most PWA tutorials make. The network drops behind the walk-in&lt;br&gt;
fridge. Four devices edit the same table at the same time. And the one thing a&lt;br&gt;
waiter will never do is wait for your app to finish reloading while a customer&lt;br&gt;
is mid-sentence.&lt;/p&gt;

&lt;p&gt;Here are three decisions from &lt;a href="https://servito.lumnika.com/?src=devto" rel="noopener noreferrer"&gt;Servito&lt;/a&gt;, an&lt;br&gt;
order-taking PWA for restaurants and pubs, that only look obvious after you've&lt;br&gt;
watched the app being used during a dinner service.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Never call &lt;code&gt;skipWaiting()&lt;/code&gt; automatically
&lt;/h2&gt;

&lt;p&gt;The default snippet everyone copies makes a new service worker take over as&lt;br&gt;
soon as it installs:&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="nb"&gt;self&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;install&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipWaiting&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a dining room this is a bug with a uniform on. A waiter is halfway through&lt;br&gt;
a five-item order; you ship a deploy; the new worker activates, the page is&lt;br&gt;
reloaded under them, and the order is gone. The customer is still talking.&lt;/p&gt;

&lt;p&gt;So the install handler precaches and stops there. The new worker sits in&lt;br&gt;
&lt;code&gt;waiting&lt;/code&gt; until a client explicitly asks for it:&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="nb"&gt;self&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;message&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;event&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SKIP_WAITING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipWaiting&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 app surfaces a small "update available" banner, and the update happens when&lt;br&gt;
the staff taps it — between tables, not between courses. Shipping becomes&lt;br&gt;
boring, which is the goal.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Scope realtime channels to the venue, not the table
&lt;/h2&gt;

&lt;p&gt;Multi-device is the whole point: the tablet at the pass, the phone in the&lt;br&gt;
waiter's apron, the screen in the kitchen. Supabase Realtime makes the&lt;br&gt;
subscription itself trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`floor-plan-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;venueId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres_changes&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="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`venue_id=eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;venueId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="nf"&gt;refresh&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;Two details matter more than the API surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The filter is server-side.&lt;/strong&gt; &lt;code&gt;venue_id=eq.${venueId}&lt;/code&gt; means the database&lt;br&gt;
never pushes another restaurant's rows onto this socket. Filtering in the&lt;br&gt;
client is not a performance optimisation you can skip — it's the difference&lt;br&gt;
between a leak and a query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One channel per view, named after what it shows.&lt;/strong&gt; &lt;code&gt;floor-plan-${venueId}&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;status-lanes-${venueId}&lt;/code&gt;. When something is stale in production, the channel&lt;br&gt;
name tells you which screen to open. A single mega-channel that fans out to&lt;br&gt;
every component is easy to write and impossible to debug at 9pm on a Saturday.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Offline means "degrade to something usable", not "queue everything"
&lt;/h2&gt;

&lt;p&gt;Full offline write support for a POS is a distributed-systems project:&lt;br&gt;
conflicting edits to the same bill, out-of-order sync, staff who reconcile&lt;br&gt;
totals by hand anyway. What actually helps is a precached shell and an honest&lt;br&gt;
offline page:&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;const&lt;/span&gt; &lt;span class="nx"&gt;PRECACHE_URLS&lt;/span&gt; &lt;span class="o"&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;/&lt;/span&gt;&lt;span class="dl"&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;/offline&lt;/span&gt;&lt;span class="dl"&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;/manifest.webmanifest&lt;/span&gt;&lt;span class="dl"&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 app tells the user the truth immediately instead of accepting input it may&lt;br&gt;
silently lose. "The connection dropped, this order was not saved" is a&lt;br&gt;
recoverable moment. "Your order was saved" followed by a missing order is how&lt;br&gt;
you lose a customer for good — and in a restaurant, a lost order is a lost&lt;br&gt;
table, not a lost row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;All three are the same trade: &lt;strong&gt;give up an optimisation that reads well in a&lt;br&gt;
blog post, in exchange for behaviour that survives a room full of people who&lt;br&gt;
did not agree to be your test users.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want to poke at the result, the &lt;a href="https://servito.lumnika.com/demo?src=devto" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;&lt;br&gt;
runs without an account — it's the same build, with seeded data.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pwa</category>
      <category>javascript</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Most AI-crawler blocking doesn't happen in robots.txt</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:19:52 +0000</pubDate>
      <link>https://dev.to/deusautomations/most-ai-crawler-blocking-doesnt-happen-in-robotstxt-3gae</link>
      <guid>https://dev.to/deusautomations/most-ai-crawler-blocking-doesnt-happen-in-robotstxt-3gae</guid>
      <description>&lt;h1&gt;
  
  
  Most AI-crawler blocking doesn't happen in robots.txt
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Numbers re-derived from the live Agent Web Index aggregate on 2026-09-19 (47,314 domains with a&lt;br&gt;
measured verdict). Dataset CC BY 4.0: Zenodo DOI, GitHub DeusAcc/agent-web-index, Hugging Face&lt;br&gt;
DeusHorizon/agent-web-index. Live hub: &lt;a href="https://shop.lumnika.com/ai-readiness/?src=devto" rel="noopener noreferrer"&gt;https://shop.lumnika.com/ai-readiness/?src=devto&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everyone audits &lt;code&gt;robots.txt&lt;/code&gt;. We probed 47,314 domains the way an AI assistant actually fetches&lt;br&gt;
them — one request from a browser user-agent, then one from each published crawler user-agent —&lt;br&gt;
and compared what &lt;code&gt;robots.txt&lt;/code&gt; permits with what the server really returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;22.5% of measured domains block at least one major AI crawler. Most of those blocks are not in&lt;br&gt;
&lt;code&gt;robots.txt&lt;/code&gt;.&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;Crawler&lt;/th&gt;
&lt;th&gt;Served&lt;/th&gt;
&lt;th&gt;Blocked in robots.txt&lt;/th&gt;
&lt;th&gt;Blocked by the edge anyway&lt;/th&gt;
&lt;th&gt;Edge : robots&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;39,946&lt;/td&gt;
&lt;td&gt;2,077&lt;/td&gt;
&lt;td&gt;6,571&lt;/td&gt;
&lt;td&gt;3.2 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;40,150&lt;/td&gt;
&lt;td&gt;2,595&lt;/td&gt;
&lt;td&gt;6,250&lt;/td&gt;
&lt;td&gt;2.4 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;42,518&lt;/td&gt;
&lt;td&gt;1,233&lt;/td&gt;
&lt;td&gt;4,391&lt;/td&gt;
&lt;td&gt;3.6 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;42,507&lt;/td&gt;
&lt;td&gt;826&lt;/td&gt;
&lt;td&gt;4,513&lt;/td&gt;
&lt;td&gt;5.5 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Blocked by the edge" means one thing only, and it is measured, not inferred: &lt;code&gt;robots.txt&lt;/code&gt; allows&lt;br&gt;
the bot, a browser user-agent gets the page, and the crawler user-agent gets a different answer —&lt;br&gt;
a 403, a challenge, or a block page. Nobody wrote that rule for ClaudeBot. It came with a CDN&lt;br&gt;
setting.&lt;/p&gt;
&lt;h2&gt;
  
  
  It depends on who answers for your domain
&lt;/h2&gt;

&lt;p&gt;Grouping by the edge vendor that serves the host, over crawler×domain pairs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;th&gt;Crawler pairs blocked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Akamai&lt;/td&gt;
&lt;td&gt;266&lt;/td&gt;
&lt;td&gt;43.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google (Cloud/Frontend)&lt;/td&gt;
&lt;td&gt;722&lt;/td&gt;
&lt;td&gt;38.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sucuri&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;18.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CloudFront&lt;/td&gt;
&lt;td&gt;2,903&lt;/td&gt;
&lt;td&gt;16.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;25,404&lt;/td&gt;
&lt;td&gt;12.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No identifiable edge&lt;/td&gt;
&lt;td&gt;11,335&lt;/td&gt;
&lt;td&gt;11.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Front Door&lt;/td&gt;
&lt;td&gt;283&lt;/td&gt;
&lt;td&gt;8.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A site behind Akamai is roughly three and a half times more likely to refuse an AI crawler than a&lt;br&gt;
site behind Cloudflare — while both owners believe their &lt;code&gt;robots.txt&lt;/code&gt; is the policy.&lt;/p&gt;

&lt;p&gt;Mean readability score across measured domains: 73.1 (median 74).&lt;/p&gt;
&lt;h2&gt;
  
  
  Check yours
&lt;/h2&gt;

&lt;p&gt;Every domain has a page with its own evidence: &lt;code&gt;https://shop.lumnika.com/ai-readiness/&amp;lt;domain&amp;gt;?src=devto&lt;/code&gt;.&lt;br&gt;
The same data is a public MCP server, no key, if you would rather have your assistant ask:&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;POST https://shop.lumnika.com/ai-readiness/mcp
{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"domain_readiness","arguments":{"host":"example.com"}}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limits, stated by us first
&lt;/h2&gt;

&lt;p&gt;Single vantage point, one probe per crawler per domain, one point in time — a challenge page can&lt;br&gt;
be transient and we count it as a block. Percentages cover only domains that answered at all;&lt;br&gt;
unreachable domains are excluded, not counted as open. &lt;code&gt;google-extended&lt;/code&gt; and &lt;code&gt;applebot-extended&lt;/code&gt;&lt;br&gt;
are robots-only signals with no fetch to compare, so they carry no edge figure. Vendor rows with&lt;br&gt;
few domains (Sucuri, Akamai) have wide error bars — the direction is solid, the second decimal is&lt;br&gt;
not.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>seo</category>
      <category>data</category>
    </item>
    <item>
      <title>Your site's robots.txt says AI crawlers are welcome. Your CDN might disagree.</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Tue, 15 Sep 2026 23:28:55 +0000</pubDate>
      <link>https://dev.to/deusautomations/your-sites-robotstxt-says-ai-crawlers-are-welcome-your-cdn-might-disagree-37bn</link>
      <guid>https://dev.to/deusautomations/your-sites-robotstxt-says-ai-crawlers-are-welcome-your-cdn-might-disagree-37bn</guid>
      <description>&lt;p&gt;Every week another AI crawler shows up in your logs: ClaudeBot, GPTBot, OAI-SearchBot, PerplexityBot, Google-Extended. Your &lt;code&gt;robots.txt&lt;/code&gt; might allow all of them — and your CDN might still be answering with a 403 challenge page before the crawler ever sees your content. Nobody checks for that contradiction, because it means actually making the requests, not just parsing a text file.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://shop.lumnika.com/lab/agentready/?src=devto-laboratorio" rel="noopener noreferrer"&gt;AgentReady&lt;/a&gt;: you give it a URL, it makes &lt;strong&gt;six real HTTP requests&lt;/strong&gt; — one as a normal browser, five using the actual published user-agents of ClaudeBot, GPTBot, OAI-SearchBot, PerplexityBot and Google-Extended — plus checks for &lt;code&gt;robots.txt&lt;/code&gt;, &lt;code&gt;llms.txt&lt;/code&gt;, &lt;code&gt;llms-full.txt&lt;/code&gt;, &lt;code&gt;sitemap.xml&lt;/code&gt; and &lt;code&gt;security.txt&lt;/code&gt;. It's live-fetched from our own server (no domain sends CORS headers to a site it's never heard of), so there's no caching or guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually measures, in order of weight:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User-agent parity — status code and content length each crawler &lt;em&gt;actually got back&lt;/em&gt;, versus what a browser got. Served / challenged / thin.&lt;/li&gt;
&lt;li&gt;Text present without JavaScript execution&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; at the root, per-agent, including Cloudflare's Content-Signal header&lt;/li&gt;
&lt;li&gt;JSON-LD and prices declared in markup, not just painted on screen&lt;/li&gt;
&lt;li&gt;Ratio of content tokens to total tokens inside &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Discovery files and basic semantic structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output is a 0-100 score with fixes ordered by points recoverable, not just a checklist.&lt;/p&gt;

&lt;p&gt;I ran it against a few well-known sites: nytimes.com came back a &lt;strong&gt;D (58)&lt;/strong&gt; — 4 out of 5 AI crawlers got a 403 straight from the edge, and &lt;code&gt;robots.txt&lt;/code&gt; disallows everything. stripe.com scored &lt;strong&gt;A+ (93)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Worth being upfront: this space is crowded. There are a bunch of free &lt;code&gt;robots.txt&lt;/code&gt; parsers out there, and &lt;a href="https://crawlreadiness.com" rel="noopener noreferrer"&gt;crawlreadiness.com&lt;/a&gt; does something very close to this already (32 bots, real fetches, scored, paid tiers above a free daily limit). AgentReady is free, has no login, and the badge it generates is meant to sit on your own site — but I'm not claiming a moat here. What I actually think matters is the sibling project, &lt;a href="https://shop.lumnika.com/lab/agentindex/?src=devto-laboratorio" rel="noopener noreferrer"&gt;The Agent Web Index&lt;/a&gt;: the same measurement, run continuously across the web, as a public index instead of a one-off check. That's the part nobody else is doing yet.&lt;/p&gt;

&lt;p&gt;Curious what other checks people would want in the per-agent breakdown, or which crawlers I'm missing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>seo</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A free flight-price API that tells you buy or wait, not just today's fare</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:06:18 +0000</pubDate>
      <link>https://dev.to/deusautomations/a-free-flight-price-api-that-tells-you-buy-or-wait-not-just-todays-fare-17pd</link>
      <guid>https://dev.to/deusautomations/a-free-flight-price-api-that-tells-you-buy-or-wait-not-just-todays-fare-17pd</guid>
      <description>&lt;p&gt;Every flight comparison site shows you today's cheapest fare. None of them will tell you to wait — it costs them the booking commission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Piratefly&lt;/strong&gt; watches flight prices over time and answers the actual question: is this price good, or should you wait? We just opened that logic up as a free JSON API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://next.piratefly.com/v1/analisi?origine=FCO&amp;amp;destinazione=BCN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give it an IATA origin and destination, get back today's cheapest fare, where it sits against that route's own price history (percentile), and a buy/wait verdict.&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="s2"&gt;"https://next.piratefly.com/v1/analisi?origine=FCO&amp;amp;destinazione=BCN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Plans
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt; — 30 requests/day per IP, no signup, no key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro — $9.99/month&lt;/strong&gt; — no daily cap. &lt;code&gt;POST /v1/pro/checkout&lt;/code&gt; with your email, pay on Stripe, get an &lt;code&gt;X-Api-Key&lt;/code&gt; back immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full machine-readable reference: &lt;a href="https://next.piratefly.com/v1/docs" rel="noopener noreferrer"&gt;https://next.piratefly.com/v1/docs&lt;/a&gt;&lt;br&gt;
Landing page with the Pro checkout: &lt;a href="https://next.piratefly.com/api" rel="noopener noreferrer"&gt;https://next.piratefly.com/api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We only publish a verdict once a route has at least 8 price observations behind it — below that we say nothing rather than guess. Feedback and route requests welcome.&lt;/p&gt;

</description>
      <category>api</category>
      <category>travel</category>
      <category>json</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your open-source repo's issue queue doesn't have to sit there for months</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Wed, 09 Sep 2026 04:44:30 +0000</pubDate>
      <link>https://dev.to/deusautomations/your-open-source-repos-issue-queue-doesnt-have-to-sit-there-for-months-7d0</link>
      <guid>https://dev.to/deusautomations/your-open-source-repos-issue-queue-doesnt-have-to-sit-there-for-months-7d0</guid>
      <description>&lt;p&gt;Small and medium open-source repos accumulate bug reports and feature requests that sit open for months because nobody has time to triage them. Every maintainer knows the feeling of opening the Issues tab and seeing 40+ items with zero labels, zero triage, zero idea which ones are quick fixes and which ones are rabbit holes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Issue Triage BYOK&lt;/strong&gt; is a small WordPress plugin that gives you a wp-admin dashboard listing a repository's open GitHub issues, with a one-click "Generate fix proposal" button. Click it and an AI model reads the issue title and body and gives you back a likely root cause, a concrete fix, and a suggested test — you copy, adapt, and act on it yourself.&lt;/p&gt;

&lt;p&gt;A few things it deliberately does NOT do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It never writes to GitHub, comments, labels, or opens pull requests. Read-only, always.&lt;/li&gt;
&lt;li&gt;It doesn't ship an API key of its own. BYOK (Bring Your Own Key): you plug in your own GitHub token and your own AI key (any OpenAI-compatible chat completions endpoint works, not just OpenAI).&lt;/li&gt;
&lt;li&gt;No subscription for the base functionality: the free version generates up to 20 AI fix proposals per month, which is enough to stay on top of a small/medium repo's queue. A one-time-payment Pro license removes the monthly cap if you need more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why WordPress and not a GitHub Action or a CLI tool? Because a lot of maintainers of small plugins/themes/tools already live in wp-admin for their own site, and having the triage dashboard one click away (rather than another terminal tab or CI log to check) is the difference between actually doing triage weekly and letting it pile up.&lt;/p&gt;

&lt;p&gt;If you maintain a repo where issues outnumber your available time, it's on the WordPress plugin directory search and here: &lt;a href="https://shop.lumnika.com/gh-issue-triage-byok.html" rel="noopener noreferrer"&gt;https://shop.lumnika.com/gh-issue-triage-byok.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the BYOK approach or how the fix-proposal prompt is built.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>wordpress</category>
      <category>ai</category>
    </item>
    <item>
      <title>We Scanned Random Websites for Missing Alt Text. Even Wikipedia Failed.</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 05 Sep 2026 02:47:50 +0000</pubDate>
      <link>https://dev.to/deusautomations/we-scanned-random-websites-for-missing-alt-text-even-wikipedia-failed-3fn8</link>
      <guid>https://dev.to/deusautomations/we-scanned-random-websites-for-missing-alt-text-even-wikipedia-failed-3fn8</guid>
      <description>&lt;p&gt;Alt text isn't optional. Under WCAG 2.1 success criterion 1.1.1 ("Non-text Content"), every&lt;br&gt;
meaningful image needs a text alternative — and it's one of the most commonly cited failures in&lt;br&gt;
automated accessibility audits (WebAIM's Million report has flagged missing alt text as the&lt;br&gt;
single most frequent error on the web for years running).&lt;/p&gt;

&lt;p&gt;Finding every offending image by hand is the boring part, so we built a small crawler that walks&lt;br&gt;
a site's public pages and flags every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with a missing or empty &lt;code&gt;alt&lt;/code&gt; attribute. To sanity&lt;br&gt;
check it, we pointed it at a handful of real, well-known domains instead of a synthetic test&lt;br&gt;
page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;One of the sites we scanned was &lt;code&gt;wikipedia.org&lt;/code&gt;. A single-page scan of the portal landing page —&lt;br&gt;
the one that lets you pick a language — turned up an image with &lt;strong&gt;no alt attribute at all&lt;/strong&gt;: the&lt;br&gt;
Wikipedia wordmark logo itself (&lt;code&gt;Wikipedia-logo-v2.png&lt;/code&gt;). One of the most-visited, most-audited&lt;br&gt;
sites on the internet, and its own front door ships a logo image a screen reader has no way to&lt;br&gt;
describe.&lt;/p&gt;

&lt;p&gt;That's not a dig at Wikipedia specifically — it's the point. If a site with that much scrutiny can&lt;br&gt;
ship an unlabeled image on its landing page, it happens everywhere, silently, on every site that&lt;br&gt;
doesn't check for it systematically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;A few patterns we see repeatedly when auditing real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logos and icons get skipped.&lt;/strong&gt; Teams treat "obviously branding" images as exempt from
needing alt text, but a screen reader user still needs to know what's there (or that it's
purely decorative and should have &lt;code&gt;alt=""&lt;/code&gt;, not a missing attribute).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alt text is added once, at launch, and never re-checked.&lt;/strong&gt; New pages, new CMS templates, new
marketing images ship without it because nothing in the pipeline catches the omission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Missing" and "empty" get conflated.&lt;/strong&gt; A missing &lt;code&gt;alt&lt;/code&gt; attribute is a hard WCAG failure. An
empty &lt;code&gt;alt=""&lt;/code&gt; on a genuinely decorative image is &lt;em&gt;correct&lt;/em&gt;. Telling the two apart at scale is
exactly the kind of repetitive checking that's easy to skip by hand and easy to automate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checking your own site
&lt;/h2&gt;

&lt;p&gt;If you want to see this on your own domain rather than take our word for it, we put a free,&lt;br&gt;
no-signup version of the same scanner online: it crawls up to 10 public pages, lists every image&lt;br&gt;
with missing or empty alt text, and drafts a ready-to-paste alt text for each one using a vision&lt;br&gt;
model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.lumnika.com/alt-text-audit.html?src=devto" rel="noopener noreferrer"&gt;Try the free scanner →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No login, no email required for the sample scan — paste a domain and see what it finds. The paid&lt;br&gt;
version (CSV + report, up to 300 images) is there if you want the full site covered end to end,&lt;br&gt;
but the free scan alone is enough to tell you whether it's worth doing.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Watching a Solana wallet without running your own RPC node</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:51 +0000</pubDate>
      <link>https://dev.to/deusautomations/watching-a-solana-wallet-without-running-your-own-rpc-node-2lnn</link>
      <guid>https://dev.to/deusautomations/watching-a-solana-wallet-without-running-your-own-rpc-node-2lnn</guid>
      <description>&lt;h2&gt;
  
  
  Watching a Solana wallet without running your own RPC node
&lt;/h2&gt;

&lt;p&gt;If you want to know the moment a specific Solana wallet's balance moves — a treasury, a whale you're tracking, your own hot wallet for a security check — the "proper" way people reach for is running a dedicated indexer or paying for a webhook service like Helius. For a single wallet with modest polling needs, that's overkill.&lt;/p&gt;

&lt;p&gt;Solana's public RPC (&lt;code&gt;api.mainnet-beta.solana.com&lt;/code&gt;) is rate-limited but free, and it's enough to poll a wallet's SOL and SPL token balances on a schedule without an API key at all. Here's the pattern I used, in case you're about to build a similar watcher and don't want to pay for infra you don't need yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Poll every 5 minutes&lt;/strong&gt; — &lt;code&gt;getBalance&lt;/code&gt; for SOL, &lt;code&gt;getTokenAccountsByOwner&lt;/code&gt; for SPL tokens, against the public RPC endpoint. No auth, no key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff against last known balance&lt;/strong&gt; — stored between runs (a simple key-value store is enough; I used n8n's built-in static data, but a Sheet or a Redis key works the same).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold check&lt;/strong&gt; — only fire an alert if the change exceeds a threshold you set. Without this, you get pinged on every dust transaction and start ignoring the channel within a day — the threshold is what makes the alert actually useful instead of noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert with context&lt;/strong&gt; — a Telegram message with the delta and a link straight to the transaction on Solscan, so you don't have to go look it up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Where the public RPC runs out
&lt;/h3&gt;

&lt;p&gt;At low polling frequency (minutes, not seconds) and a handful of wallets, the public endpoint holds up fine. If you're watching dozens of wallets or need sub-minute latency, you'll hit rate limits and want to swap in a dedicated RPC (Helius, QuickNode, Triton) — the workflow is built so that's a one-field change, not a rewrite, since the RPC URL is a single credential.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you want the built version
&lt;/h3&gt;

&lt;p&gt;Packaged as an n8n workflow — polling, diffing, thresholding, and the Telegram alert with the Solscan link already wired up:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/solana-wallet-watcher.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solana-wallet-watcher" rel="noopener noreferrer"&gt;https://shop.lumnika.com/solana-wallet-watcher.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solana-wallet-watcher&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$14.99, one-time, no API key required to get started since it uses the public RPC by default.&lt;/p&gt;

&lt;p&gt;Anyone else tracking wallets for security/ops reasons rather than trading? Curious what thresholds people settled on before the alerts stopped being noise.&lt;/p&gt;

</description>
      <category>solana</category>
      <category>n8n</category>
      <category>automation</category>
      <category>crypto</category>
    </item>
    <item>
      <title>Syncing a CRM with Odoo without paying for an iPaaS subscription</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:24 +0000</pubDate>
      <link>https://dev.to/deusautomations/syncing-a-crm-with-odoo-without-paying-for-an-ipaas-subscription-35n3</link>
      <guid>https://dev.to/deusautomations/syncing-a-crm-with-odoo-without-paying-for-an-ipaas-subscription-35n3</guid>
      <description>&lt;h2&gt;
  
  
  Syncing a CRM with Odoo without paying for an iPaaS subscription
&lt;/h2&gt;

&lt;p&gt;Odoo is great as an ERP, but the moment sales wants to keep using HubSpot or Pipedrive as their CRM, you're stuck with a sync problem: contacts and orders need to exist correctly in both places, in both directions, without double-creating records or silently dropping updates.&lt;/p&gt;

&lt;p&gt;The "just use Zapier/Make/Workato" answer works, but at CRM+ERP volume you're often paying $50-300/mo for what is, underneath, a scheduled poll and a webhook. I ended up building this as a plain n8n workflow instead, and wanted to share the pattern since it generalizes to basically any "sync system A with system B" problem, not just Odoo.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two directions are not symmetric, and that matters
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Odoo → CRM&lt;/strong&gt;: a scheduled poll every 15 minutes, using Odoo's &lt;code&gt;write_date&lt;/code&gt; field to pull only records changed since the last run (a delta sync, not a full table scan — this is the difference between a sync that stays fast at 50 records and one that stays fast at 50,000). Each changed &lt;code&gt;res.partner&lt;/code&gt; or &lt;code&gt;sale.order&lt;/code&gt; gets upserted into the CRM via its REST API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRM → Odoo&lt;/strong&gt;: a webhook, because CRMs push changes in real time and there's no reason to wait 15 minutes for a lead update to land in Odoo. The webhook branches on whether the incoming record is new (create) or existing (update), matched by an external-ID field kept on both sides.&lt;/p&gt;

&lt;p&gt;The asymmetry is the part people usually get wrong when they build this the first time: they either poll both directions (adds needless latency on the CRM side) or webhook both directions (Odoo doesn't push webhooks natively without extra modules). Poll the system that doesn't push, webhook the one that does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Matching records without collisions
&lt;/h3&gt;

&lt;p&gt;Both &lt;code&gt;res.partner&lt;/code&gt; (contacts) and &lt;code&gt;sale.order&lt;/code&gt; (orders) get an external-ID field synced on both sides at creation time, so updates always match the right record instead of relying on fuzzy matching (name + email, which breaks the moment someone has a typo or a duplicate).&lt;/p&gt;

&lt;h3&gt;
  
  
  If you want the built version
&lt;/h3&gt;

&lt;p&gt;I packaged this as a 27-node n8n workflow — Odoo delta poll, CRM webhook with create/update branching, external-ID matching for both contacts and orders:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/crm-odoo-sync.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm-odoo-sync" rel="noopener noreferrer"&gt;https://shop.lumnika.com/crm-odoo-sync.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm-odoo-sync&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$49, one-time, works with HubSpot, Pipedrive, or any CRM with a REST webhook — you import it into your own n8n instance and point it at your credentials.&lt;/p&gt;

&lt;p&gt;If you've built a two-way sync between other systems, I'd like to hear what matching strategy you used instead of external IDs — curious if anyone's had a cleaner approach.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turning a folder of PDFs into a webhook that returns JSON</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:21 +0000</pubDate>
      <link>https://dev.to/deusautomations/turning-a-folder-of-pdfs-into-a-webhook-that-returns-json-40pg</link>
      <guid>https://dev.to/deusautomations/turning-a-folder-of-pdfs-into-a-webhook-that-returns-json-40pg</guid>
      <description>&lt;h2&gt;
  
  
  Turning a folder of PDFs into a webhook that returns JSON (no OCR infra to run)
&lt;/h2&gt;

&lt;p&gt;If you've ever had to pull data out of PDFs at scale — invoices, ID scans, scanned forms, shipping manifests — you know the annoying part isn't the OCR itself, it's everything around it: standing up a service, handling the queueing, storing the output somewhere useful, and doing it all without paying for a $200/mo document-AI SaaS for what's often a low-volume internal tool.&lt;/p&gt;

&lt;p&gt;I ended up solving this with a single n8n workflow instead of a custom service, and it turned out simpler than I expected, so here's the shape of it in case you're about to build the same thing from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pipeline
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhook in&lt;/strong&gt; — POST a PDF URL (or upload) to a single endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR&lt;/strong&gt; — the file goes to &lt;a href="https://ocr.space" rel="noopener noreferrer"&gt;OCR.space&lt;/a&gt;, which has a genuinely usable free tier (25k requests/month, no card required). No self-hosted Tesseract, no GPU, no Docker image to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text → structure&lt;/strong&gt; — the raw OCR text gets parsed into a best-effort table: rows, columns, a JSON array, and a CSV export, using a mix of layout heuristics on the OCR bounding boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log (optional)&lt;/strong&gt; — every extraction gets appended to a Google Sheet automatically, so you get a free audit trail without adding a database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response&lt;/strong&gt; — the webhook returns plain text + structured JSON + CSV, so any app, script, or Zapier/Make alternative can call it directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part worth calling out: because OCR.space is a hosted API, the whole thing runs with zero infrastructure of your own — n8n is the only thing you need to keep running, and if you already use n8n for other automations, this is just one more workflow, not a new service to babysit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this breaks down
&lt;/h3&gt;

&lt;p&gt;Layout-heavy PDFs (multi-column, dense tables with merged cells) will always need some manual review — no OCR pipeline gets that free. This is aimed at the 80% case: invoices, forms, receipts, single-column reports, where "close enough JSON" saves someone an afternoon of manual data entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you'd rather not build it
&lt;/h3&gt;

&lt;p&gt;I packaged the exact workflow — webhook, OCR.space call, structuring logic, optional Sheets logging — as a ready-to-import n8n template:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/pdf-ocr-extractor.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pdf-ocr" rel="noopener noreferrer"&gt;https://shop.lumnika.com/pdf-ocr-extractor.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pdf-ocr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$19.99, one-time, JSON file you import into your own n8n instance — no subscription, no vendor lock-in, you can edit every node.&lt;/p&gt;

&lt;p&gt;Curious if anyone here has fought with layout-heavy PDF extraction and found a heuristic that actually holds up — table detection is still the weakest link in every pipeline I've tried.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>ocr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is Your Shopify or WooCommerce Store an ADA/WCAG Lawsuit Risk? Free 2-Minute Check</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:34:02 +0000</pubDate>
      <link>https://dev.to/deusautomations/is-your-shopify-or-woocommerce-store-an-adawcag-lawsuit-risk-free-2-minute-check-2h3b</link>
      <guid>https://dev.to/deusautomations/is-your-shopify-or-woocommerce-store-an-adawcag-lawsuit-risk-free-2-minute-check-2h3b</guid>
      <description>&lt;p&gt;Every year, ADA (Title III) web-accessibility lawsuits against US retailers keep climbing, and a huge share of them cite the same root cause: product images with no &lt;code&gt;alt&lt;/code&gt; text. If you run a Shopify or WooCommerce store, that's usually not a design choice — it's just what happens when a catalog grows to hundreds or thousands of SKUs and nobody goes back to fill in &lt;code&gt;alt&lt;/code&gt; attributes.&lt;/p&gt;

&lt;p&gt;I wanted to know how exposed a "typical" store actually is, so I built a free scanner and ran it against a handful of real storefronts. Here's what I found, and the tool so you can check your own store in about two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why missing alt text is a legal risk, not just an SEO nitpick
&lt;/h2&gt;

&lt;p&gt;Screen readers rely on &lt;code&gt;alt&lt;/code&gt; text to describe images to blind and low-vision shoppers. When a product image has no &lt;code&gt;alt&lt;/code&gt;, or a generic one like &lt;code&gt;IMG_4021.jpg&lt;/code&gt;, a screen reader either skips it or reads the filename — the shopper has no idea what they're looking at, can't tell products apart, and often can't complete checkout.&lt;/p&gt;

&lt;p&gt;That's the exact pattern plaintiffs' firms look for before sending a demand letter: it's easy to prove (view source), easy to explain to a judge, and expensive to defend even when the claim doesn't go to trial. Settlements for small-to-mid retailers commonly land in the $5,000–$20,000 range — for something that's a content-population problem, not a code bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scan actually checks
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text" rel="noopener noreferrer"&gt;ComplianceSnap&lt;/a&gt; to answer one narrow question fast: &lt;strong&gt;out of all the product images on your store, how many have no usable alt text?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You paste a store URL, it crawls the product pages, and it reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total product images found&lt;/li&gt;
&lt;li&gt;how many are missing &lt;code&gt;alt&lt;/code&gt; (or have placeholder/filename-only alt)&lt;/li&gt;
&lt;li&gt;an estimated risk band based on catalog size and % missing (a 2,000-SKU store at 80% missing is a very different risk profile than a 40-SKU store at 10%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's read-only — it doesn't touch your store, theme, or admin, it just reads the public pages the same way a screen reader or a plaintiff's audit tool would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found scanning real stores
&lt;/h2&gt;

&lt;p&gt;Running it against a small sample of public Shopify/WooCommerce stores, the missing-alt rate ranged from under 10% (stores that clearly have an accessibility process) to over 70% (stores where alt text was never part of the product-upload workflow at all). The pattern held regardless of store size — it's a process gap, not a platform limitation. Both Shopify and WooCommerce support alt text natively; almost nobody fills it in by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  If your scan comes back bad
&lt;/h2&gt;

&lt;p&gt;The scan itself is free and doesn't require an account. If you want the missing alt text actually written (AI-generated, per image, reviewed before you publish), that's a paid step — the free scan is just there so you know whether it's worth doing before you spend anything.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;strong&gt;&lt;a href="https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text" rel="noopener noreferrer"&gt;https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Curious what other devs/store owners have seen on this — did anyone here actually get an ADA demand letter over image alt text, or is it mostly theoretical risk?&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Most "AI Resume Builders" Produce PDFs That Fail ATS Parsers</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 15:56:15 +0000</pubDate>
      <link>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</link>
      <guid>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</guid>
      <description>&lt;p&gt;If you've ever run your own resume through an ATS (Applicant Tracking System) parser and watched&lt;br&gt;
your job title or bullet points come out garbled, you've hit a problem that has nothing to do with&lt;br&gt;
your content and everything to do with how the PDF was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: PDF is a layout format, not a text format
&lt;/h2&gt;

&lt;p&gt;A PDF doesn't store "a paragraph followed by another paragraph." It stores absolute-positioned&lt;br&gt;
glyphs on a page. Most polished-looking resume templates get that layout from HTML+CSS rendered&lt;br&gt;
through a headless browser (Puppeteer/Chromium), which is great for visual fidelity and terrible&lt;br&gt;
for text order: multi-column layouts, sidebars, and floated elements often get flattened in&lt;br&gt;
reading order that has nothing to do with visual order. An ATS parser reads the underlying text&lt;br&gt;
stream top-to-bottom, left-to-right in &lt;em&gt;insertion&lt;/em&gt; order — so a two-column template can silently&lt;br&gt;
interleave your work history with your skills list, or drop your most recent job title into the&lt;br&gt;
middle of a bullet point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes it
&lt;/h2&gt;

&lt;p&gt;Two things matter more than template beauty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the PDF with a low-level drawing API, not a browser.&lt;/strong&gt; Libraries like &lt;code&gt;pdfkit&lt;/code&gt; let you
place text in the exact order you write it in code — single-column templates render sidebar
accents as &lt;em&gt;decoration&lt;/em&gt; (colored rectangles, icons) that never enter the text stream, so the
parser only ever sees the real content, in the real order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always keep a resume-only fallback path.&lt;/strong&gt; If you're generating resume content with an LLM
(turning raw notes into structured bullet points, for example), the model call &lt;em&gt;will&lt;/em&gt;
eventually fail or time out. A parser that falls back to a deterministic, rule-based structuring
of the same raw input — instead of failing the whole request — means the user still gets a
correct, ATS-safe PDF even when the AI provider has a bad day.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A concrete example
&lt;/h2&gt;

&lt;p&gt;I ended up building this into &lt;a href="https://shop.lumnika.com/cv-forge/?src=devto" rel="noopener noreferrer"&gt;CV Forge&lt;/a&gt;, a small AI resume generator: raw notes&lt;br&gt;
go in, an LLM (OpenAI-compatible, tested against Groq's Llama 3.3 70B) turns them into a structured&lt;br&gt;
CV JSON, and &lt;code&gt;pdfkit&lt;/code&gt; renders it with a single-column-first "Compact" template designed to stay&lt;br&gt;
ATS-safe, alongside two more visual templates for when you're sending directly to a human. If the&lt;br&gt;
AI call fails, a local parser produces the same structured JSON so the PDF still comes out&lt;br&gt;
correctly formatted.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past resumes: &lt;strong&gt;any time you're generating a PDF that a machine will read&lt;br&gt;
back&lt;/strong&gt;, decide &lt;em&gt;before&lt;/em&gt; picking a rendering approach whether text-stream order matters as much as&lt;br&gt;
visual layout. If it does, skip the headless browser and reach for a library that lets you control&lt;br&gt;
the text stream directly.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>career</category>
      <category>pdf</category>
    </item>
  </channel>
</rss>
