<?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: Hammad Shams Uddin</title>
    <description>The latest articles on DEV Community by Hammad Shams Uddin (@hammad4june1999).</description>
    <link>https://dev.to/hammad4june1999</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%2F4066514%2F2ba9ed94-45a3-4e62-910f-8c42551b2b91.jpg</url>
      <title>DEV Community: Hammad Shams Uddin</title>
      <link>https://dev.to/hammad4june1999</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hammad4june1999"/>
    <language>en</language>
    <item>
      <title>A reader asked a question my log could not answer. The fix was one column.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:34:55 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/a-reader-asked-a-question-my-log-could-not-answer-the-fix-was-one-column-126c</link>
      <guid>https://dev.to/hammad4june1999/a-reader-asked-a-question-my-log-could-not-answer-the-fix-was-one-column-126c</guid>
      <description>&lt;p&gt;I have been publishing numbers from a crawler log I added to my own site after a compromise: one line per crawler request — time, crawler, status served, path, IP. Last week I used it to show that a flood of requests for dead URLs was not the 410 failing, because almost none of those URLs were repeats.&lt;/p&gt;

&lt;p&gt;A reader, jonas_bra, replied with the sentence that makes a measurement honest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;first seen in our logs isn't the same as newly created&lt;/p&gt;

&lt;p&gt;A low repeat rate suggests Bing isn't repeatedly revisiting the same dead pages, but it doesn't tell you whether those URLs are still being discovered through links elsewhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He is right, and I had not noticed. My log can tell you a path is new &lt;strong&gt;to the log&lt;/strong&gt;. It cannot tell you whether the URL was minted during the attack in September and is only now being reached, or whether something out there is still handing Bing fresh ones today. Those are different problems — one is a backlog that ends, the other is a live source that does not — and I had been reporting on the first as if I had ruled out the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the existing data can settle
&lt;/h2&gt;

&lt;p&gt;Two of the three questions turned out to be answerable with what I already had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my own site still emitting them?&lt;/strong&gt; I fetched fourteen pages — homepage, every section hub, the sitemap, robots.txt, a few tools — and matched every &lt;code&gt;href&lt;/code&gt; against the cloaker's URL shape. Zero. The generator and the files it lived in are gone, so my site is not a source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the backlog plausibly finite?&lt;/strong&gt; The malware handed out about 291 invented URLs per page load, to anything that asked for a page, for six days. Bing currently fetches roughly 3,500 of them a day. Six days of being served 291 URLs per request, at anything like that rate, mints URLs on the order of a million. Against that, the 80,394 distinct ones my log has seen in 24 days is a fraction of a backlog, not evidence of a live source.&lt;/p&gt;

&lt;p&gt;Neither of those is proof. Together they make "finite backlog" the better-supported story — but "better-supported" is not "measured", and the difference matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The column I did not have
&lt;/h2&gt;

&lt;p&gt;What would actually settle it is the &lt;strong&gt;referer&lt;/strong&gt;. A crawler that followed a link usually says where it came from. My log did not record it, because when I wrote the thing I was asking "who is crawling and what are they being served", and that question does not need a referer.&lt;/p&gt;

&lt;p&gt;So the log has a sixth column now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_REFERER'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&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="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ref&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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&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;And the summary counts how many hits carried one.&lt;/p&gt;

&lt;p&gt;The important part is what that column can and cannot prove, and it is worth being explicit because the asymmetry is the whole value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A non-empty referer is proof of continuing discovery.&lt;/strong&gt; If something links to &lt;code&gt;/stimulated/a0d3o2b2o25378994&lt;/code&gt;, that link exists and I can go and look at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty referer proves nothing.&lt;/strong&gt; Bing frequently sends none. A column of blanks is consistent with a finite backlog &lt;em&gt;and&lt;/em&gt; with a live source that Bing reached without passing a referer along.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One direction is evidence; the other is silence. Writing that down in the code comment matters as much as writing the code, because in three weeks I will be reading a column of blanks and the temptation will be to call it an answer.&lt;/p&gt;

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

&lt;p&gt;This is the third time in two months that the fault in one of my instruments was not a bug but a &lt;strong&gt;missing dimension&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A sitemap check counted URLs and could not tell "empty" from "unreachable" — it needed a second outcome, not a better number.&lt;/li&gt;
&lt;li&gt;A link check printed how many articles answered as though that were how many exist — it needed a denominator.&lt;/li&gt;
&lt;li&gt;This log could tell new-to-me from seen-before, and could not tell new-to-me from newly-created — it needed the referer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case the data looked complete, every number in it was correct, and the conclusion was wrong anyway. You cannot find that by staring at your own output. It took a stranger reading a post to find this one, which is the strongest argument I know for publishing the numbers rather than just acting on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three small things, if you keep logs like this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write down what each column can falsify, not just what it records.&lt;/strong&gt; "Non-empty proves X, empty proves nothing" belongs next to the field, in the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the raw line, not just the aggregate.&lt;/strong&gt; Every one of these gaps was recoverable only because the per-request lines were still on disk; a daily counter would have thrown the evidence away. If you are pulling a value out of a URL by hand while debugging, &lt;a href="https://utilorax.com/tools/url-parser" rel="noopener noreferrer"&gt;a URL parser&lt;/a&gt; saves you from the usual off-by-one in the path split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the numbers.&lt;/strong&gt; The reader who finds your missing column is not reading your code — they are reading your conclusion, and they are the only person in the loop who does not already believe it. For what it is worth, my posts are about &lt;a href="https://utilorax.com/tools/word-counter" rel="noopener noreferrer"&gt;a thousand words&lt;/a&gt;; the useful corrections have all come from people replying to the specific figure, not to the argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current state, so the next correction has something to bite on: 24 days of log, 80,394 distinct dead URLs, 3,000–4,000 new a day and not declining, repeat rate up from about 1% to about 5%, Googlebot has asked for none of them, and the referer column starts filling tomorrow.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>seo</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>My 410 was not failing. Bing was never asking twice.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:59:13 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-410-was-not-failing-bing-was-never-asking-twice-2adm</link>
      <guid>https://dev.to/hammad4june1999/my-410-was-not-failing-bing-was-never-asking-twice-2adm</guid>
      <description>&lt;p&gt;Two weeks ago I switched a batch of dead URLs from 404 to 410 Gone and watched the daily count of them go &lt;em&gt;up&lt;/em&gt;. I wrote a correction saying the 410 had not worked.&lt;/p&gt;

&lt;p&gt;That correction was also wrong, and the number that shows why took one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I had not counted
&lt;/h2&gt;

&lt;p&gt;My crawler log has one line per crawler request: time, crawler, status served, path, IP. I had been counting requests per day. What I had never counted was how many of those paths the log had &lt;strong&gt;seen before&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;day         distinct   new     repeated
29 Sep        972      972        0
30 Sep      3,912    3,909        3
 1 Oct      2,484    2,482        2
 5 Oct      4,055    4,019       36
 8 Oct      4,662    4,609       53
10 Oct      5,034    4,932      102
12 Oct      2,318    2,285       33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Over 97% of what Bing asks for every day is a URL it has never asked for before.&lt;/strong&gt; In fifteen days: 50,844 distinct dead URLs, and almost no repeats.&lt;/p&gt;

&lt;p&gt;So the 410 was doing exactly what a 410 does. It tells a crawler &lt;em&gt;this one is gone, do not come back&lt;/em&gt;, and Bing does not come back — the repeat column is 0.7% to 2%, and those few are probably retries in flight. What a 410 cannot do is shrink a queue of URLs the crawler has not reached yet. I had been reading a backlog being drained as a fix that was not working.&lt;/p&gt;

&lt;p&gt;The backlog exists because, during a compromise in September, a cloaker generated roughly 291 invented URLs &lt;strong&gt;per page load&lt;/strong&gt; and handed them to any crawler that asked for a page, for six days. Nobody knows how many it minted. What I know is that Bing found at least 50,844 of them, and is still working through the list at about 4,000 a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same log, read as a whole
&lt;/h2&gt;

&lt;p&gt;Fifteen days, every crawler, by status served:&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;200&lt;/th&gt;
&lt;th&gt;410&lt;/th&gt;
&lt;th&gt;other&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;googlebot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;513&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3× 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bingbot&lt;/td&gt;
&lt;td&gt;2,347&lt;/td&gt;
&lt;td&gt;51,326&lt;/td&gt;
&lt;td&gt;51× 404, 2× 302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bytespider&lt;/td&gt;
&lt;td&gt;4,141&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yandexbot&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;3× 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claudebot&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things in that table I would not have guessed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Googlebot has never once asked for a spam URL.&lt;/strong&gt; Not one 410 in fifteen days. Whatever Google discovered during the compromise, it has already dropped. Its crawl of this site is now 100% real pages — and it is small: 513 requests, 291 distinct URLs, against a sitemap of 6,215.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing's crawl is 95.5% dead URLs.&lt;/strong&gt; It is spending twenty-two requests on the attacker's imagination for every one it spends on a real page of mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ByteDance's crawler fetches eight times more real pages than Google does.&lt;/strong&gt; 4,141 against 513. It is not going to send me anything, but it is the single heaviest consumer of real pages on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix I did not ship
&lt;/h2&gt;

