<?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: Jianzhujisuan</title>
    <description>The latest articles on DEV Community by Jianzhujisuan (@jianzhujisuan).</description>
    <link>https://dev.to/jianzhujisuan</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%2F4215661%2Ffc715d8d-5ae7-4f51-ba83-4ce606f7d9f3.png</url>
      <title>DEV Community: Jianzhujisuan</title>
      <link>https://dev.to/jianzhujisuan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jianzhujisuan"/>
    <language>en</language>
    <item>
      <title>Googlebot ran my analytics script — my unique visitor count was 14x too high</title>
      <dc:creator>Jianzhujisuan</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:37:18 +0000</pubDate>
      <link>https://dev.to/jianzhujisuan/googlebot-ran-my-analytics-script-my-unique-visitor-count-was-14x-too-high-13am</link>
      <guid>https://dev.to/jianzhujisuan/googlebot-ran-my-analytics-script-my-unique-visitor-count-was-14x-too-high-13am</guid>
      <description>&lt;p&gt;For three weeks, my site's dashboard told a nice story. Traffic was growing. Unique visitors hit 87 on the best day, then 94, then a hundred-odd. New site, indexing starting to happen, numbers going up — exactly what you want to see.&lt;/p&gt;

&lt;p&gt;Then I looked at the actual requests and found &lt;strong&gt;six humans&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not six hundred. Six. And two of those six were probably me.&lt;/p&gt;

&lt;p&gt;This post is about where the other 81 came from, why the usual bot filters miss them, and the four checks that finally made the number honest. If you run a small site with a hand-rolled analytics endpoint, you have the same problem — you just don't know it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism: rendered pages run JavaScript
&lt;/h2&gt;

&lt;p&gt;I built the analytics myself. A tiny script posts to &lt;code&gt;/api/pv&lt;/code&gt; on page load; a Cloudflare Worker writes a row into D1 with path, referrer, UA, and IP. Fifty lines, no vendor, no cookie banner.&lt;/p&gt;

&lt;p&gt;That design assumes a visit is a person. It isn't.&lt;/p&gt;

&lt;p&gt;Googlebot fetches a page, renders it in headless Chromium, and executes the JavaScript — my beacon included. So &lt;strong&gt;every page Google crawls generates a "visit"&lt;/strong&gt;. On a 380-page site, one crawl pass is up to 380 hits. And because crawlers don't reuse a connection the way a browser session does, each batch can arrive from a different egress IP, so &lt;code&gt;COUNT(DISTINCT ip)&lt;/code&gt; treats them as hundreds of separate people.&lt;/p&gt;

&lt;p&gt;That's the whole illusion. Nothing was broken. My endpoint correctly recorded what happened. It just recorded the wrong thing as a visitor.&lt;/p&gt;

&lt;p&gt;Worth knowing the scale before you go hunting: on my site, Googlebot had crawled 372 of 380 pages, while bingbot had crawled 5. Both are "search engine traffic" in a naive dashboard. One of them is 70x the other, and neither is a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix attempt #1: filter on user agent (insufficient)
&lt;/h2&gt;

&lt;p&gt;The obvious move — classify at ingest time, store the result, and never reinterpret it later:&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;UA_BOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/bot|crawl|spider|slurp|headless|phantom|puppeteer|playwright|selenium|curl&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;|wget|python-|go-http-client|java&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;|okhttp|axios|node-fetch|scrapy|semrush|ahrefs|dataforseo|lighthouse/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classifyUA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&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;ua&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UA_BOT&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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bot&lt;/span&gt;&lt;span class="dl"&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="sr"&gt;/ipad|tablet|silk&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tb&lt;/span&gt;&lt;span class="dl"&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="sr"&gt;/android|iphone|mobile/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This caught the honest bots immediately — &lt;code&gt;Googlebot&lt;/code&gt;, &lt;code&gt;bingbot&lt;/code&gt;, &lt;code&gt;AhrefsBot&lt;/code&gt;. It also caught a few things I hadn't thought of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GoogleOther&lt;/code&gt;, &lt;code&gt;Google-InspectionTool&lt;/code&gt;, &lt;code&gt;APIs-Google&lt;/code&gt;, &lt;code&gt;FeedFetcher&lt;/code&gt; — Google's non-search fetchers. A regex containing only &lt;code&gt;bot&lt;/code&gt; misses all of them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BuiltWith&lt;/code&gt;, &lt;code&gt;DataForSEO&lt;/code&gt;, &lt;code&gt;Lighthouse&lt;/code&gt; — audit tools that behave exactly like a crawler.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HeadlessChrome&lt;/code&gt; — the giveaway inside otherwise-normal Chrome UAs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;mediapartners&lt;/code&gt; and &lt;code&gt;google-inspectiontool&lt;/code&gt; entries were the ones that actually moved my numbers. Add them even if you've never heard of them.&lt;/p&gt;