&lt;p&gt;The obvious next move is to stop Bing asking at all. The spam URLs all end in digits, so ten lines of &lt;code&gt;robots.txt&lt;/code&gt; would do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;Disallow: /*0$
Disallow: /*1$
&lt;span class="err"&gt;…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked that against my own sitemap before writing it, and it would have blocked &lt;strong&gt;105 real pages&lt;/strong&gt; — &lt;code&gt;/tools/rot13&lt;/code&gt;, &lt;code&gt;/tools/image-to-base64&lt;/code&gt;, and their four language copies each. A rule that costs real pages to save a crawler's time is a bad trade.&lt;/p&gt;

&lt;p&gt;It is also the wrong goal. A URL that is disallowed in robots.txt is a URL the crawler &lt;strong&gt;cannot fetch&lt;/strong&gt;, so it never sees the 410, so it never learns the URL is gone. Blocking would freeze 50,000 dead URLs in Bing's index instead of removing them. The crawl is not costing me anything — 4,000 requests a day is nothing, and the pages they hit do not exist, so they cost a 410 and a database-free code path.&lt;/p&gt;

&lt;p&gt;So: no action. The right answer is the one already deployed, and the rest is patience.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count distinct, not total.&lt;/strong&gt; "4,000 requests a day, not falling" and "4,000 &lt;em&gt;new&lt;/em&gt; URLs a day" lead to opposite conclusions, and one &lt;code&gt;sort -u&lt;/code&gt; separates them. If you are eyeballing a list by hand, &lt;a href="https://utilorax.com/tools/sort-lines" rel="noopener noreferrer"&gt;sorting it&lt;/a&gt; first makes repeats visible immediately — the shape of the data is the diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the rule, then test it against your real URLs before shipping it.&lt;/strong&gt; My ten robots lines looked harmless. &lt;a href="https://utilorax.com/tools/regex-tester" rel="noopener noreferrer"&gt;Try the pattern&lt;/a&gt; against a sample of paths that must keep working; mine failed on a tool called rot13.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A correction can be wrong too.&lt;/strong&gt; I corrected a claim with a bigger sample and still had the wrong model of what was happening. The fix was not more data — it was one more column.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>monitoring</category>
      <category>security</category>
    </item>
    <item>
      <title>Three of twenty pages are indexed. Here is what ten days of my own crawler log says about why.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Thu, 08 Oct 2026 17:45:05 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/three-of-twenty-pages-are-indexed-here-is-what-ten-days-of-my-own-crawler-log-says-about-why-33f</link>
      <guid>https://dev.to/hammad4june1999/three-of-twenty-pages-are-indexed-here-is-what-ten-days-of-my-own-crawler-log-says-about-why-33f</guid>
      <description>&lt;p&gt;I run a site with 6,100 URLs in its sitemap. Google downloaded that sitemap three days ago, no warnings, no errors. Today I inspected twenty randomly chosen pages from it, one API call each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  3  Submitted and indexed
 11  Crawled - currently not indexed
  6  URL is unknown to Google
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen per cent. And the six "unknown" ones are the interesting part: they are listed in a sitemap Google has, and Google has never fetched them at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Search Console shows, and what it hides
&lt;/h2&gt;

&lt;p&gt;The performance numbers for the last 28 days: &lt;strong&gt;85 impressions, 5 clicks.&lt;/strong&gt; Sixty of those 85 impressions are the homepage. The top queries are not tool queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;software &amp;amp; it services                  3 impressions   position 5.7
software services                       3               position 2.0
software house near me for freshers     2               position 1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a calculator site ranking for "software house near me". The pages that would answer a real query — the converters, the calculators — are not in the index to be shown.&lt;/p&gt;

&lt;p&gt;Ninety days, by week, tells the rest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;week&lt;/th&gt;
&lt;th&gt;impressions&lt;/th&gt;
&lt;th&gt;clicks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;W31&lt;/td&gt;
&lt;td&gt;13,683&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W32&lt;/td&gt;
&lt;td&gt;24,082&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W33&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68,834&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W34&lt;/td&gt;
&lt;td&gt;27,317&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;191&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W36–W40&lt;/td&gt;
&lt;td&gt;31, 32, 18, 21, 12&lt;/td&gt;
&lt;td&gt;~1 each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cliff between W34 and W35 is where a compromise was cleaned up. Note the clicks column during the peak: 68,834 impressions produced 35 clicks, a CTR of 0.05%. Those impressions were never real demand — they were the attacker's invented URLs appearing in results. Losing them cost almost nothing. What has not come back is everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log the host does not give you
&lt;/h2&gt;

&lt;p&gt;Search Console is two days behind and rounds everything into weekly shapes, so last week I gave the site its own crawler log: one line per crawler request — time, crawler, &lt;strong&gt;the status it was served&lt;/strong&gt;, path, IP — and nothing at all for human visitors. Ten days in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;day&lt;/th&gt;
&lt;th&gt;Googlebot&lt;/th&gt;
&lt;th&gt;bingbot&lt;/th&gt;
&lt;th&gt;of which 410&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;29 Sep&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;1,077&lt;/td&gt;
&lt;td&gt;982&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Sep&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;4,114&lt;/td&gt;
&lt;td&gt;3,914&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Oct&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;2,660&lt;/td&gt;
&lt;td&gt;2,487&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2,903&lt;/td&gt;
&lt;td&gt;2,708&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Oct&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;3,832&lt;/td&gt;
&lt;td&gt;3,616&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 Oct&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;3,967&lt;/td&gt;
&lt;td&gt;3,776&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;4,255&lt;/td&gt;
&lt;td&gt;4,056&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 Oct&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;4,976&lt;/td&gt;
&lt;td&gt;4,795&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 Oct&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;4,527&lt;/td&gt;
&lt;td&gt;4,389&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two numbers to take from that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Googlebot fetches about 37 pages a day.&lt;/strong&gt; In ten days it fetched 221 distinct real URLs, all 200s. Against a 6,100-URL sitemap, a single complete pass at that rate takes about five months. That alone explains the six "unknown to Google" pages in my sample of twenty: nothing is wrong with them, Google simply has not got there, and at this rate it will not get there soon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bingbot fetches about 4,000 dead URLs a day&lt;/strong&gt;, and that number is going &lt;em&gt;up&lt;/em&gt;. Those are the URLs a cloaker invented during the compromise — a word, a slash, a word with digits between its letters — about 291 of them handed to every crawler that asked for a page, for six days.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was wrong about the fix
&lt;/h2&gt;

&lt;p&gt;Four days into this log I switched those dead URLs from 404 to &lt;strong&gt;410 Gone&lt;/strong&gt;, which tells a crawler to drop the URL rather than check again later. I then wrote that the daily count was "pointing the right way": 3,914 → 2,487 → 1,653.&lt;/p&gt;

&lt;p&gt;It was not. Those were two partial days at either end of a four-day sample. Ten days later the sequence reads 3,914, 2,487, 2,708, 3,616, 3,776, 4,056, 4,795, 4,389 — flat to rising. A 410 does tell Bing to drop &lt;em&gt;that&lt;/em&gt; URL, but it does nothing to the rate at which Bing works through a queue of nine thousand others, and I had read a trend into a sample too short to hold one. I have left the original numbers in that post with a correction under them, because deleting them would make my reasoning look better than it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two instruments say together
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Google &lt;strong&gt;is&lt;/strong&gt; crawling — steadily, slowly, only real pages, every one a 200. There is no technical blockage: no 403s, no 404 storm, no cloaking, a clean sitemap fetch three days ago.&lt;/li&gt;
&lt;li&gt;Google &lt;strong&gt;is not&lt;/strong&gt; indexing what it crawls: 11 of 20 sampled pages are "crawled, currently not indexed", most last crawled in August.&lt;/li&gt;
&lt;li&gt;Bing is crawling thirty times more than Google and spending 94% of it on URLs that have been gone for a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Crawled — currently not indexed" is a judgement, not a fault. There is no header to add and no file to fix; it means Google fetched the page and decided it was not worth a slot. For a page that converts hectares to acres, competing with a hundred thousand identical pages, that judgement is not unreasonable, which is the uncomfortable part.&lt;/p&gt;

&lt;p&gt;So the work is not technical any more. It is the slower kind: fewer, better pages, and reasons for anyone to link to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the same two numbers for your own site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure indexation, do not estimate it.&lt;/strong&gt; &lt;code&gt;site:&lt;/code&gt; queries in a browser are a lossy approximation. The URL Inspection API gives you a verdict per URL, and twenty of them is a sample that will tell you something real in ten minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know how big your sitemap actually is before you argue about crawl budget.&lt;/strong&gt; Mine says 6,100 across 54 child sitemaps, which is not a number I would have guessed; a &lt;a href="https://utilorax.com/tools/sitemap-generator" rel="noopener noreferrer"&gt;sitemap generator&lt;/a&gt; that walks your own site will tell you what you are really asking Google to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the result you are competing for.&lt;/strong&gt; Before writing another near-duplicate page, &lt;a href="https://utilorax.com/tools/serp-snippet-preview" rel="noopener noreferrer"&gt;put the title and description into a SERP preview&lt;/a&gt; and ask whether that line would get clicked next to nine others exactly like it. If the honest answer is no, indexing is not your bottleneck.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>monitoring</category>
      <category>googlesearchconsole</category>
    </item>
    <item>
      <title>Bing spent 94% of four days crawling a hacker''s URLs. Google spent none.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:09:00 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/bing-spent-94-of-four-days-crawling-a-hackers-urls-google-spent-none-404o</link>
      <guid>https://dev.to/hammad4june1999/bing-spent-94-of-four-days-crawling-a-hackers-urls-google-spent-none-404o</guid>
      <description>&lt;p&gt;Four days ago I gave my site its own crawler log, because the host keeps access logs somewhere a script cannot read them and Search Console answers two days late. Here is the first full read of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bingbot            9,629 hits     bytespider   357
googlebot            183 hits     yandexbot     94
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the status codes those two were served:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;day&lt;/th&gt;
&lt;th&gt;bingbot 200&lt;/th&gt;
&lt;th&gt;bingbot 410&lt;/th&gt;
&lt;th&gt;googlebot 200&lt;/th&gt;
&lt;th&gt;googlebot other&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;29 Sep (from 15:51)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;982&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Sep&lt;/td&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;3,914&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Oct&lt;/td&gt;
&lt;td&gt;172&lt;/td&gt;
&lt;td&gt;2,487&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;1× 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Oct (to 16:03)&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;1,653&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;9,036 of bingbot's 9,629 requests — 94% — were to URLs that do not exist.&lt;/strong&gt; 9,015 of them were &lt;em&gt;distinct&lt;/em&gt;. Every one has the same shape: a dictionary word, a slash, a word with digits pushed between its letters.&lt;/p&gt;

&lt;p&gt;They are not Bing's fault. In September this site was compromised, and the malware handed every crawler that asked for a page roughly 291 invented URLs. It did that for six days. The malware has been gone for weeks; the queue it created is still being worked through, at about two thousand requests a day.&lt;/p&gt;

&lt;p&gt;Googlebot, over the same four days: 183 requests, 135 distinct real pages, and &lt;strong&gt;not one spam URL&lt;/strong&gt;. Two crawlers, the same site, the same week, completely different pictures. If I had only Search Console I would have had neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in those four days
&lt;/h2&gt;

&lt;p&gt;On the 29th those dead URLs were answering 404. A 404 means &lt;em&gt;not here right now&lt;/em&gt;, which is an invitation to come back. I switched the cloaker's URL shape to &lt;strong&gt;410 Gone&lt;/strong&gt; — &lt;em&gt;stop asking&lt;/em&gt; — after checking the pattern against every URL in my sitemap (9 of 9 known spam paths matched; 0 of 6,240 real ones did).&lt;/p&gt;

&lt;p&gt;The daily 410 count since: &lt;strong&gt;3,914 → 2,487 → 1,653&lt;/strong&gt;. The last day is two-thirds of a day, so it is not yet a trend I would bet on, but it is the first number I have had at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 8 October.&lt;/strong&gt; It was not a trend, and I should not have printed those three numbers in that order. With ten days instead of four, the daily 410 count reads 982 (part day), 3,914, 2,487, 2,708, 3,616, 3,776, 4,056, 4,795, 4,389, 3,274 (part day) — flat to rising, not falling. The "decline" was two partial days at either end of a short sample, which is exactly the shape I warn about elsewhere in this post. A 410 tells a crawler to drop a URL; it does not slow down the rate at which it works through a queue of nine thousand others, and ten days was not long enough to see the first effect either. I have left the original numbers above rather than quietly editing them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule itself lives inside the 404 handler, not at the top of the router. That ordering is the whole safety argument: a request only reaches &lt;code&gt;notFound()&lt;/code&gt; once it has failed to match anything real, so a live route cannot be caught by the pattern — and one of mine, &lt;code&gt;/s/{code}&lt;/code&gt;, has exactly the same shape as the spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unverified" was hiding two different facts
&lt;/h2&gt;

&lt;p&gt;I had the log check each crawler's IP against the published address ranges — Google's &lt;code&gt;googlebot.json&lt;/code&gt;, Bing's &lt;code&gt;bingbot.json&lt;/code&gt; — and report &lt;code&gt;verified&lt;/code&gt; against &lt;code&gt;unverified&lt;/code&gt;. A reader of the first write-up, who runs the same check over nginx logs, pointed out that my split quietly merged two things that are not alike:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a request that &lt;strong&gt;failed&lt;/strong&gt; the list: claims to be Googlebot, is not in Google's ranges — suspicious;&lt;/li&gt;
&lt;li&gt;a request with &lt;strong&gt;no list to check&lt;/strong&gt;: Anthropic publishes no ranges at all, so a ClaudeBot hit can never be more than unchecked — not suspicious, just unknowable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing those is the same error as calling an unreachable sitemap "empty", which this project has made more than once. So there are three counters now, and the digest prints what it actually knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;googlebot          183 hits (178 verified, 5 FAILED the list)
yandexbot           94 hits (94 no published list)
claudebot           39 hits (39 no published list)
google-inspection    2 hits (2 FAILED the list)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same reader made a second point I have taken: these lists &lt;strong&gt;move&lt;/strong&gt;, so a verification you made today will not reproduce against next month's list. The cache now keeps a dated snapshot of every list it fetches, sixty days of them, which costs a few hundred kilobytes and makes an old answer checkable.&lt;/p&gt;

&lt;p&gt;Those 5 Googlebot hits that failed the list are all mine, and I had to look at the addresses to know it: two came from my laptop testing with a spoofed agent, and three from the server itself — my own daily cloaking check, which asks for the homepage as Googlebot and compares it against the homepage as a browser. My monitoring impersonates Googlebot for a living, so of course it fails a Googlebot address check. The log recorded all five exactly as it was told, which is the point: &lt;strong&gt;a user agent is a claim, and a claim is not an identity.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the same visibility
&lt;/h2&gt;

&lt;p&gt;You do not need a log pipeline. Roughly 120 lines of PHP: match the user agent against a list of crawler tokens, append one line at shutdown — time, crawler, &lt;strong&gt;status served&lt;/strong&gt;, path, IP — and never write a line for a human. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count distinct paths, not requests.&lt;/strong&gt; 9,629 hits sounded like a crawl-rate problem; 9,015 &lt;em&gt;distinct&lt;/em&gt; dead URLs is a completely different diagnosis. Any &lt;a href="https://utilorax.com/tools/remove-duplicate-lines" rel="noopener noreferrer"&gt;list of lines will collapse to its unique entries&lt;/a&gt; in a second, and the gap between the two numbers is usually where the story is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what you are telling crawlers to skip.&lt;/strong&gt; Mine already &lt;a href="https://utilorax.com/tools/robots-txt-generator" rel="noopener noreferrer"&gt;blocks the training crawlers I want blocked&lt;/a&gt; and nothing else — worth confirming rather than assuming, because a &lt;code&gt;Disallow&lt;/code&gt; in the wrong user-agent group reads as harmless and is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the status you served, not the path requested.&lt;/strong&gt; The interesting line is never "Googlebot asked for X". It is "Googlebot was given a 404 for X".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three weeks after the site was clean, most of one search engine's attention was still going to the attacker's imagination. Nothing in Search Console would have told me that, and nothing in my code was telling me either — until the site started writing it down.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>security</category>
      <category>webdev</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>I gave my site an access log. Within a minute it showed Bing crawling a hacker''s URLs.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 16:00:54 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/i-gave-my-site-an-access-log-within-a-minute-it-showed-bing-crawling-a-hackers-urls-30c5</link>
      <guid>https://dev.to/hammad4june1999/i-gave-my-site-an-access-log-within-a-minute-it-showed-bing-crawling-a-hackers-urls-30c5</guid>
      <description>&lt;p&gt;My host keeps access logs in a control panel I have to click through, and nowhere a script can read them. So after September's compromise I could not answer the one question that mattered — &lt;em&gt;are the crawlers coming back, and what are they being served?&lt;/em&gt; — except by waiting two days for Search Console to round it into a weekly shape.&lt;/p&gt;

&lt;p&gt;So I gave the site its own log. About 120 lines of PHP: if the user agent names a known crawler, append one line at shutdown with the time, the crawler, &lt;strong&gt;the status code it was actually served&lt;/strong&gt;, the path and the IP. Humans are never written to it.&lt;/p&gt;

&lt;p&gt;The first day's output, in full, from the first minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15:51:01  bingbot  200  /calculators/pace-calculator          40.77.167.123
15:51:34  bingbot  404  /stimulated/a0d3o2b2o25378994         40.77.167.123
15:51:36  bingbot  404  /harshly/p0o9s1i9t9i3o7n38906         40.77.167.123
15:51:38  bingbot  404  /disservice/u1n6g2u8e5s4670872        40.77.167.123
15:51:40  bingbot  404  /dictation/g0o6g3g2l3e3e1y3e0264      40.77.167.123
15:51:41  bingbot  404  /coadjust/v1i4m1i5n4a2r1i8a6529       40.77.167.123
15:51:48  bingbot  404  /selfapproving/p1r3e2l1e9c2t1e3d9648  40.77.167.123
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the end of the day: &lt;strong&gt;24 of 26 crawler requests were to URLs a cloaker invented three weeks earlier.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those paths are not random junk. They are the exact shape the malware generated — a dictionary word, a slash, a second word with digits pushed between its letters — and it handed about 291 fresh ones to every crawler that asked for a page, for six days, before the site was cleaned. The malware has been gone for weeks. The queue it created has not.&lt;/p&gt;

&lt;h2&gt;
  
  
  404 was the wrong answer
&lt;/h2&gt;

&lt;p&gt;Every one of those URLs was returning 404, which is correct in the sense that they are not there. But 404 means &lt;em&gt;not here right now&lt;/em&gt;, and crawlers treat it as a reason to come back and check again later. That is why they are still working through a list from three weeks ago.&lt;/p&gt;

&lt;p&gt;410 means &lt;em&gt;gone; stop asking&lt;/em&gt;. Both Google and Bing drop a 410 faster than a 404, and the difference is exactly the behaviour I want.&lt;/p&gt;

&lt;p&gt;The risk in a rule like this is obvious: match too broadly and you 410 something real. So I wrote the shape, then measured it before it went anywhere near the site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$seg&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// our slugs are hyphenated&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&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;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~[0-9]~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~[a-z]~i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;Result: &lt;strong&gt;9 of 9 known spam paths matched, 0 of 6,240 real URLs did.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One more precaution, and it is the part I would get wrong if I were rushing. The rule does not run at the start of routing. It runs at the &lt;em&gt;end&lt;/em&gt;, inside the 404 handler — the place a request only reaches once it has failed to match anything real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …if the path has the cloaker's shape, 410 instead of 404…&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Robots-Tag: noindex'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&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;That ordering is not decoration. My site has a &lt;code&gt;/s/{code}&lt;/code&gt; short-link route: two segments, no hyphen, letters and digits — it matches the shape. Put the rule first and every short link a user shares becomes 410 Gone. Put it last, and a live route can never be caught by it, because a live route never reaches the 404 handler at all.&lt;/p&gt;

&lt;p&gt;Verified on the live site afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/accidence/d0e3p2o6s2i8t6a6r8y548   410      (cloaker shape)
/no-such-page                        404      (ordinary typo)
/s/a1b2c3d4e5                        302      (short link, still works)
/tools/json-formatter                200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The log lied to me once on day one
&lt;/h2&gt;

&lt;p&gt;One line said &lt;code&gt;googlebot 200 /&lt;/code&gt;. I was pleased for about ten seconds, until I looked at the IP: &lt;code&gt;39.34.131.166&lt;/code&gt; — my own network. It was me, testing with a spoofed user agent earlier that afternoon. The log had recorded exactly what it was told, which is all a user agent ever is: &lt;strong&gt;a claim anybody can type.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the log now checks the claim against the crawlers' own published address lists — Google's &lt;code&gt;googlebot.json&lt;/code&gt; and &lt;code&gt;special-crawlers.json&lt;/code&gt;, Bing's &lt;code&gt;bingbot.json&lt;/code&gt;, cached for a day — and reports both numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bingbot     25 hits (25 verified, 0 unverified)
googlebot    1 hit  (0 verified, 1 unverified)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the alarm that says "Googlebot has not come" now counts verified visits only, because an alarm that can be silenced by anyone typing &lt;code&gt;Googlebot&lt;/code&gt; into a user agent string is not an alarm.&lt;/p&gt;

&lt;p&gt;If you are running this kind of check yourself: your crawler's &lt;a href="https://utilorax.com/tools/http-status-checker" rel="noopener noreferrer"&gt;status codes are what it believes about your site&lt;/a&gt;, so log what was &lt;em&gt;served&lt;/em&gt;, not what was requested. And if you end up needing to retire a batch of URLs permanently, an &lt;a href="https://utilorax.com/tools/htaccess-redirect-generator" rel="noopener noreferrer"&gt;.htaccess rule&lt;/a&gt; is the cheaper place to do it than in application code — as long as you have measured the pattern against your real URLs first.&lt;/p&gt;

&lt;p&gt;Three weeks after the cleanup, most of my crawl budget was still being spent on the attacker's imagination. I only know that because the site finally started writing things down.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>security</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My monitoring said 26 pages were broken. Every one of them was fine.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 25 Sep 2026 19:02:18 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-monitoring-said-26-pages-were-broken-every-one-of-them-was-fine-i5j</link>
      <guid>https://dev.to/hammad4june1999/my-monitoring-said-26-pages-were-broken-every-one-of-them-was-fine-i5j</guid>
      <description>&lt;p&gt;This morning's report from my own daily checker opened like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEEDS YOU — 27 thing(s)
  * / returns HTTP 403 but had 33 impressions
  * /es/tools/text-summarizer returns HTTP 403 but had 11 impressions
  * /tools/text-to-slug returns HTTP 520 but had 11 impressions
  … 23 more …
  * sitemap.xml returned nothing usable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-six pages and the sitemap. The homepage among them. My first thought was that the host had suspended the account — SSH to the box was timing out at the same moment, which fitted.&lt;/p&gt;

&lt;p&gt;Every one of those pages was serving normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the site was actually doing
&lt;/h2&gt;

&lt;p&gt;Before touching anything I measured the same URL through different clients. Forty requests, alternating, same minute:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;client&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;curl sending a Chrome user agent&lt;/td&gt;
&lt;td&gt;20 of 20 → &lt;strong&gt;403&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl sending Googlebot's user agent&lt;/td&gt;
&lt;td&gt;20 of 20 → &lt;strong&gt;200&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;real headless Chrome&lt;/td&gt;
&lt;td&gt;5 of 5 → &lt;strong&gt;200&lt;/strong&gt;, full content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl sending &lt;code&gt;curl/8.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;mixed, mostly 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table twice. The requests that claimed to be a browser were refused. The request that claimed to be Googlebot was served. An actual browser was served. So the site was fine for humans, fine for Google — and hostile to exactly one population: &lt;strong&gt;things that say "Chrome" without being Chrome.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a fault. That is bot protection at the edge doing its job. Cloudflare-style protection compares what a client &lt;em&gt;claims&lt;/em&gt; against how it behaves, and a curl request wearing a Chrome user agent is the oldest impersonation in the book. My checker had been making that exact request every morning for months, and the day the protection tightened, my checker became a bot to be blocked.&lt;/p&gt;

&lt;p&gt;The 520s were the same story from a different angle: a proxy failing to get a clean answer from the origin, on requests that were being refused anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is not the block. It is the report.
&lt;/h2&gt;

&lt;p&gt;The block is somebody else's defence working. The failure that cost me an hour is that my own script turned &lt;em&gt;I was refused&lt;/em&gt; into &lt;em&gt;the page is broken&lt;/em&gt; — twenty-six times, in a list headed NEEDS YOU.&lt;/p&gt;

&lt;p&gt;Four changes, and they are all the same change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Ask whether you can see, before saying what you see.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;seoCanSee&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// fetch the homepage, then:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SEO_MARKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'why'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;];&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="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'why'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'the homepage answered HTTP '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' to this script'&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;If that fails, nothing downstream is allowed to report on individual pages at all. One line goes out — &lt;em&gt;this script cannot reach the site; page, sitemap and robots checks did NOT run; a browser and Googlebot may well be served normally&lt;/em&gt; — and the per-page loop never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Look at the body, not only at the status.&lt;/strong&gt; A 403 from my application would carry my page. A 403 from the edge carries the edge's page. Same integer, different fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;520&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ours'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$edgeBlocked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Report one fact once.&lt;/strong&gt; The blockage is upstream of every page, so it is one line — &lt;em&gt;3 pages could not be checked: our own edge answered 403 to this script&lt;/em&gt; — not one alarm per page. Twenty-six alarms for one cause is how a NEEDS YOU list becomes wallpaper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check what your loudest alarm does when the probe is blocked.&lt;/strong&gt; Mine compares the homepage as Googlebot sees it against the homepage as a browser sees it, and shouts CLOAKING if they differ — the alarm that means &lt;em&gt;your site is hacked&lt;/em&gt;. With browser-UA requests being refused, the "browser" side was a 403 page, whose &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; is &lt;code&gt;403 Forbidden&lt;/code&gt;. Two different titles. That alarm was one edge-rule away from telling me I had been hacked again, three weeks after I actually had been. Now a response that is not my page is not a comparison — it is "did not see it".&lt;/p&gt;

&lt;p&gt;After all four: 27 items became 4, and the four are true.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are chasing the same ghost
&lt;/h2&gt;

&lt;p&gt;Three things worth doing before you believe your own monitoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load the page in a real browser.&lt;/strong&gt; Not curl with a browser string — an actual browser. If it renders, your users are fine and your problem is your client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at what your client is really sending.&lt;/strong&gt; Your checker, your CI job, your uptime probe: each one has a user agent, and it may not be what you think. &lt;a href="https://utilorax.com/tools/what-is-my-user-agent" rel="noopener noreferrer"&gt;See exactly what a client sends&lt;/a&gt;, then make your script say something honest rather than dressing up as Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the whole response, not the code.&lt;/strong&gt; &lt;a href="https://utilorax.com/tools/http-header-checker" rel="noopener noreferrer"&gt;The headers a URL actually returns&lt;/a&gt; will usually name the thing that answered — &lt;code&gt;server: cloudflare&lt;/code&gt;, &lt;code&gt;x-turbo-charged-by: LiteSpeed&lt;/code&gt;, a &lt;code&gt;cf-ray&lt;/code&gt; id — and that tells you whether you reached your application or something standing in front of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable part is that none of this was a site problem for a single minute. Every visitor and every crawler was served the whole time. The only thing that was down was my ability to tell.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>webdev</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>My check said "all 17 articles". There were 18.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 22 Sep 2026 17:52:44 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-check-said-all-17-articles-there-were-18-jn3</link>
      <guid>https://dev.to/hammad4june1999/my-check-said-all-17-articles-there-were-18-jn3</guid>
      <description>&lt;p&gt;Every morning a script of mine fetches each of my published articles and checks that the outbound links are still followed. Yesterday's run ended with this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev.to: links on all 18 published articles are still followed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today's run, same script, nothing removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev.to: links on all 17 published articles are still followed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had published one article since. The honest number was 18 and rising. The word doing the damage is &lt;strong&gt;all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$articles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- here&lt;/span&gt;
    &lt;span class="nv"&gt;$checked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// …find every link to my site, look at its rel attribute…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// later&lt;/span&gt;
&lt;span class="nv"&gt;$did&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'links on all '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$checked&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' published articles are still followed.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One article did not come back — a throttle, a hiccup, it does not matter. The loop skipped it, &lt;code&gt;$checked&lt;/code&gt; stayed where it was, and the summary printed &lt;code&gt;$checked&lt;/code&gt; &lt;strong&gt;as if it were the total&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the sentence was not "17 of 18 are fine, 1 is unknown". It was "all 17", which asserts something about a set I never measured. The one article that could have gone bad was precisely the one that was not looked at, and the report was worded to sound most confident about exactly that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the denominator is the whole thing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$checked&lt;/code&gt; answers "how many replied". The summary claims to answer "how many exist and are fine". Those are the same integer only when nothing fails, which is the one case where you do not need the check.&lt;/p&gt;

&lt;p&gt;A count of successes cannot describe a set unless you also carry the size of the set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$checked&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="nv"&gt;$unchecked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$articles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DevTo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&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="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$unchecked&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$checked&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$checked&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$unchecked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the report can say what actually happened: &lt;em&gt;links still followed on 17 of 18; 1 could not be fetched twice and was NOT checked&lt;/em&gt;. And when most of them fail, that is no longer a quiet line at the bottom of an email — it is escalated, because a check that could not run is not a check that passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the third time
&lt;/h2&gt;

&lt;p&gt;The same shape has bitten this project three times in a month, in three different files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;where&lt;/th&gt;
&lt;th&gt;what it printed&lt;/th&gt;
&lt;th&gt;what was true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sitemap check&lt;/td&gt;
&lt;td&gt;"1 of 54 child sitemaps is empty"&lt;/td&gt;
&lt;td&gt;the child was throttled, not empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots.txt check&lt;/td&gt;
&lt;td&gt;passed, six days running&lt;/td&gt;
&lt;td&gt;it was reading an attacker's planted file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;link check&lt;/td&gt;
&lt;td&gt;"all 17 articles"&lt;/td&gt;
&lt;td&gt;there were 18; one was never fetched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them turned &lt;em&gt;I could not measure this&lt;/em&gt; into &lt;em&gt;I measured this and it is fine&lt;/em&gt;. That direction of error is the dangerous one: a false alarm gets investigated, a false all-clear gets filed.&lt;/p&gt;

&lt;p&gt;So I wrote the test as a source check rather than a behaviour test, because the bug lives in a shape, not in a value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~^\s*if \(\$html === .{2}\) \{ *continue; *\}~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$silentSkips&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'line %d drops a page without recording it: %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&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="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'no fetch failure is skipped without being counted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$silentSkips&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;I put the old line back to make sure the test fails on it, then took it out again. It fails on the bug and passes on the fix — which is the only way to know a regression test is a test and not decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions worth asking your own reports
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the denominator?&lt;/strong&gt; If a line says "all N", find where N comes from. If N is a counter that increments on success, the word "all" is a lie waiting for a bad day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it tell "nothing failed" from "nothing was checked"?&lt;/strong&gt; Zero failures out of zero attempts prints the same as a clean run. Report failures &lt;em&gt;against attempts&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the count come from the source of truth or from the transport?&lt;/strong&gt; The number of URLs you have comes from your list — for a site, the sitemap itself, which you can &lt;a href="https://utilorax.com/tools/sitemap-downloader" rel="noopener noreferrer"&gt;pull into a plain list of every URL&lt;/a&gt; and count — not from how many of them your script happened to load. Then check each one's &lt;a href="https://utilorax.com/tools/http-status-checker" rel="noopener noreferrer"&gt;status code&lt;/a&gt; against that list, so the two numbers stay separate and the gap between them stays visible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is four lines. The habit is harder: when a script tells you everything is fine, ask it how many things it actually looked at.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>invalid_grant: my service account was fine. My laptop's clock was three days slow.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Sun, 20 Sep 2026 21:50:41 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/invalidgrant-my-service-account-was-fine-my-laptops-clock-was-three-days-slow-11hm</link>
      <guid>https://dev.to/hammad4june1999/invalidgrant-my-service-account-was-fine-my-laptops-clock-was-three-days-slow-11hm</guid>
      <description>&lt;p&gt;My daily SEO round emails me one digest. This morning it led with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEEDS YOU — 5 thing(s)
  * Search Console could not be read today.
  ...

SEARCH CONSOLE — COULD NOT READ
  Token request failed (HTTP 400): {"error":"invalid_grant",
  "error_description":"Invalid JWT: Token must be a short-lived token
  (60 minutes) and in a reasonable timeframe. Check your iat and exp
  values in the JWT claim."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;invalid_grant&lt;/code&gt; is the error you get when a credential is wrong, so that is where I went first: had the service account been disabled, had someone rotated the key, had I lost access to the property? I had spent six days cleaning a compromised hosting account the week before, so "the key is gone" was not a paranoid thought.&lt;/p&gt;

&lt;p&gt;All of that was the wrong question, and the report had already told me so. I just had not read the line above the error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header was the evidence
&lt;/h2&gt;

&lt;p&gt;The digest's own title line said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Utilorax SEO — Thu 17 Sep 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That date is generated by the script at run time, from the machine it runs on. I asked something with an authoritative clock what day it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.google.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^date:'&lt;/span&gt;
Date: Sun, 20 Sep 2026 21:03:40 GMT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three days apart. The machine that signed the token thought it was the 17th.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that is fatal to a signed token, and to nothing else
&lt;/h2&gt;

&lt;p&gt;Here is the part of my client that builds the assertion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$claim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;b64url&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'iss'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'client_email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SCOPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'aud'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token_uri'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'exp'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'iat'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$now&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;There is no bug in it. &lt;code&gt;iat&lt;/code&gt; is now and &lt;code&gt;exp&lt;/code&gt; is an hour from now — exactly what Google asks for. But &lt;em&gt;now&lt;/em&gt; is whatever the machine says it is, and the machine was saying Thursday. So Google received a token that claimed to have been issued three days ago and to have expired two days and twenty-three hours ago. It refused, and its refusal named the two fields it was unhappy with: &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt;. The error text was a precise description of the fault. I read it as a category — "auth error" — instead of as a sentence.&lt;/p&gt;

&lt;p&gt;The giveaway I walked past: &lt;strong&gt;every other check in the same run was fine.&lt;/strong&gt; IndexNow checked what had changed and correctly sent nothing, dev.to answered, the sitemap's 54 children were counted, the AI model probe came back. Not one of them signs anything, so not one of them cares what time my laptop thinks it is. The only check that broke was the only one holding a stopwatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two minutes that would have saved an hour
&lt;/h2&gt;

&lt;p&gt;If you get &lt;code&gt;invalid_grant&lt;/code&gt; from a Google, Apple or AWS token endpoint, before you touch the key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compare your clock to theirs.&lt;/strong&gt; &lt;code&gt;curl -sI https://www.google.com | grep -i ^date&lt;/code&gt; costs nothing and answers the question outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the token you actually sent.&lt;/strong&gt; The assertion is a JWT, so paste it into a &lt;a href="https://utilorax.com/tools/jwt-decoder" rel="noopener noreferrer"&gt;JWT decoder&lt;/a&gt; and look at the payload rather than at your code. Your code says what you meant; the token says what you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; into dates.&lt;/strong&gt; They are Unix seconds, which are unreadable on sight — &lt;code&gt;1789938294&lt;/code&gt; looks like every other ten-digit number. A &lt;a href="https://utilorax.com/tools/unix-timestamp-converter" rel="noopener noreferrer"&gt;Unix timestamp converter&lt;/a&gt; turns them into a date you can compare to today's, and the gap is either zero or your whole answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule of thumb worth internalising: &lt;strong&gt;a signed short-lived token is a claim about time, so a rejected one is as likely to be a clock complaint as a credential complaint.&lt;/strong&gt; The word &lt;code&gt;invalid_grant&lt;/code&gt; is doing double duty for both, which is why the &lt;code&gt;error_description&lt;/code&gt; — not the &lt;code&gt;error&lt;/code&gt; — is the field to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that went right
&lt;/h2&gt;

&lt;p&gt;The round did not print zero impressions. It said &lt;em&gt;Search Console could not be read today&lt;/em&gt;, kept the section, and put the raw HTTP body in it.&lt;/p&gt;

&lt;p&gt;That distinction has bitten this project repeatedly in the other direction: a sitemap check that reported unreachable children as empty, a robots.txt check that passed for six days on an attacker's file, a fetch failure that arrived as "no data". Every one of them turned a failure to measure into a measurement. Here the instrument said it was blind, said why, and handed me the sentence that contained the answer.&lt;/p&gt;

&lt;p&gt;I still needed three days of wrong clock before I read that sentence properly. But I got to read it, which is the whole difference.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The clock has since resynced on its own. The same round, re-run: Search Console read, 46 pages snapshotted, nothing owed.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>devops</category>
      <category>php</category>
      <category>api</category>
    </item>
    <item>
      <title>The backdoor was a database row</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:30:53 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/the-backdoor-was-a-database-row-8dk</link>
      <guid>https://dev.to/hammad4june1999/the-backdoor-was-a-database-row-8dk</guid>
      <description>&lt;p&gt;I cleaned a compromised hosting account on 5 September and wrote down, with some satisfaction, that it was done. On 11 September I found the rest of it.&lt;/p&gt;

&lt;p&gt;The part that bothers me is not that I missed something. It is &lt;em&gt;why&lt;/em&gt; I missed it: I had built three scanners, and the thing they were looking for was not in any file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a file scanner was asked to find
&lt;/h2&gt;

&lt;p&gt;This is the file that mattered, in full. 420 bytes, sitting where a WordPress core file should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'DOCUMENT_ROOT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/wp-load.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_codehidden'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tmpfile&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="nv"&gt;$tmp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'uri'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No obfuscation. No &lt;code&gt;eval&lt;/code&gt;. No hex-escaped strings, no &lt;code&gt;goto&lt;/code&gt; spaghetti, no variable function names. It reads well. Every one of my signature checks looked straight at it and had nothing to say, because there is nothing in it to flag — the malicious part is a &lt;strong&gt;row in &lt;code&gt;wp_options&lt;/code&gt;&lt;/strong&gt;, and the file is just the thing that fetches it.&lt;/p&gt;

&lt;p&gt;The row held 9,784 bytes of base64. Decoded: 7,338 bytes, whose first line is &lt;code&gt;eval(base64_decode('...'))&lt;/code&gt; and whose inner layer reads &lt;code&gt;HTTP_HOST&lt;/code&gt;, &lt;code&gt;HTTP_ACCEPT_LANGUAGE&lt;/code&gt; and &lt;code&gt;HTTP_REFERER&lt;/code&gt; and picks a remote host from a list of ROT13'd domains. A cloaker — the same kind that had been serving a Japanese shopping page to Googlebot from the main site. Stored in the database, so &lt;code&gt;grep -r&lt;/code&gt; across 161,762 files finds exactly nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only authority that worked
&lt;/h2&gt;

&lt;p&gt;The file was one of ~2,000 in a WordPress install, and it looked plausible. What caught it is that WordPress publishes the md5 of every file it ships:&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;https://api.wordpress.org/core/checksums/1.0/?version=7.1&amp;amp;locale=en_US
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives a map of &lt;code&gt;path =&amp;gt; md5&lt;/code&gt; for the whole core. Three questions fall out of it, and the third is the one that mattered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sums&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$md5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$path&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="nv"&gt;$missing&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// deleted&lt;/span&gt;
    &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;md5_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$md5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$modified&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// changed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// and the other direction: anything under wp-admin/ or wp-includes/&lt;/span&gt;
&lt;span class="c1"&gt;// that the checksum list has never heard of&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$onDisk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$foreign&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$path&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;Across 18 installs on the account: 15 came back clean, and the three that did not told me exactly where to look. The loader showed up as &lt;strong&gt;MODIFIED&lt;/strong&gt;. Three empty &lt;code&gt;.php&lt;/code&gt; files in invented directories — &lt;code&gt;wp-includes/blocks/5HJlg/block/wp-utility.php&lt;/code&gt; and friends, all &lt;code&gt;chmod 777&lt;/code&gt; — showed up as &lt;strong&gt;FOREIGN&lt;/strong&gt;. Another site was missing 74 core files, which is why it had been answering 500 to every visitor.&lt;/p&gt;

&lt;p&gt;This is the difference between a signature and an authority. My signatures encode what I imagine malware looks like. The checksum list encodes what the software &lt;em&gt;is&lt;/em&gt;. Only one of those keeps working when the attacker writes something I did not imagine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I got wrong, out loud
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I cleared a file because it was not obfuscated.&lt;/strong&gt; On 5 September I read the site's &lt;code&gt;wp-login.php&lt;/code&gt;, saw no &lt;code&gt;eval&lt;/code&gt;, no base64, no hex, and moved on. The checksums flagged it. The diff turned out to be two blank lines and a missing newline — so my &lt;em&gt;conclusion&lt;/em&gt; was right, no credential stealer — but my &lt;em&gt;reason&lt;/em&gt; was worthless. "It does not look like malware" is not evidence. I only knew the file was clean once something authoritative told me what it should be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I wrote a scanner rule that was certain and wrong.&lt;/strong&gt; It flagged images containing PHP, which is a real technique:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$isImg&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~&amp;lt;\?php|&amp;lt;\?=|eval\s*\(~i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* CERTAIN */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reported &lt;strong&gt;277 files&lt;/strong&gt;, labelled CERTAIN, across ordinary stock photos and one of Advanced Custom Fields' own PNGs. &lt;code&gt;&amp;lt;?=&lt;/code&gt; is three bytes. In a 700 KB binary, three particular bytes turn up by chance constantly. Retested with the five-byte &lt;code&gt;&amp;lt;?php&lt;/code&gt; alone: &lt;strong&gt;zero&lt;/strong&gt;. Nothing was deleted on that basis, but only because I checked before acting — and I had labelled the rule CERTAIN, which is precisely the label that invites not checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the database knew that the filesystem did not
&lt;/h2&gt;