&lt;p&gt;But UA filtering alone fails on two cases, and both were present on my site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that fooled me three times: fake WeChat traffic
&lt;/h2&gt;

&lt;p&gt;I kept seeing a cluster of "WeChat" visitors. Mobile, from Tencent Cloud IP ranges, hundreds of hits. On a Chinese-language engineering site, WeChat referrals are the single most plausible traffic source, so I believed it — three separate times, over three weeks, before I stopped believing it.&lt;/p&gt;

&lt;p&gt;What finally exposed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mozilla/5.0 (Linux; Android 5.1; Nexus 5 Build/MRA58N) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/74.0.3729.131 Mobile Safari/537.36
MicroMessenger/7.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent tells:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MicroMessenger/7.0.1&lt;/code&gt;&lt;/strong&gt; — that's a 2018 client. Real WeChat traffic in 2026 reports &lt;code&gt;8.x&lt;/code&gt; and carries a build identifier like &lt;code&gt;(0x28002d3a)&lt;/code&gt;. A version with no &lt;code&gt;(0x&lt;/code&gt; segment is not the real client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Nexus 5 Build/MRA58N&lt;/code&gt;&lt;/strong&gt; — that is Puppeteer's default device descriptor, straight out of the library's device list. Real Android devices in the wild report their own build string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tencent Cloud IP ranges&lt;/strong&gt; — &lt;code&gt;81.71.&lt;/code&gt;, &lt;code&gt;183.60.&lt;/code&gt;, &lt;code&gt;42.194.&lt;/code&gt;, &lt;code&gt;14.152.&lt;/code&gt;, &lt;code&gt;43.248.&lt;/code&gt; are datacenter, not mobile carriers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A single tell is weak. Three together is conclusive. The lesson generalises: &lt;strong&gt;a UA string is a claim, not evidence.&lt;/strong&gt; Check whether the claim is internally consistent before you count it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second blind spot: crawlers wearing a normal Chrome UA
&lt;/h2&gt;

&lt;p&gt;The other failure mode is worse, because the UA looks perfect. I had a stream of &lt;code&gt;Macintosh; Intel Mac OS X&lt;/code&gt; Safari UAs from &lt;code&gt;17.166.*&lt;/code&gt; — roughly two requests per page, and 159 distinct IPs in a single day. Nothing in the UA string says "bot".&lt;/p&gt;

&lt;p&gt;The behaviour does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;exactly two requests per page&lt;/strong&gt; (HTML + one asset), never the full asset set&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;no referrer, ever&lt;/strong&gt;, and no session continuity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;159 IPs, all on the same cloud range&lt;/strong&gt;, all doing the same thing within hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zero interaction&lt;/strong&gt; — no search query, no second page view, no scroll-triggered event&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the profile of a distributed fetcher, not of people. Two requests per page means "I parsed your HTML", not "I read your page".&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable part: &lt;strong&gt;rendering completeness is not a reliable human signal any more.&lt;/strong&gt; Googlebot loads CSS, executes JS, and would happily pass a "did they load the assets" check. I used to treat "loaded everything" as proof of humanity. It isn't. It only rules out the lazy crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked: four layers, in this order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Classify at write time, store the verdict.&lt;/strong&gt; A &lt;code&gt;dev&lt;/code&gt; column (&lt;code&gt;bot&lt;/code&gt; / &lt;code&gt;tb&lt;/code&gt; / &lt;code&gt;mb&lt;/code&gt; / &lt;code&gt;pc&lt;/code&gt;) written by the Worker. Doing this at query time means every new bot name invalidates three months of history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named crawlers get IP verification, not just UA matching.&lt;/strong&gt; Google publishes its ranges; match &lt;code&gt;66.249.&lt;/code&gt;, &lt;code&gt;64.233.&lt;/code&gt;, &lt;code&gt;72.14.&lt;/code&gt;, &lt;code&gt;74.125.&lt;/code&gt;, &lt;code&gt;209.85.&lt;/code&gt; alongside the UA. This also removes the impostors: I had a &lt;code&gt;bingbot&lt;/code&gt; UA from &lt;code&gt;209.112.91.75&lt;/code&gt;, which is a vulnerability scanner, not Bing. Real Bing crawls from &lt;code&gt;40.77.167.&lt;/code&gt;, &lt;code&gt;52.167.144.&lt;/code&gt;, &lt;code&gt;207.46.13.&lt;/code&gt;, &lt;code&gt;157.55.39.&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency checks on the claim.&lt;/strong&gt; WeChat version vs build number vs IP ownership. Datacenter IP claiming to be a phone is a bot, no matter what the UA says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request-shape checks for the rest.&lt;/strong&gt; Assets loaded, referrer present, page-depth, inter-request timing. Any one alone is weak; three together are decisive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then every downstream number gets a single filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;humans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dev&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;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'bot'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'+8 hours'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'+8 hours'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;COALESCE(dev, '') &amp;lt;&amp;gt; 'bot'&lt;/code&gt; has to be in &lt;strong&gt;every&lt;/strong&gt; query — dashboard, admin panel, the JSON endpoint the frontend calls. I missed it in two places and got a "traffic surged" alert about my own crawler.&lt;/p&gt;