&lt;p&gt;Once I was reading the database, the rest of the story was there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lms   patilari       31 Aug 05:53
lms   suxexnob        4 Sep 05:45     1 live session
lms   root_1n84gk9    4 Sep 07:13:21  2 live sessions
lms   jalejsul        8 Sep 09:09
site2 patilari       31 Aug 07:05:04
site2 root            2 Sep 01:25:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Administrator accounts. &lt;code&gt;root_1n84gk9&lt;/code&gt; was created in the same minute as those 777 files. &lt;code&gt;patilari&lt;/code&gt; appears on two unrelated sites, and on the second one a file-manager plugin was installed &lt;strong&gt;nine seconds&lt;/strong&gt; after the account existed. And &lt;code&gt;jalejsul&lt;/code&gt; is dated 8 September — three days after I declared the account clean.&lt;/p&gt;

&lt;p&gt;That last row is the whole lesson. I had removed their files. They still had a way in, and it was never a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer an authority to a signature.&lt;/strong&gt; For WordPress that is the checksums API; for your own code it is a manifest you generated somewhere the attacker cannot reach. A signature list only finds malware that resembles malware you have already seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep the datastore too.&lt;/strong&gt; Anything that can be &lt;code&gt;include&lt;/code&gt;d can be stored: a row, an object-cache entry, a serialised option, an S3 key. A scanner that only walks a filesystem is auditing half the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you label a check CERTAIN, go and verify it once on real data&lt;/strong&gt; — before you build anything that acts on it automatically. Mine would have deleted 277 innocent photos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean does not mean cleaned.&lt;/strong&gt; After an incident, re-verify against something the attacker could not have touched. A baseline taken on a compromised system certifies the compromise.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. If you want to check a file against a published hash without installing anything, the &lt;a href="https://utilorax.com/tools/md5-hash-generator" rel="noopener noreferrer"&gt;MD5 hash generator&lt;/a&gt; runs entirely in your browser — the file never leaves your machine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>php</category>
      <category>wordpress</category>
      <category>devops</category>
    </item>
    <item>
      <title>My robots.txt check passed for six days on an attacker's robots.txt</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:15:36 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/my-robotstxt-check-passed-for-six-days-on-an-attackers-robotstxt-51jc</link>
      <guid>https://dev.to/hammad4june1999/my-robotstxt-check-passed-for-six-days-on-an-attackers-robotstxt-51jc</guid>
      <description>&lt;p&gt;Every morning a script checks my site's health and emails me. One of its lines, for six days running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fine. Sitemap index lists 54 child sitemaps holding 6,240 URLs in total,