&lt;p&gt;One more trap in the same query: my timestamps are stored in UTC. Comparing "today so far" against "yesterday's full day" made the dashboard report &lt;strong&gt;-93%&lt;/strong&gt; on a completely normal morning. Six visits by 11:35 versus thirty-eight across the whole previous day isn't a decline, it's arithmetic. If your period comparison can produce a number that large, the bug is in the comparison, not the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the honest number looks like
&lt;/h2&gt;

&lt;p&gt;After filtering, on a 380-page site with 168 bilingual calculators:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best-day unique visitors&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traffic source&lt;/td&gt;
&lt;td&gt;"growing"&lt;/td&gt;
&lt;td&gt;1 real organic click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawl coverage (Googlebot)&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;td&gt;372 / 380 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawl coverage (bingbot)&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;td&gt;5 / 380 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one that changed my priorities. With bots stripped out, I could finally see that Google was indexing aggressively and Bing essentially wasn't crawling at all — a fact completely invisible in the inflated dashboard, where both were just "traffic".&lt;/p&gt;

&lt;p&gt;You can't diagnose a funnel you can't measure. My dashboard was measuring machine labour and calling it demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your analytics are hand-rolled, assume they're counting crawlers until you've proven otherwise. The cheap version of the proof:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log the UA and IP at write time — you can't retroactively classify what you didn't record.&lt;/li&gt;
&lt;li&gt;Treat every UA as a claim, then check it against IP ownership and request shape.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;&amp;lt;&amp;gt; 'bot'&lt;/code&gt; to every query, not just the pretty one.&lt;/li&gt;
&lt;li&gt;Compare like periods, in the same timezone, or your percentages will lie to you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site in question is &lt;a href="https://jianzhujisuan.com" rel="noopener noreferrer"&gt;jianzhujisuan.com&lt;/a&gt; — 168 construction-material calculators, 380 static pages, no framework. If you want to see what the crawling looks like from the other side, pick a page and view source; the result table is already in the HTML, which is why crawlers execute it and why my visitor count was fiction for three weeks.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>javascript</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Your sitemap's lastmod is lying — one CSS tweak marked all 380 pages as updated</title>
      <dc:creator>Jianzhujisuan</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:00:23 +0000</pubDate>
      <link>https://dev.to/jianzhujisuan/your-sitemaps-lastmod-is-lying-one-css-tweak-marked-all-380-pages-as-updated-4pdi</link>
      <guid>https://dev.to/jianzhujisuan/your-sitemaps-lastmod-is-lying-one-css-tweak-marked-all-380-pages-as-updated-4pdi</guid>
      <description>&lt;p&gt;Last winter I added one line to a stylesheet on my site — &lt;code&gt;letter-spacing: -0.01em&lt;/code&gt; on a heading — and redeployed. Nothing else changed. And yet, according to my sitemap, &lt;strong&gt;all 380 pages had been updated that day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;bingbot, being reasonable, went off to recrawl 380 pages. Almost none of them had changed.&lt;/p&gt;

&lt;p&gt;This is the quiet way a static site wastes its crawl budget: not through broken links or slow responses, but through a &lt;code&gt;lastmod&lt;/code&gt; that lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;lastmod&lt;/code&gt; matters more than people think
&lt;/h2&gt;

&lt;p&gt;Google has said for years that it mostly ignores &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; unless it can trust it. Bing is blunter about it: crawl scheduling uses change history per URL. If your sitemap claims everything changed today, every day, then either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the crawler recrawls a lot of unchanged pages, or&lt;/li&gt;
&lt;li&gt;it learns your &lt;code&gt;lastmod&lt;/code&gt; is noise and stops using it — so genuinely new pages wait behind nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either outcome is bad, and both are invisible in your analytics because nothing errors. You just notice, months later, that a batch of pages never got indexed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive fix, and why it backfires
&lt;/h2&gt;

&lt;p&gt;The obvious improvement over file mtimes is to hash the &lt;em&gt;rendered output&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lastmod_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly what I shipped first. It's correct in principle and useless in practice, because the bytes of a rendered page are not stable even when the content is.&lt;/p&gt;

&lt;p&gt;Two things move on every single deploy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Cache-busting query strings.&lt;/strong&gt; My asset references look like &lt;code&gt;/assets/style.css?v=3f9a1c&lt;/code&gt;. The build rewrites that hash whenever the stylesheet changes. So a one-line CSS edit changes the bytes of &lt;em&gt;every page that links the stylesheet&lt;/em&gt; — which is all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The analytics beacon.&lt;/strong&gt; A small script tag with a site token gets injected into &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Harmless, identical on every page — but if the injection order or whitespace ever shifts (a template refactor, a new minifier version, a different Python release writing attributes in a different order), the hash changes for the whole site at once.&lt;/p&gt;

&lt;p&gt;Neither is a content change. Both are pure output noise. A hash of the raw file can't tell the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash the meaning, not the bytes
&lt;/h2&gt;

&lt;p&gt;The fix is to normalise before hashing — strip the parts that are allowed to vary without the page meaning anything different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="n"&gt;NOISE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\?v=[0-9a-zA-Z]+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                 &lt;span class="c1"&gt;# cache-busting hashes
&lt;/span&gt;    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script[^&amp;gt;]*data-analytics[^&amp;gt;]*&amp;gt;.*?&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                              &lt;span class="c1"&gt;# whitespace
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pat&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NOISE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then keep a state file mapping path → fingerprint, and only touch &lt;code&gt;lastmod&lt;/code&gt; when the fingerprint moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_lastmod.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;built_pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;page&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lastmod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
        &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_lastmod.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; pages changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;lastmod&lt;/code&gt; values in the sitemap now come from that state file, not from the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that keeps it honest
&lt;/h2&gt;

&lt;p&gt;A fingerprint system is easy to write and easy to break silently. The assertion I run in CI is deliberately dumb:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Build twice in a row. The second build must report zero changed pages.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If it reports one page, something in the pipeline is non-deterministic. If it reports 380, the normaliser stopped working — usually because someone added a new injected block and didn't add it to &lt;code&gt;NOISE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That single assertion has caught three real regressions for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a minifier that started emitting a random class-name suffix&lt;/li&gt;
&lt;li&gt;an analytics snippet that included a build timestamp&lt;/li&gt;
&lt;li&gt;a Python upgrade that changed attribute order in the HTML writer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three would have shipped an "everything changed" sitemap, and none of them would have raised anything in a normal test suite, because the pages were &lt;em&gt;correct&lt;/em&gt;. They were just dishonest about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the state file lives
&lt;/h2&gt;

&lt;p&gt;One trap worth calling out: if &lt;code&gt;_lastmod.json&lt;/code&gt; lives only in CI and the cache is wiped, every page looks new again — the failure mode you were trying to fix.&lt;/p&gt;

&lt;p&gt;Two options that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commit it to the repo (small, boring, effective — 380 lines of JSON for my site)&lt;/li&gt;
&lt;li&gt;store it as a build artefact with a long retention and restore it before building&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does &lt;em&gt;not&lt;/em&gt; work: recomputing it from git history. Git tells you when a source file changed, not when a rendered page's output changed — and one shared layout file touches all 380 outputs.&lt;/p&gt;

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