robots.txt points at it, and every page earning impressions answers 200.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For those six days, this was my robots.txt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Allow&lt;/span&gt;: /

&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemapindex&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap249&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap476&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap531&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap458&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/?&lt;span class="n"&gt;sitemap522&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;utilorax&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/&lt;span class="n"&gt;goods&lt;/span&gt;.&lt;span class="n"&gt;php&lt;/span&gt;?&lt;span class="n"&gt;sitemap806&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those are mine. &lt;code&gt;goods.php&lt;/code&gt; is the giveaway: it is the shape of a Japanese shop-spam campaign, and the site had been hit by exactly that — a cloaker serving Googlebot a clothing store while people saw the real site. I cleaned that up on 5 September and wrote down, with some satisfaction, that the site was clean.&lt;/p&gt;

&lt;p&gt;It was not. The cloaker had left a second thing behind, and every instrument I had built to catch the first one looked straight at it and said &lt;em&gt;fine&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://utilorax.com/robots.txt'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Sitemap:'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt does not point at a sitemap'&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;It asks whether robots.txt &lt;em&gt;mentions a sitemap&lt;/em&gt;. The attacker's file mentions seven. It passed with a margin.&lt;/p&gt;

&lt;p&gt;This is the same bug as a login check that verifies a password field is non-empty. The test was written against the failure I imagined — someone deleting the Sitemap line by accident — and not against the failure that happened, which is someone replacing it on purpose. &lt;strong&gt;A check for the presence of a field passes on anyone's field.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a static file beat a dynamic one
&lt;/h2&gt;

&lt;p&gt;My real robots.txt is not a file. &lt;code&gt;index.php&lt;/code&gt; generates it, so the sitemap URL always matches the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="cm"&gt;/* sitemap*.xml */&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Sitemap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&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;and &lt;code&gt;.htaccess&lt;/code&gt; sends a request there only when no real file exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_FILENAME} !-f
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_FILENAME} !-d
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ index.php [L]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So dropping a plain &lt;code&gt;robots.txt&lt;/code&gt; into the web root does not fight the generator. It simply wins, silently, because that is what &lt;code&gt;!-f&lt;/code&gt; means. There is no conflict to notice.&lt;/p&gt;

&lt;p&gt;The file itself had been dressed for the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;-r--r--r--  356 bytes   Jul 7 2024   robots.txt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read-only, so a careless cleanup would fail. And dated &lt;strong&gt;July 2024&lt;/strong&gt; — on a domain registered in July 2026. The only file on the server older than the project, and the only file outside &lt;code&gt;vendor/&lt;/code&gt; set to 444.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three instruments, one blind spot each
&lt;/h2&gt;

&lt;p&gt;What bothers me is not that one check was weak. It is that I had built three, after the hack, specifically so this could not happen again — and each one had a different reason to wave it through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The health check&lt;/strong&gt; tested for a string the attacker supplied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integrity monitor&lt;/strong&gt; hashes every static file and reports anything that changes. I took its baseline &lt;em&gt;after&lt;/em&gt; the cleanup — so the planted robots.txt went into the baseline as a known-good file. From then on, the monitor's job was to guarantee the attacker's file stayed exactly as they left it. It did that perfectly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A baseline taken on a compromised system does not detect the compromise. It certifies it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The off-server verifier&lt;/strong&gt; compares live files against git and lists anything on the server that git does not know about. On 5 September it listed &lt;code&gt;robots.txt&lt;/code&gt;. I read the list, saw a handful of expected runtime files — caches, a ratings database, a speed-test payload — and filed &lt;code&gt;robots.txt&lt;/code&gt; with them, because a site having a robots.txt looked normal.&lt;/p&gt;