&lt;p&gt;On my site, &lt;a href="https://jianzhujisuan.com" rel="noopener noreferrer"&gt;jianzhujisuan.com&lt;/a&gt; — 168 bilingual engineering calculators, 380 static pages — the effect was immediate and measurable in the logs rather than in rankings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deploys that change one article now mark &lt;strong&gt;1&lt;/strong&gt; page as updated instead of 380&lt;/li&gt;
&lt;li&gt;the second build in any CI run reports &lt;strong&gt;0&lt;/strong&gt; changed pages&lt;/li&gt;
&lt;li&gt;crawlers spend their budget on the pages that actually moved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No ranking jump. No dramatic story. Just a sitemap that stopped lying, which is what &lt;code&gt;lastmod&lt;/code&gt; was supposed to be all along.&lt;/p&gt;

&lt;p&gt;If you want to see the output side of this, open any tool page and view source — say &lt;a href="https://jianzhujisuan.com/en/brick-count" rel="noopener noreferrer"&gt;the brick count calculator&lt;/a&gt; — the result table is in the HTML, with a fingerprint attribute that's the same on every build until the formula changes.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your calculator returns nothing to crawlers. Here's how I fixed it with Playwright.</title>
      <dc:creator>Jianzhujisuan</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:19:41 +0000</pubDate>
      <link>https://dev.to/jianzhujisuan/your-calculator-returns-nothing-to-crawlers-heres-how-i-fixed-it-with-playwright-108o</link>
      <guid>https://dev.to/jianzhujisuan/your-calculator-returns-nothing-to-crawlers-heres-how-i-fixed-it-with-playwright-108o</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;If your site is "user types numbers → JavaScript computes a result", your HTML source probably looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"calc.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users see nothing wrong. &lt;strong&gt;Crawlers download 30KB of HTML containing no answer at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Googlebot does execute JavaScript, but only when it decides the page is worth rendering — and the render queue can lag days to weeks. &lt;strong&gt;bingbot and most other crawlers do not execute JavaScript at all.&lt;/strong&gt; They get the blank div, index it, and your page competes for "concrete volume calculator" with zero matching text.&lt;/p&gt;

&lt;p&gt;I hit this building &lt;a href="https://jianzhujisuan.com" rel="noopener noreferrer"&gt;jianzhujisuan.com&lt;/a&gt; — 168 construction calculators, 380 static pages, every result computed in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: don't solve it at runtime, solve it at build time
&lt;/h2&gt;

&lt;p&gt;One extra build step: open each page in a real browser with default inputs, wait for the JS to finish, capture &lt;code&gt;#result&lt;/code&gt;'s innerHTML, write it back into the same container in the HTML file.&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prerender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// local file, no server needed&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#result&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Note the (expr)() — see trap 1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)()`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;injectIntoContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// into the container, see trap 2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- prerender:start fp=a3f8c91b2e4d7a60 --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;table&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;Concrete volume&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;3.24 m³&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- prerender:end --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For visitors&lt;/strong&gt;: &lt;code&gt;calc.js&lt;/code&gt; loads and overwrites &lt;code&gt;#result&lt;/code&gt;'s innerHTML — the static copy disappears, nothing changes for them.&lt;br&gt;
&lt;strong&gt;For crawlers&lt;/strong&gt;: no JS execution, but the table is right there in the source.&lt;/p&gt;

&lt;p&gt;Single acceptance test — open the page with &lt;code&gt;javaScriptEnabled: false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;javaScriptEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#result&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// must contain real numbers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 1: &lt;code&gt;page.evaluate("() =&amp;gt; x")&lt;/code&gt; evaluates, doesn't call
&lt;/h2&gt;

&lt;p&gt;I used a "formula fingerprint" to decide whether a page needs re-rendering — hash the compute function's source, compare against a stored fingerprint, skip if unchanged.&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;// WRONG&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;() =&amp;gt; window.CALC.fp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fingerprint came back empty every time. When &lt;code&gt;page.evaluate&lt;/code&gt; receives a string, it &lt;strong&gt;evaluates the expression and returns the resulting function object&lt;/strong&gt; — it never calls it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callExpr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expr&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="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)()`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// becomes (() =&amp;gt; x)()&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;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;callExpr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;() =&amp;gt; window.CALC.fp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trap exists in &lt;code&gt;waitForFunction&lt;/code&gt;, and it's worse there: a string that evaluates truthy means &lt;strong&gt;you waited for nothing&lt;/strong&gt; and captured a half-rendered result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: injecting before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; shows users two copies
&lt;/h2&gt;