&lt;p&gt;It was the only item on that list that could not legitimately exist. The real one is generated; a file by that name should never be on disk at all. The verifier was right and I explained it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The check now asks &lt;em&gt;whose&lt;/em&gt; sitemap, and treats a file existing as the finding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;preg_match_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~^\s*Sitemap:\s*(\S+)~mi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$ours&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://utilorax.com/sitemap.xml'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$foreign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sm&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="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&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;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ours&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sm&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt does not list our sitemap'&lt;/span&gt;&lt;span class="p"&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="nv"&gt;$foreign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'robots.txt lists sitemaps that are not ours'&lt;/span&gt;&lt;span class="p"&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="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$webRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/robots.txt'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$problems&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'a static robots.txt exists — the real one is dynamic, so this is shadowing it'&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 verifier has a separate section for static files that shadow a generated route, so it cannot be mistaken for runtime clutter again. And a failed fetch is reported as &lt;em&gt;not evaluated today&lt;/em&gt;, rather than as &lt;em&gt;no sitemap&lt;/em&gt; — the old code turned an empty response into the same message as a real problem, which is part of how I found this: a false alarm made me open the actual file.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the fix was invisible
&lt;/h2&gt;

&lt;p&gt;I deleted the file, fetched &lt;code&gt;/robots.txt&lt;/code&gt;, and got my real one back. Then the new check ran and fired again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*** robots.txt lists 7 sitemap(s) that are not ours ***
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had tested with a cache-busting query string. The plain URL — the one crawlers request — was coming from the CDN:&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;cf-cache-status: HIT
Cache-Control: max-age=14400
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The origin was fixed. The edge was still serving the attacker's file, for up to four more hours, to every crawler that asked. If I had kept the old string check, it would have passed that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;Three things, and none of them are about robots.txt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test for the right value, not for a value.&lt;/strong&gt; "Is there a sitemap line" and "is it our sitemap line" differ by exactly one attacker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never baseline a system you have just cleaned&lt;/strong&gt; without first proving it against something the attacker could not touch. My proof was git, and I read its answer too quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An "expected" list is where things hide.&lt;/strong&gt; Every exception you wave through once becomes invisible forever. If a file cannot legitimately exist, it does not belong in a list of files that usually do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And test your fix the way the crawler will see it, not the way you will.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. If you want to see what your own robots.txt actually tells a crawler — including which sitemaps it advertises — the &lt;a href="https://utilorax.com/tools/robots-txt-tester" rel="noopener noreferrer"&gt;robots.txt tester&lt;/a&gt; will show you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>php</category>
      <category>devops</category>
    </item>
    <item>
      <title>Page 999 of my 5-page blog returned 200 and said it was canonical</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:29:14 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/page-999-of-my-5-page-blog-returned-200-and-said-it-was-canonical-1d9i</link>
      <guid>https://dev.to/hammad4june1999/page-999-of-my-5-page-blog-returned-200-and-said-it-was-canonical-1d9i</guid>
      <description>&lt;p&gt;Search Console, this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Discovered - currently not indexed   20,852
Crawled - currently not indexed       1,582
Excluded by 'noindex' tag                63
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My sitemap has 6,240 URLs.&lt;/p&gt;

&lt;p&gt;Google knows about roughly 22,500 addresses on a site that publishes 6,240. Nobody typed the other sixteen thousand. Something on the site is generating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pager
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;paged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$page&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="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$all&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$page&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;      &lt;span class="c1"&gt;// &amp;lt;- here&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&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="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'pages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'page'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$page&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;That clamp is the sensible thing to write. Somebody asks for page 40 of five, you show them page 5 rather than an empty screen or a stack trace. I would write it again.&lt;/p&gt;

&lt;p&gt;The blog has 5 pages. Here is what the server does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page=1      HTTP 200   9 posts   canonical = /blog
page=5      HTTP 200   3 posts   canonical = /blog?page=5
page=6      HTTP 200   3 posts   canonical = /blog?page=6
page=999    HTTP 200   3 posts   canonical = /blog?page=999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page 6 does not exist. It answers 200, serves page 5's three posts, and tells Google &lt;strong&gt;this URL is the canonical version of this content&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So does page 7. And page 8. There is no largest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp was never the bug
&lt;/h2&gt;

&lt;p&gt;The clamp is right, and the caller is wrong, and it took me a while to see which was which.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;paged()&lt;/code&gt; returns &lt;code&gt;'page' =&amp;gt; 5&lt;/code&gt; when you ask for 999, because 5 is the page it is rendering. That is honest. But the template built its canonical from the &lt;strong&gt;request&lt;/strong&gt;, not the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'canonical'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;abs_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'?page='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$page&lt;/code&gt; there is 999. It was never reconciled with what the pager decided.&lt;/p&gt;

&lt;p&gt;So the function quietly answered two different questions with one number, and the caller picked the wrong one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;which page am I showing?&lt;/em&gt; → 5&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;does the page you asked for exist?&lt;/em&gt; → &lt;strong&gt;never asked, never answered&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second question is the one a router needs, and there was no way to ask it. That is the actual defect: not a wrong value, a missing one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$asked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pages&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="s1"&gt;'items'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&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="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'pages'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'page'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// what is being rendered&lt;/span&gt;
    &lt;span class="s1"&gt;'requested'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// what was asked for&lt;/span&gt;
    &lt;span class="s1"&gt;'exists'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$asked&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$pages&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$paged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BlogRepo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;paged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$paged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exists'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;notFound&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;and the canonical now uses &lt;code&gt;$paged['page']&lt;/code&gt; — the page that was served — rather than the number in the query string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page=5      HTTP 200   3 posts   canonical = /blog?page=5
page=6      HTTP 404
page=999    HTTP 404
page=abc    HTTP 200   9 posts   canonical = /blog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?page=abc&lt;/code&gt; still resolves to page 1, which is right: it is not a request for a page that does not exist, it is a request that does not name a page at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape is worth recognising
&lt;/h2&gt;

&lt;p&gt;A clamp is a &lt;strong&gt;rendering&lt;/strong&gt; decision. It answers "what do I draw". The moment its output is used to decide "what do I say this URL is", it has silently become a &lt;strong&gt;routing&lt;/strong&gt; decision, and it is the wrong tool for that, because clamping's entire job is to erase the difference between valid and out-of-range input.&lt;/p&gt;

&lt;p&gt;That is fine inside a view. It is not fine anywhere near an HTTP status or a canonical tag, and those are usually written a long way from the function doing the clamping.&lt;/p&gt;