&lt;p&gt;My first version inserted the block before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;. Locally, with JS enabled, users saw &lt;strong&gt;two result tables&lt;/strong&gt; — the static one sat outside &lt;code&gt;#result&lt;/code&gt;, so the live innerHTML overwrite never removed it.&lt;/p&gt;

&lt;p&gt;You have to splice into the container's &lt;em&gt;interior&lt;/em&gt;, matching tag depth:&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;spliceInner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;div[^&amp;gt;]*id=["']&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;["'][^&amp;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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;index&lt;/span&gt; &lt;span class="o"&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;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;depth&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagRe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;div&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;|&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;div&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;tagRe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tagRe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;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="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&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="kc"&gt;null&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;Now the static block lives inside &lt;code&gt;#result&lt;/code&gt; and gets replaced along with everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: re-running stacks blocks
&lt;/h2&gt;

&lt;p&gt;A build step must be idempotent. Inserting before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; gives you three copies after three runs. Wrap the block in markers and replace on re-run:&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;BLOCK_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;!--&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*prerender:start&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*--&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;!--&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*prerender:end&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BLOCK_RE&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;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;before&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="nx"&gt;BLOCK_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second run should report &lt;code&gt;0 rendered, 1 unchanged&lt;/code&gt;. &lt;strong&gt;If every run reports "all rendered", your fingerprint is empty — go back to trap 1.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: lastmod ordering
&lt;/h2&gt;

&lt;p&gt;If your sitemap carries &lt;code&gt;lastmod&lt;/code&gt;, &lt;strong&gt;the prerender injection must run before lastmod is computed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Otherwise adding prerender marks all 380 pages as "modified today". Run it again the next day — all 380 pages modified again. From the crawler's perspective your site claims a full-site rewrite every single day, which destroys your sitemap's credibility.&lt;/p&gt;

&lt;p&gt;If you must run it daily, strip the injected block from the content fingerprint — the block changed, the page's substance didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;380 pages, on a single reused browser instance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build time: noticeably longer, but it's a one-off cost with a reused single browser instance — never in the user request path&lt;/li&gt;
&lt;li&gt;Every page returns its full result table with JS disabled&lt;/li&gt;
&lt;li&gt;Median real body content: 1,655 CJK characters (was ~400 characters of template skeleton)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What prerendering does &lt;em&gt;not&lt;/em&gt; fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It's worth being blunt here.&lt;/strong&gt; Prerendering fixes "can the crawler see the content". It does not fix "does the search engine want to index you".&lt;/p&gt;

&lt;p&gt;I assumed fixing the blank-div problem would fix indexing. It didn't. Two weeks later, bingbot had fetched 2 pages out of 380 and indexed zero. The reason had nothing to do with my HTML — it was a new domain with zero inbound links, and no crawler algorithm allocates crawl budget for a site nobody mentions.&lt;/p&gt;

&lt;p&gt;Content visibility and site trust are two separate problems. Prerender solves the first one properly. The second one is a marketing problem, and you solve it by writing things people link to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and verification
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The code&lt;/strong&gt;: &lt;a href="https://gitee.com/the-art-of-strategic-planning/calc-prerender" rel="noopener noreferrer"&gt;calc-prerender&lt;/a&gt; — MIT licensed, ~180 lines of Node, a runnable example, and the three traps written up in the README. It is not a demo: this is the script that runs in the production build.&lt;/p&gt;

&lt;p&gt;Production case: &lt;a href="https://jianzhujisuan.com" rel="noopener noreferrer"&gt;jianzhujisuan.com&lt;/a&gt; — 168 bilingual construction calculators, 380 pages, all built this way. The whole implementation (~180 lines) is what runs in that build pipeline.&lt;/p&gt;

&lt;p&gt;Quick verification: open a page like &lt;a href="https://jianzhujisuan.com/en/brick-count" rel="noopener noreferrer"&gt;brick count calculator&lt;/a&gt;, disable JavaScript, refresh. If the result table is still there, it worked. If you see an empty div, prerendering never ran.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>seo</category>
      <category>playwright</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