&lt;p&gt;The generalisation I would carry:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A function that normalises its input owes the caller the un-normalised value too.&lt;/strong&gt; Otherwise every caller that needed to know "was this valid?" has to re-derive it, and the ones that forget do not fail — they succeed at something slightly wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;max()&lt;/code&gt; and &lt;code&gt;min()&lt;/code&gt; are where this hides. So is &lt;code&gt;?? $default&lt;/code&gt;, &lt;code&gt;intval()&lt;/code&gt;, and any &lt;code&gt;array_slice&lt;/code&gt; that silently returns fewer rows than you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I cannot claim
&lt;/h2&gt;

&lt;p&gt;I found one unbounded URL source and I fixed it. I cannot tell you it accounts for 20,852, because Search Console's Page Indexing report does not expose the URLs through the API, and I am not going to work backwards from a number I want to explain.&lt;/p&gt;

&lt;p&gt;What I can say is that this one is unbounded, it was reachable, it answered 200, and it invited indexing — and that until this morning I would have told you the site had 6,240 URLs.&lt;/p&gt;

&lt;p&gt;If your Discovered count is wildly larger than your sitemap, the thing to look for is not a leak of &lt;em&gt;pages&lt;/em&gt;. It is a parameter with no upper bound that still answers 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 5 exists'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 6 does not'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=6'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nor does page 999'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=999'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a non-numeric page is page 1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=abc'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/blog'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'page 5 is its own canonical'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/blog?page=5'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth and fifth assertions matter more than they look. The obvious fix — 404 anything that is not a valid page number — breaks &lt;code&gt;?page=abc&lt;/code&gt;, which should be page 1. The two cases sit either side of the same &lt;code&gt;if&lt;/code&gt; and the temptation is to write one test.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This one started with the &lt;a href="https://utilorax.com/tools/sitemap-url-counter" rel="noopener noreferrer"&gt;sitemap URL counter&lt;/a&gt;, which is how I knew the sitemap said 6,240 while Search Console said 22,500.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>96% of my labels were translated. The other 4% could never be.</title>
      <dc:creator>Hammad Shams Uddin</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:45:58 +0000</pubDate>
      <link>https://dev.to/hammad4june1999/96-of-my-labels-were-translated-the-other-4-could-never-be-5ffl</link>
      <guid>https://dev.to/hammad4june1999/96-of-my-labels-were-translated-the-other-4-could-never-be-5ffl</guid>
      <description>&lt;p&gt;My site ships in five languages. The calculator results go through a dictionary:&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;function&lt;/span&gt; &lt;span class="nf"&gt;T&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CH_CALC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// { "English label": "translation" }&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasOwnProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;Look up the English, fall back to the English. It is about as simple as translation gets, and for most of the site it works.&lt;/p&gt;

&lt;p&gt;Then I read a CPM calculator row on the Spanish page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Impresiones            20,000
Coste por impresión    0.0025
Impressions per 50.00  20,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row in English. Not a missing translation — I went and checked, and there was no key to add. There is no key that could be added.&lt;/p&gt;

&lt;h2&gt;
  
  
  The string does not exist until the reader types
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Impressions per &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Impressions per 50.00&lt;/code&gt;. Change the campaign cost to 75 and the label is &lt;code&gt;Impressions per 75.00&lt;/code&gt;. The dictionary would need an entry for every number anyone ever enters.&lt;/p&gt;

&lt;p&gt;This is a different category from "not translated yet", and I had been filing it under the same heading. Untranslated is a backlog. This is a &lt;strong&gt;defect&lt;/strong&gt;, and no amount of work on the dictionaries would ever have closed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many
&lt;/h2&gt;

&lt;p&gt;I did not want to guess, and I could not eyeball it — 112 calculators. But the engines are pure functions of an input object, so I could run all of them and read what came out:&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;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is telling the two categories apart. Run each calculator &lt;strong&gt;twice with different numbers&lt;/strong&gt;: a label that changes between the runs is being assembled; a label that stays put is a fixed string that is simply missing.&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;nudge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reader-facing strings   fixed: 397   built from input: 29
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The first count was wrong
&lt;/h2&gt;

&lt;p&gt;29 was too many, and the reason is worth the detour.&lt;/p&gt;

&lt;p&gt;BMI at one weight says &lt;code&gt;Healthy weight&lt;/code&gt;. At double that weight it says &lt;code&gt;Overweight&lt;/code&gt;. The string changed between runs, so my sweep called it assembled — but it is not assembled at all. It is a categorical label from a fixed set of four, and every one of them is sitting in the dictionary already, translated.&lt;/p&gt;

&lt;p&gt;The nudge did not reveal interpolation. It revealed a &lt;strong&gt;threshold&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the discriminator needs a second condition: the label must also contain a digit.&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;for &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;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;varying&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;/&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;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;varying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reader-facing strings   fixed: 405   built from input: 21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;21, across 18 calculators. And now the numbers say something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;es:  missing but fixable: 15    unreachable by lookup: 21   (all of them)
fr:  missing but fixable: 16    unreachable by lookup: 21   (all of them)
pt:  missing but fixable: 14    unreachable by lookup: 21   (all of them)
zh:  missing but fixable: 10    unreachable by lookup: 21   (all of them)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixed strings are about 96% translated — an ordinary, healthy backlog of a dozen or so. The assembled ones are 0% translated in all four languages, without exception.&lt;/p&gt;

&lt;p&gt;That is not a coincidence you have to interpret. &lt;strong&gt;A 96% column next to a 0% column is a mechanism, not a backlog.&lt;/strong&gt; If it were an effort problem the two would look alike. When one category is perfect and the neighbouring one is empty, something is preventing the work rather than nobody doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is to translate the shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{(\d)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Impressions per {0}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imp&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 key is now &lt;code&gt;Impressions per {0}&lt;/code&gt;, which is a constant. Twenty-three of them across the site.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;a === undefined ? m : ...&lt;/code&gt; matters more than it looks. Templates get edited in dictionaries, by people who are not looking at the call site, months later. If a translator adds a &lt;code&gt;{1}&lt;/code&gt; that no argument feeds, the honest outcome is a visible &lt;code&gt;{1}&lt;/code&gt; — not the word &lt;code&gt;undefined&lt;/code&gt; sitting in a financial result where a number belongs.&lt;/p&gt;

&lt;p&gt;And because an untranslated template falls back to English exactly like an untranslated string, this converts one label at a time with nothing to coordinate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it end to end
&lt;/h2&gt;

&lt;p&gt;Not "does TF work" — of course TF works. The question is whether a Spanish reader now sees Spanish, which means running the real calculators against the real dictionary:&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;function&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CALC&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;CH_CALC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CALC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;ENGINES&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;percentage-calculator     "200.00 plus 15%"       -&amp;gt; "200.00 más 15%"
discount-calculator       "You save 30.00"        -&amp;gt; "Ahorras 30.00"
roi-calculator            "Profit 3,000.00"       -&amp;gt; "Beneficio 3,000.00"
zakat-calculator          "Zakat due (2.5%)"      -&amp;gt; "Zakat a pagar (2.5%)"
paypal-fee-calculator     "Fee (2.9% + 0.30)"     -&amp;gt; "Comisión (2.9% + 0.30)"
cpm-calculator            "Impressions per 50.00" -&amp;gt; "Impresiones por 50.00"
emergency-fund-calculator "13,000.00 to go"       -&amp;gt; "Faltan 13,000.00"
...
20 labels now reach a Spanish reader in Spanish that could not before.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this also flushed out that &lt;code&gt;T()&lt;/code&gt; reached straight for &lt;code&gt;window&lt;/code&gt;. The engines had never called it — only the renderer did — so no Node test had ever touched that line. Making labels translatable inside the engines was what dragged the browser into a test suite that had happily run without one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two assertions I would keep
&lt;/h2&gt;

&lt;p&gt;The mechanical ones are the ones that survive:&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="c1"&gt;// Every TF template must exist in all four dictionaries. A template nobody&lt;/span&gt;
&lt;span class="c1"&gt;// translated is the same English string with extra steps.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/TF&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;'&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;'/g&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&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="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: all templates translated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="c1"&gt;// And no translation may drop a {0}. That does not look wrong in review -&lt;/span&gt;
&lt;span class="c1"&gt;// it looks like a slightly shorter sentence - and it deletes the number&lt;/span&gt;
&lt;span class="c1"&gt;// from a financial result.&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;: every placeholder survives&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dropped&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 template list is read out of the source rather than written down again in the test. A hand-maintained copy would drift, and it would drift &lt;em&gt;silently&lt;/em&gt;, in the direction of passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;+&lt;/code&gt; in a label is the whole bug. It is invisible in review because it is the most ordinary line of code in the world, and it does not fail — it produces exactly the right English string.&lt;/p&gt;

&lt;p&gt;If your product speaks more than one language, the pattern to grep for is a string literal next to a &lt;code&gt;+&lt;/code&gt; inside anything a reader will see. And if you already have translation coverage numbers, look at whether they cluster: a column at 96% and a column at 0% are not the same project running at different speeds. One of them is a wall.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://utilorax.com" rel="noopener noreferrer"&gt;Utilorax&lt;/a&gt;, a set of free browser-based tools. This came out of the &lt;a href="https://utilorax.com/calculators/cpm-calculator" rel="noopener noreferrer"&gt;CPM calculator&lt;/a&gt;, which now says "Impresiones por 50.00" to people reading it in Spanish.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
