<?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: 孙永瑞</title>
    <description>The latest articles on DEV Community by 孙永瑞 (@toolkitcreators).</description>
    <link>https://dev.to/toolkitcreators</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%2F4056853%2F127a15a3-74bd-4d2d-af7d-18bfb566fed2.png</url>
      <title>DEV Community: 孙永瑞</title>
      <link>https://dev.to/toolkitcreators</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toolkitcreators"/>
    <language>en</language>
    <item>
      <title>I shipped llms.txt and unblocked every AI crawler. Citation count: still zero.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:36:42 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/i-shipped-llmstxt-and-unblocked-every-ai-crawler-citation-count-still-zero-3e2c</link>
      <guid>https://dev.to/toolkitcreators/i-shipped-llmstxt-and-unblocked-every-ai-crawler-citation-count-still-zero-3e2c</guid>
      <description>&lt;p&gt;I run five niche content sites. Two months ago I set out to get them cited by AI answer engines, and I did what the current advice says to do.&lt;/p&gt;

&lt;p&gt;I shipped &lt;code&gt;llms.txt&lt;/code&gt; on all five. It went from 404 to 200, 19–22.6 KB each, written in the same honest voice as the rest of the site — including the part where we state we don't run hands-on lab tests.&lt;/p&gt;

&lt;p&gt;I unblocked every AI training and search crawler I could name — GPTBot, ClaudeBot, CCBot, OAI-SearchBot, PerplexityBot — and verified all five homepages returned 200 for each, then re-verified 12 hours later to confirm the config actually stuck.&lt;/p&gt;

&lt;p&gt;I unified author attribution: real bylines, real LinkedIn, zero remaining "Editorial Team" placeholders.&lt;/p&gt;

&lt;p&gt;Twelve hours later I measured citations again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero. On all five sites, across all five probe questions.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  First job: prove the zero is real
&lt;/h2&gt;

&lt;p&gt;A zero is worthless until you've ruled out a broken measurement. This is the part most people skip, and I nearly got it wrong.&lt;/p&gt;

&lt;p&gt;My check was source count. The probe returns a set of sources per query, and those counts came back 20, 21, 23, 18, 22 — matching my baseline run one for one. Same numbers means the same retrieval is happening. The probe was working; the answer was genuinely empty.&lt;/p&gt;

&lt;p&gt;The second check was stickiness. Over 12 hours, the head of the source pool didn't change at all. That told me something useful: this isn't a volatile set that might roll my way tomorrow. It's a high-retention pool, which means &lt;strong&gt;30 days is the minimum meaningful re-measurement window.&lt;/strong&gt; Measuring daily is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false zero I almost published
&lt;/h2&gt;

&lt;p&gt;There was a trap sitting right next to the real one. Brave rate-limits repeated queries. When it happens, source count comes back &lt;strong&gt;0&lt;/strong&gt; and navigation times out — which renders as "no citations found."&lt;/p&gt;

&lt;p&gt;That is not the same thing as zero citations. It's an empty response wearing the same clothes. Same failure shape as the Cloudflare interstitial trap I'd already hit on Perplexity.&lt;/p&gt;

&lt;p&gt;The guardrails I added, all cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat a sample as valid only if &lt;strong&gt;source count ≥ 5&lt;/strong&gt;. Below that, it's a failure, not a finding.&lt;/li&gt;
&lt;li&gt;Retry invalid samples up to 3 times.&lt;/li&gt;
&lt;li&gt;Space queries &lt;strong&gt;≥ 25 seconds&lt;/strong&gt; apart; &lt;strong&gt;≥ 150 seconds&lt;/strong&gt; cooldown after a confirmed rate-limit.&lt;/li&gt;
&lt;li&gt;Record invalid samples explicitly instead of silently writing 0.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that, I would have written "AI search doesn't cite us" from a rate limit. With it, I can say the zero is real and know why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's actually zero
&lt;/h2&gt;

&lt;p&gt;The tempting explanation is content shape — &lt;em&gt;our pages just aren't the kind that get quoted&lt;/em&gt;. I went looking for evidence and didn't find it.&lt;/p&gt;

&lt;p&gt;What I found in the citation pool was a lot of structurally identical small vertical sites. Project management tools, AI-for-education tools especially: the same comparison formats, the same depth, the same "vs" pages we publish. Those sites get cited. Ours don't.&lt;/p&gt;

&lt;p&gt;So format isn't the differentiator. The remaining variable is &lt;strong&gt;domain age and accumulated authority&lt;/strong&gt; — the one thing you cannot ship in a sprint.&lt;/p&gt;

&lt;p&gt;Traditional search agrees. Across the same sites, Search Console reports an &lt;strong&gt;average position of 73 and a CTR of 0.011%&lt;/strong&gt;. That's not a content-quality signal. That's a two-month-old domain behaving exactly like a two-month-old domain.&lt;/p&gt;

&lt;p&gt;My working expectation now: first real citation somewhere in the &lt;strong&gt;6–12 month&lt;/strong&gt; range. We're two months in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conclusion I didn't want
&lt;/h2&gt;

&lt;p&gt;Shipping &lt;code&gt;llms.txt&lt;/code&gt; and unblocking crawlers changed nothing measurable. Those two actions moved real numbers — 404 to 200, blocked to allowed — and produced zero movement in the metric I actually care about.&lt;/p&gt;

&lt;p&gt;So I downgraded them. They're no longer growth levers; they're &lt;strong&gt;completed compliance items&lt;/strong&gt;. Necessary, cheap, done, and filed. What I will not do is keep re-measuring them and calling it progress.&lt;/p&gt;

&lt;p&gt;The separation I now apply to every task on these sites:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compliance work&lt;/strong&gt; — correct, cheap, one-time. Does not move traffic or citations. Stop measuring it after it's done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growth work&lt;/strong&gt; — expensive, slow, uncertain. This is where the 6–12 month bets live.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mixing them is how you spend a month feeling productive. "Shipped llms.txt" is a real checkbox, and it is worth exactly zero citations.&lt;/p&gt;

&lt;p&gt;The uncomfortable but useful part: the zero was honest data. Knowing why it's zero — and knowing it wasn't a rate limit — is worth more than a lucky citation I couldn't explain.&lt;/p&gt;




&lt;p&gt;If you want a second pair of eyes on your site's AI-search visibility, structured data, or the measurements behind them: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>My honesty linter flagged my own honesty disclaimer</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:31:33 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/my-honesty-linter-flagged-my-own-honesty-disclaimer-4gco</link>
      <guid>https://dev.to/toolkitcreators/my-honesty-linter-flagged-my-own-honesty-disclaimer-4gco</guid>
      <description>&lt;p&gt;I run five small software comparison sites. I have not installed most of the tools I write about. That gap is the whole problem: the easy way to sound authoritative is to imply I did things I didn't do, and that is exactly the kind of sentence that gets a site rejected for ads, or quietly distrusted by readers.&lt;/p&gt;

&lt;p&gt;So instead of promising myself I'd be careful, I wrote a checker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;site_verify.py&lt;/code&gt; scans every page before deploy and fails the build on phrases that assert first-hand experience. Not "we think" — "we tested," "hands-on," "in our testing," "we measured." If the site can't back the claim up, the phrase doesn't ship.&lt;/p&gt;

&lt;p&gt;It isn't clever. It's a list of patterns and a hard exit code. After enough rounds it's at version 26, and this week it finally went green across all five sites — 59 of 59 checks.&lt;/p&gt;

&lt;p&gt;The same discipline applies to the bylines. An earlier pass rewrote 302 author bios and 302 role lines that had been describing a persona with hands-on experience of the products. That number is what convinced me the manual approach had already failed: I cannot hand-audit 604 fields, and I definitely cannot hand-audit them every time I publish. A script can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The failure wasn't a page. It was my own disclaimer.&lt;/p&gt;

&lt;p&gt;One of the sites carries a line explaining the limits of the content: &lt;em&gt;"We do not run hands-on lab tests."&lt;/em&gt; The checker's fallback rule was a bare regex on &lt;code&gt;hands-on&lt;/code&gt;. It matched. My honesty checker was flagging my honesty disclaimer as a dishonest claim.&lt;/p&gt;

&lt;p&gt;The same round produced two more: &lt;em&gt;"We ran no benchmarks."&lt;/em&gt; and a "we measured no ..." line, on two different sites. Same cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fix isn't "add an exception"
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to allowlist those exact sentences. That works today and rots next month — I write "we ran no benchmarks on the enterprise tier" and it sails through, or I rephrase slightly and it fails again for no reason.&lt;/p&gt;

&lt;p&gt;What I actually changed: the matcher now looks at &lt;strong&gt;negation context&lt;/strong&gt;. A phrase only passes if it's explicitly negated — "do not," "no," "never" — within the same clause. The allowlist strips negated forms; it never opens the door to affirmative ones. Claiming I tested something is still a hard fail. Saying I didn't is now fine.&lt;/p&gt;

&lt;p&gt;That distinction is the entire point. The goal was never to remove the word "testing." It was to remove the &lt;em&gt;impression&lt;/em&gt; of experience I don't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sentence that got me anyway
&lt;/h2&gt;

&lt;p&gt;A few hours later, reading a page I had already shipped, I hit this subheading:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How we built and checked these prompts&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Checked." It's on no pattern list. It passes every rule I wrote. And it reads exactly like someone opened the tools and verified the output.&lt;/p&gt;

&lt;p&gt;I changed it to "How this prompt set is put together." Same information, no borrowed credibility.&lt;/p&gt;

&lt;p&gt;That one is the honest lesson, and it's the part I can't automate: &lt;strong&gt;a linter catches patterns, not impressions.&lt;/strong&gt; Every rule I have encodes a phrasing I already thought of. The one that slips through is always the verb I didn't consider — "checked," "verified," "tried," "ran it ourselves."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Two passes, every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the checker. It's cheap, it's deterministic, and it catches the obvious 95%.&lt;/li&gt;
&lt;li&gt;Read the page once, out loud, asking one question only: &lt;em&gt;what would a reader believe I did, based on this sentence?&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 caught the subheading. Step 1 never would have.&lt;/p&gt;

&lt;p&gt;If you're running content sites where you don't have first-hand experience of the product, this is worth an hour of your time. The checker isn't the valuable part — anyone can grep for "we tested." The valuable part is the habit of asking what your sentence implies, independent of what it literally says.&lt;/p&gt;




&lt;p&gt;If you want a second pair of eyes on your site's claims or your pre-deploy checks: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>automation</category>
      <category>testing</category>
    </item>
    <item>
      <title>I fixed 4 broken JSON-LD blocks at 3pm. Google found 32 more at 8pm.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Sun, 27 Sep 2026 15:59:05 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/i-fixed-4-broken-json-ld-blocks-at-3pm-google-found-32-more-at-8pm-1e8j</link>
      <guid>https://dev.to/toolkitcreators/i-fixed-4-broken-json-ld-blocks-at-3pm-google-found-32-more-at-8pm-1e8j</guid>
      <description>&lt;p&gt;At 15:40 I finished patching four pages where an affiliate link had been injected &lt;em&gt;inside&lt;/em&gt; a JSON-LD block. I rewrote the safety check, re-ran the verifier, got zero failures, deployed. Felt done.&lt;/p&gt;

&lt;p&gt;At 20:44 an email from Google Search Console arrived:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unparsable structured data. Parsing error: Missing ',' or '}'&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a different site. Different pages. Same root cause.&lt;/p&gt;

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

&lt;p&gt;Months earlier I wrote a script that inserts internal links into article bodies. It was a naive string operation: find anchor text, wrap it in an &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;href="..."&lt;/code&gt; contains double quotes. JSON-LD lives inside a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block, and a JSON string cannot contain a raw double quote. Every FAQ answer that got a link injected became unparsable JSON. The whole block — including the valid FAQPage markup around it — was dead.&lt;/p&gt;

&lt;p&gt;GSC reported one affected page. A full scan found &lt;strong&gt;32 broken blocks across three sites&lt;/strong&gt;: 27 on one, 3 on another, 2 on a third. Two sites were clean, purely because nobody had run the link-injection script on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bothers me
&lt;/h2&gt;

&lt;p&gt;My own checker had reported zero failures on all three sites that same week. It was green the entire time this was broken.&lt;/p&gt;

&lt;p&gt;The reason is simple and embarrassing: my verifier validated the things I was worried about. Titles, canonicals, link attributes, image alt text, ads.txt. It never parsed the JSON-LD. I had written a checker for the failure modes I had already imagined, and this was not one of them.&lt;/p&gt;

&lt;p&gt;Google submitted to the same pages my checker passed and got a different answer, because Google actually parses structured data and my script didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs, one class, one day
&lt;/h2&gt;

&lt;p&gt;The afternoon fix and the evening discovery are the same bug with different entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;15:40&lt;/strong&gt; — affiliate link injection wrote an anchor into a JSON-LD block. My "safe spot" heuristic checked the first 300 characters of the file and missed script blocks that opened earlier than that. Four pages hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20:44&lt;/strong&gt; — internal link injection had done the same thing months earlier and I never noticed, because nothing was watching. Thirty-two blocks hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I patched symptoms at 15:40. The class of bug wasn't addressed until 20:44, and only because an external system told me.&lt;/p&gt;

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

&lt;p&gt;Three rules, all cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Any script that writes into HTML must first map the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; regions and treat them as off-limits.&lt;/strong&gt; Not "check the first N characters" — build the actual exclusion ranges. My 300-character window was a guess dressed up as a check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The verifier now parses every &lt;code&gt;ld+json&lt;/code&gt; block with a real JSON parser&lt;/strong&gt; and fails the build if any one is unparsable. Not regex. &lt;code&gt;json.loads&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When injecting into a JSON string, escape or strip.&lt;/strong&gt; I chose stripping: remove the &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag, keep the anchor text. An FAQ answer reads fine without a hyperlink, and structured data validity is worth more than one internal link.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;A local checker only tells you about the questions you asked it. Every green run I had was real — and every one of them was silent about structured data, because I had never asked.&lt;/p&gt;

&lt;p&gt;The fix isn't a better checker. It's treating external systems as the second opinion they are. GSC, browser consoles, and third-party validators cost nothing and catch the category you forgot existed. In my case the gap between "my tool says fine" and "Google says broken" was five hours and 28 pages.&lt;/p&gt;




&lt;p&gt;If you want a second pair of eyes on your structured data or your site's search setup: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>json</category>
      <category>automation</category>
    </item>
    <item>
      <title>Every indexing request failed today. The bug was my timezone.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Sat, 26 Sep 2026 15:31:24 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/every-indexing-request-failed-today-the-bug-was-my-timezone-4pod</link>
      <guid>https://dev.to/toolkitcreators/every-indexing-request-failed-today-the-bug-was-my-timezone-4pod</guid>
      <description>&lt;p&gt;My daily job was simple: open Google Search Console, paste in a URL, click &lt;strong&gt;Request Indexing&lt;/strong&gt;, repeat for ten URLs, done. It ran every morning at 10:07 Beijing time. It had been working.&lt;/p&gt;

&lt;p&gt;Then one morning every single request came back with the same popup:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sorry! We are unable to process this request because you have exceeded your daily quota. Please try again tomorrow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Zero accepted. Zero skipped as already indexed. Sixty-two URLs still sitting in the queue, untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  First assumption: the script broke
&lt;/h2&gt;

&lt;p&gt;I wrote the automation myself — a headless browser that fills the inspect field, waits 24 seconds for Google's check to finish, clicks the request button, waits another 5 seconds, and verifies the result. My first instinct was that something in that flow had rotted.&lt;/p&gt;

&lt;p&gt;It hadn't. The script did exactly what it was told. It typed the URL. It clicked. Google answered. The answer was just "no."&lt;/p&gt;

&lt;p&gt;Nothing in my verification logic was wrong. I was checking for the wrong failure mode — I had accounted for "already indexed" and "not found," but never for "quota." So every attempt looked like a clean run with no URL removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cause
&lt;/h2&gt;

&lt;p&gt;Search Console's indexing quota resets per &lt;strong&gt;US Pacific natural day&lt;/strong&gt;. That's a hard cutover at 15:00 Beijing time (during PDT).&lt;/p&gt;

&lt;p&gt;Here's what my two runs looked like after conversion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous batch, submitted at &lt;strong&gt;22:11 Beijing on Sept 22&lt;/strong&gt; → &lt;strong&gt;07:11 PT, Sept 22&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;That morning's batch, at &lt;strong&gt;10:07 Beijing on Sept 23&lt;/strong&gt; → &lt;strong&gt;19:07 PT, Sept 22&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same Pacific day. I had already spent that day's quota twelve hours earlier, from my own point of view "yesterday."&lt;/p&gt;

&lt;p&gt;Being nineteen hours apart in local time means nothing to Google. It counts calendar days in one fixed timezone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why my schedule made it inevitable
&lt;/h2&gt;

&lt;p&gt;A single daily job at a fixed Beijing hour is safe &lt;em&gt;on its own&lt;/em&gt;. 10:00 Beijing today and 10:00 Beijing tomorrow land on two different Pacific days. No collision.&lt;/p&gt;

&lt;p&gt;What broke it was a manual extra run. I'd kicked off an additional batch at 22:00 the night before, which landed early in Pacific day Sept 22. The next morning's automated run then landed late in that same Pacific day — after the quota was gone.&lt;/p&gt;

&lt;p&gt;So: not a script bug, and not really a timezone bug either. A &lt;strong&gt;scheduling&lt;/strong&gt; bug, exposed by mixing manual runs with automation.&lt;/p&gt;

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

&lt;p&gt;Three fixes, all cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Moved the job to 16:00 Beijing&lt;/strong&gt; — one hour after the Pacific reset, so the quota is always full when it fires. Shift the run to after the reset rather than hoping the previous day left something behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taught the verifier to recognize the quota popup&lt;/strong&gt; and stop immediately. Burning five attempts into a dead quota teaches you nothing and risks looking like abuse. Now it reports "blocked by quota," keeps every URL in the queue, and retries next cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Added a rule for anything touching "tomorrow"&lt;/strong&gt;: convert to Pacific first. If a service resets daily and doesn't tell you in which timezone, find out before you build a cron around it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last one generalizes furthest. Rate limits, daily quotas, usage caps, "resets at midnight" — if it's a daily window, it belongs to &lt;em&gt;someone's&lt;/em&gt; midnight, and it's usually not yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong
&lt;/h2&gt;

&lt;p&gt;I spent my first twenty minutes reading my own code, convinced there was a bug. The script was fine. The honest lesson: when an automated task fails after previously working, &lt;strong&gt;check the external system's state before you audit your own logic.&lt;/strong&gt; Confirm the limit, the timeline, and the timezone first. Often the bug isn't in your code at all — it's in the calendar.&lt;/p&gt;




&lt;p&gt;If you want a second pair of eyes on your site or your indexing setup: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>automation</category>
      <category>beginners</category>
    </item>
    <item>
      <title>There's no API" doesn't mean "can't be automated</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:54:12 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/theres-no-api-doesnt-mean-cant-be-automated-3llo</link>
      <guid>https://dev.to/toolkitcreators/theres-no-api-doesnt-mean-cant-be-automated-3llo</guid>
      <description>&lt;p&gt;For weeks I told myself Google Search Console's "Request indexing" had to be done by hand. The reasoning felt solid: there's no API for it, therefore a human must click it.&lt;/p&gt;

&lt;p&gt;That conclusion was wrong, and it cost me days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction I was missing
&lt;/h2&gt;

&lt;p&gt;No API means no &lt;em&gt;programmatic&lt;/em&gt; interface. It doesn't mean no &lt;em&gt;browser&lt;/em&gt; interface. If a human can do it by clicking, a script driving a browser can usually do it too.&lt;/p&gt;

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

&lt;p&gt;One afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the URL inspection tool&lt;/li&gt;
&lt;li&gt;Find the input field (locate it by its aria-label, which is more stable than any selector)&lt;/li&gt;
&lt;li&gt;Set the value using the native property setter, then dispatch an input event — Angular ignores direct assignments&lt;/li&gt;
&lt;li&gt;Dispatch Enter&lt;/li&gt;
&lt;li&gt;Wait for the check to finish&lt;/li&gt;
&lt;li&gt;Click "Request indexing"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roughly 26 seconds per URL. Batched two at a time to avoid timeouts. Ten priority pages pushed the first day, and a queue handles the rest daily.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that stung
&lt;/h2&gt;

&lt;p&gt;I'd been sitting on 72 undiscovered pages while telling myself the bottleneck was unavoidable. The bottleneck was my own assumption.&lt;/p&gt;

&lt;p&gt;I'd even said it out loud more than once: "this one has to be manual." Nobody challenged it, including me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;When you catch yourself saying "X has to be manual," check which claim you're actually making — "no API" or "no interface." They're different, and only the first one is usually true.&lt;/p&gt;




&lt;p&gt;Site builds and diagnostics if you want help automating this kind of thing: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>HRIS Migrations Don't Fail on the API. They Fail on the Data Model.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:47:31 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/hris-migrations-dont-fail-on-the-api-they-fail-on-the-data-model-2pn8</link>
      <guid>https://dev.to/toolkitcreators/hris-migrations-dont-fail-on-the-api-they-fail-on-the-data-model-2pn8</guid>
      <description>&lt;p&gt;Every HRIS implementation plan has a data migration phase that reads like a file transfer: export from the old system, map the columns, import into the new one, reconcile the headcount, go live. The integration work gets estimated in story points. The data model gets a spreadsheet.&lt;/p&gt;

&lt;p&gt;That ordering is backwards, and it is why go-lives slip by a quarter. The endpoints are rarely the hard part — rate limits, pagination and webhook retries are solved problems with published patterns. What breaks migrations is the quiet assumption that an employee is a row with columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  HR data is temporal, and most schemas are not
&lt;/h2&gt;

&lt;p&gt;In a typical application table, a row holds current state. In an HR system, nearly every meaningful field is effective-dated: compensation, job title, manager, department, location, cost center, FTE percentage. Each carries a validity window, and the history is the point. You cannot answer "what was this person's salary in March" or "who approved that backdated promotion" from current state.&lt;/p&gt;

&lt;p&gt;Load current-state columns and you get a system that is accurate on day one and wrong forever after. Retroactive pay, accrual recalculation and any audit request all fail against it. The load has to target effective-dated records, which usually means one source row fans out into several target rows that must be inserted in chronological order — and most vendor APIs reject an out-of-sequence effective date outright.&lt;/p&gt;

&lt;p&gt;That single constraint, ordering, is what breaks most first-pass migration scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  One human is not one record
&lt;/h2&gt;

&lt;p&gt;The second mismatch is identity. In the source system a person is usually one row. In the target, the same human may legitimately be several: an employee record, a separate contractor record, a rehire with a new hire date, or a worker holding two concurrent assignments in different legal entities.&lt;/p&gt;

&lt;p&gt;The join keys are worse than they look. Email addresses change. Some vendors reuse employee IDs after termination. National identifiers are jurisdiction-specific and frequently absent for non-payrolled workers entirely.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating any vendor-supplied field as a primary key. Generate a durable surrogate key you control, persist it on both sides, and join on that. It is one extra column, and it is the difference between a clean load and re-importing everyone because somebody got married.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stored values versus derived values
&lt;/h2&gt;

&lt;p&gt;This is the one that produces silent corruption instead of loud failures.&lt;/p&gt;

&lt;p&gt;PTO balances, tenure, accrued liability, FTE-normalized headcount, compa-ratio — these are computed from events, not stored facts. Import a balance snapshot and the new system holds a number with no ledger underneath it. The first time someone books a day off, the balance moves from a starting point nobody can explain, and HR and payroll stop agreeing.&lt;/p&gt;

&lt;p&gt;Import the events instead: hires, terminations, accrual rules, grants, leave taken. Let the target compute the balances. Then compare the computed balance against what the old system reported — any gap is a genuine configuration difference, and it is far better to find it during migration than during the first payroll run.&lt;/p&gt;

&lt;h2&gt;
  
  
  "The counts match" is not reconciliation
&lt;/h2&gt;

&lt;p&gt;A matching headcount is the most common false green light in a migration. Row counts say nothing about whether the values are correct.&lt;/p&gt;

&lt;p&gt;Reconcile on aggregates that a finance or HR stakeholder can independently verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;active headcount by department and by legal entity&lt;/li&gt;
&lt;li&gt;year-to-date gross pay by legal entity&lt;/li&gt;
&lt;li&gt;total PTO liability, in hours and in currency&lt;/li&gt;
&lt;li&gt;workers with no manager assigned&lt;/li&gt;
&lt;li&gt;workers with a null cost center&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two matter more than they look. Nulls in structural fields are what actually break downstream reporting, and they are invisible in a row count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the load idempotent before you make it fast
&lt;/h2&gt;

&lt;p&gt;Migration runs fail partway. A vendor gateway times out at record 600. Someone re-runs the job. If the load is not idempotent, you now have 600 duplicated employees and a cleanup project considerably nastier than the migration itself.&lt;/p&gt;

&lt;p&gt;Every upsert should carry an external ID you own, and the job should be safe to run twice against the same dataset. Test that explicitly: run a full load into a sandbox, run it again, assert the record count is unchanged. It is a short test that prevents the most common migration disaster there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  HR data is the most sensitive data you will touch
&lt;/h2&gt;

&lt;p&gt;Salaries, health information, leave reasons, disciplinary records, immigration status, bank details. Handle it accordingly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no production HR data in staging or local environments&lt;/li&gt;
&lt;li&gt;no employee records in logs, error messages or support tickets — log the surrogate key, not the payload&lt;/li&gt;
&lt;li&gt;redaction before anything reaches third-party observability tooling&lt;/li&gt;
&lt;li&gt;a retention and deletion path defined before you load, not after someone files a request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a script dumps a full employee payload to stdout on failure, that payload is now sitting in CI logs with a longer retention period than the HR system has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order worth running it in
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Freeze the source data, or snapshot it with an explicit as-of date.&lt;/li&gt;
&lt;li&gt;Model the target's effective-dated fields first, before writing any load code.&lt;/li&gt;
&lt;li&gt;Generate and persist your own surrogate keys.&lt;/li&gt;
&lt;li&gt;Build an idempotent load, and prove it by running it twice.&lt;/li&gt;
&lt;li&gt;Reconcile on finance-verifiable aggregates, not row counts.&lt;/li&gt;
&lt;li&gt;Run a parallel or dual-write window before the hard cutover.&lt;/li&gt;
&lt;li&gt;Only then automate webhooks and ongoing sync.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 through 6 are data engineering. Step 7 is the part every team staffs for.&lt;/p&gt;

&lt;p&gt;The API is not where HRIS migrations go wrong. The data model is: temporal records, ambiguous identity, and derived values that should never have been imported in the first place. Get those three right and the integration really is the easy part.&lt;/p&gt;

&lt;p&gt;If you are scoping the other half of this — vendor selection, change management, training and the go-live sequence — the full walkthrough lives here: &lt;a href="https://hrcompared.com/guides/hr-software-implementation-guide" rel="noopener noreferrer"&gt;HR software implementation guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>database</category>
      <category>integrations</category>
      <category>sql</category>
    </item>
    <item>
      <title>60% of my keyword list wasn't real. Here's the filter that fixed it.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:42:58 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/60-of-my-keyword-list-wasnt-real-heres-the-filter-that-fixed-it-41cd</link>
      <guid>https://dev.to/toolkitcreators/60-of-my-keyword-list-wasnt-real-heres-the-filter-that-fixed-it-41cd</guid>
      <description>&lt;p&gt;I exported 4,122 queries from Search Console across five sites and nearly built a content plan around all of them.&lt;/p&gt;

&lt;p&gt;Then I looked at the impression counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The noise
&lt;/h2&gt;

&lt;p&gt;On two of the sites, &lt;strong&gt;57–60% of queries had two impressions or fewer&lt;/strong&gt; over three months. Hundreds of them showed exactly one.&lt;/p&gt;

&lt;p&gt;Those aren't keywords. Nobody is searching for them in any meaningful volume — they're artifacts of Google showing a page once or twice, or long-tail phrasings that appeared a single time.&lt;/p&gt;

&lt;p&gt;Treating them as keywords is worse than useless: it makes you write content for demand that doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter I use now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three or more impressions before a query counts.&lt;/strong&gt; Two or fewer, it goes in a separate bucket labelled "noise" and gets ignored for planning purposes.&lt;/p&gt;

&lt;p&gt;That one number cut my working list by more than half, and what remained was actually worth writing for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second filter
&lt;/h2&gt;

&lt;p&gt;Of what's left, I only act on queries ranking &lt;strong&gt;21 to 60&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Below 21: already competitive, needs links and authority, not another article&lt;/li&gt;
&lt;li&gt;Above 60: too far to close with on-page work alone&lt;/li&gt;
&lt;li&gt;21–60: close enough that internal links and page improvements can actually move it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined, those two filters turned 4,122 queries into a few hundred actionable ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the raw export misleads you
&lt;/h2&gt;

&lt;p&gt;Search Console is a log of what happened, not a list of what people want. It will faithfully record every single-impression phrase your pages matched, and present it to you in the same table as a term with 2,925 impressions. The spreadsheet doesn't rank them by importance — you have to.&lt;/p&gt;




&lt;p&gt;Free diagnosis if you want help reading your own export: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>beginners</category>
      <category>data</category>
    </item>
    <item>
      <title>The HTML used a CSS class that was never defined. Three of them, actually.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:24:57 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/the-html-used-a-css-class-that-was-never-defined-three-of-them-actually-4cdp</link>
      <guid>https://dev.to/toolkitcreators/the-html-used-a-css-class-that-was-never-defined-three-of-them-actually-4cdp</guid>
      <description>&lt;p&gt;Three separate visual bugs on my sites had the same cause: &lt;strong&gt;the HTML referenced a CSS class that didn't exist in any stylesheet.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No build error. No console warning. The markup was completely valid. The class just did nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;.hero-visual&lt;/code&gt;&lt;/strong&gt; — used on the homepage to center and constrain the hero image. Never defined. The image rendered at full width, uncentered, with no rounded corners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;.card-thumb&lt;/code&gt;&lt;/strong&gt; — used on card thumbnails. Also never defined. One card had a portrait image (798×942) next to landscape ones (1192×941), so it stretched taller and the whole row looked broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Styles written &lt;em&gt;after&lt;/em&gt; the closing &lt;code&gt;&amp;lt;/style&amp;gt;&lt;/code&gt; tag.&lt;/strong&gt; During a redesign, about 460 characters of CSS got appended past the closing tag — including the rule that centered the hero image. The browser rendered it as body text. So the fix for bug 1 was sitting on the page as visible text instead of being applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;My pre-deploy check validated content, structured data, affiliate compliance, image alt text, and that CSS files return 200. All green.&lt;/p&gt;

&lt;p&gt;What it didn't check: whether a class used in HTML is actually defined anywhere. A missing definition isn't an error — it's a no-op. Validators don't flag it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two checks I added
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CSS outside &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;&lt;/strong&gt; — look for &lt;code&gt;{&lt;/code&gt; appearing right after a closing tag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undefined classes&lt;/strong&gt; — extract class names from HTML, confirm each is defined in a stylesheet&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are cheap. Both would have caught this in seconds.&lt;/p&gt;

&lt;p&gt;The third thing isn't automatable, and it's the one that actually matters: &lt;strong&gt;open the page and look at it after every deploy.&lt;/strong&gt; Ten seconds. It would have caught all three.&lt;/p&gt;




&lt;p&gt;If you want a second pair of eyes on your site: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Your LLM API Key Is a Production Credential. Start Treating It Like One.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:04:00 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/your-llm-api-key-is-a-production-credential-start-treating-it-like-one-3c2k</link>
      <guid>https://dev.to/toolkitcreators/your-llm-api-key-is-a-production-credential-start-treating-it-like-one-3c2k</guid>
      <description>&lt;p&gt;Every team I have watched handle an LLM key leak made the same category error, and it has nothing to do with carelessness. They filed the API key under &lt;em&gt;configuration&lt;/em&gt; — a string you paste into a settings panel — instead of under &lt;em&gt;credentials&lt;/em&gt;. Configuration gets committed, copied into a Notion doc, and pasted into a Slack thread. Credentials get a rotation policy.&lt;/p&gt;

&lt;p&gt;That distinction is the whole problem. An OpenAI or Anthropic key is a bearer token: whoever holds it is you, for as long as it lives, with no second factor and no per-request verification. The blast radius is not data exfiltration. It is your invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLM keys leak more easily than database passwords
&lt;/h2&gt;

&lt;p&gt;A database password tends to be protected by network topology. The database is in a VPC, the app server is in the VPC, and a stolen string is useless unless the attacker is also inside. LLM API keys are called over the public internet from anywhere, which means the string alone is sufficient. There is no network layer to fall back on.&lt;/p&gt;

&lt;p&gt;Three properties make them worse than a typical SaaS key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They are long-lived by default.&lt;/strong&gt; Nothing forces expiry. A key minted during a hackathon in March is still valid in September.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend is the abuse signal, and it lags.&lt;/strong&gt; Most teams look at usage monthly. By the time a spike is visible on a statement, the attacker has had weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution is hard.&lt;/strong&gt; A shared &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; in a team workspace means a spike could be any of eleven services, and nobody can prove which one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a resale market, which is what makes this worth an attacker's time. Paid model access gets resold as cheap API capacity. The buyer runs a real workload; you pay the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five places these keys actually end up
&lt;/h2&gt;

&lt;p&gt;This is not a theoretical list. These are the paths that show up over and over in post-incident writeups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Git history after a "quick fix."&lt;/strong&gt; Someone commits &lt;code&gt;.env&lt;/code&gt;, a scanner flags it, they add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; and push again. The blob is still in the object database, and every fork and clone still has it. Deleting a file in a new commit does not delete it from history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Client-side bundles.&lt;/strong&gt; Any variable prefixed for client exposure — &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, &lt;code&gt;VITE_&lt;/code&gt;, &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; — is compiled into JavaScript that ships to the browser. If a server-side key ends up behind one of those prefixes, it is public the moment you deploy. The build succeeds. Nothing warns you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. CI logs and artifacts.&lt;/strong&gt; A debug &lt;code&gt;echo $OPENAI_API_KEY&lt;/code&gt; in a workflow, or an uploaded &lt;code&gt;jest&lt;/code&gt; output directory that captured the environment. CI logs are often readable by far more people than production secrets are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Docker image layers.&lt;/strong&gt; &lt;code&gt;COPY . .&lt;/code&gt; before &lt;code&gt;RUN&lt;/code&gt; that needs the key, or an &lt;code&gt;ARG&lt;/code&gt; baked into the final stage. Anyone who can pull the image can read it, including anyone downstream of a registry misconfiguration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The new one: agent config files.&lt;/strong&gt; MCP server configs, &lt;code&gt;.cursorrules&lt;/code&gt; side files, local agent runner settings. These are plain JSON on disk, usually outside the repo, usually not covered by whatever secret scanning you run against the repo. Info-stealer malware enumerates exactly these locations because they are high-yield and unguarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;Six checks, roughly in order of value per minute spent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan history, not just HEAD.&lt;/strong&gt; &lt;code&gt;gitleaks detect&lt;/code&gt; or &lt;code&gt;trufflehog git file://.&lt;/code&gt; against the full history, including all branches. Run it in CI on every push so a new leak fails the build rather than surfacing nine months later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grep your build output.&lt;/strong&gt; After a production build, search &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;.next/&lt;/code&gt;, or your static bundle for your providers' key prefixes (&lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;sk-ant-&lt;/code&gt;). If one appears, you have shipped a credential. This takes under a minute and catches the entire class of prefix mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate keys per environment, with hard spend caps.&lt;/strong&gt; One key per environment, one per service if you can. Most providers now let you set a monthly spend limit per key — check your provider's docs for the current mechanism, because these features change quickly. A per-key cap converts "unlimited liability" into "worst case is $50 and the key stops working."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline your spend.&lt;/strong&gt; Record tokens and cost per key per day. You do not need sophisticated anomaly detection; you need to know what normal looks like so a 4x day is visible the next morning instead of on the invoice. Alert on new models being called and on unusual request volume outside your working hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit the key list by hand, quarterly.&lt;/strong&gt; Every provider console has a key list with a creation date and last-used date. Anything with no recent legitimate use gets revoked. This is boring and it is the single highest-yield check on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the rotation runbook before you need it.&lt;/strong&gt; Rotation is an order-of-operations problem, and the order is counterintuitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation order: revoke the key first
&lt;/h2&gt;

&lt;p&gt;When access is compromised, the instinct is to change the password. For credential theft, that is close to useless. Active sessions and issued API keys survive a password reset unless you revoke them explicitly, and in some publicly reported account takeovers the attacker had also minted &lt;em&gt;additional&lt;/em&gt; API keys under the victim's account — so access persisted through every password change.&lt;/p&gt;

&lt;p&gt;The correct order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Revoke the exposed key (or all keys) in the provider console.&lt;/li&gt;
&lt;li&gt;Revoke active sessions and OAuth grants, not just the password.&lt;/li&gt;
&lt;li&gt;Delete any key you do not recognise, including ones created under your account by someone else.&lt;/li&gt;
&lt;li&gt;Rotate the password last, and enable 2FA if it was not on.&lt;/li&gt;
&lt;li&gt;Mint a replacement with a spend cap and a narrower scope than the one you just killed.&lt;/li&gt;
&lt;li&gt;Go back and find the leak path, or you will be doing this again in a month.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 is the one teams skip. Revocation stops the bleeding; it does not close the wound.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you only have twenty minutes
&lt;/h2&gt;

&lt;p&gt;Do the build-output grep, set a spend cap on every key, and turn on secret scanning for the repo. That covers the two most common leak paths and converts the worst-case outcome from an open-ended bill into a bounded one.&lt;/p&gt;

&lt;p&gt;The mental model shift is the part that sticks, though: an LLM API key is a production credential with a credit card attached. It belongs in the same rotation policy as your database password, not in a &lt;code&gt;.env&lt;/code&gt; file someone pasted into a chat window.&lt;/p&gt;




&lt;p&gt;I wrote a longer version of this focused on the subscription side — how to tell whether a paid AI account is being used by someone else, and what the usage data can and cannot tell you — over on &lt;a href="https://toolkitcreators.com/guides/ai-subscription-token-theft" rel="noopener noreferrer"&gt;Toolkit Creators&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>llm</category>
      <category>cicd</category>
      <category>api</category>
    </item>
    <item>
      <title>11 articles were missing from my sitemap and Google never told me</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:03:37 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/11-articles-were-missing-from-my-sitemap-and-google-never-told-me-13b6</link>
      <guid>https://dev.to/toolkitcreators/11-articles-were-missing-from-my-sitemap-and-google-never-told-me-13b6</guid>
      <description>&lt;p&gt;One of my sites had 81 pages indexed and 53 not. I spent a day assuming the sitemap was broken.&lt;/p&gt;

&lt;p&gt;It wasn't broken. It was &lt;strong&gt;incomplete&lt;/strong&gt; — and that's a much quieter failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The difference matters
&lt;/h2&gt;

&lt;p&gt;A broken sitemap gives you errors. An incomplete one gives you nothing: no warning, no error, just pages that quietly never get discovered. Search Console will happily show you "not indexed" without ever mentioning that you never submitted the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;I did the least clever thing possible — I diffed two lists:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every URL Google knew about (pulled from the index report, with rows-per-page set to 500 so I actually saw all of them)&lt;/li&gt;
&lt;li&gt;Every URL in my sitemap&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Anything in the second list but not the first is a candidate. Anything in &lt;em&gt;neither&lt;/em&gt; is invisible.&lt;/p&gt;

&lt;p&gt;Eleven articles were in neither. One of them was the landing page for a keyword cluster pulling 30 impressions a month — I'd spent time optimizing content for a page Google had never been told existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happened
&lt;/h2&gt;

&lt;p&gt;Those eleven were added after the last time the sitemap was regenerated. Nobody refreshed it. The sitemap wasn't wrong when it was written; it was just stale.&lt;/p&gt;

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

&lt;p&gt;Regenerating the sitemap from the actual file list, not from memory. And a check that compares sitemap contents against files on disk — because "the sitemap exists and returns 200" is not the same as "the sitemap is complete."&lt;/p&gt;

&lt;p&gt;Both of those are true statements about a sitemap that's missing eleven pages.&lt;/p&gt;




&lt;p&gt;Free diagnosis if you want me to look at yours: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>69,000 impressions, 5 clicks. Here's what a 0.04% CTR actually means.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:31:24 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/69000-impressions-5-clicks-heres-what-a-004-ctr-actually-means-5682</link>
      <guid>https://dev.to/toolkitcreators/69000-impressions-5-clicks-heres-what-a-004-ctr-actually-means-5682</guid>
      <description>&lt;p&gt;My five sites got 68,921 impressions and 5 clicks over three months. That's a 0.04% click-through rate.&lt;/p&gt;

&lt;p&gt;My first reaction was "the content must be bad." The data said something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impressions without clicks means page 7
&lt;/h2&gt;

&lt;p&gt;Average position across the sites: 65 to 77.&lt;/p&gt;

&lt;p&gt;Google was showing my pages. People weren't clicking because they never scrolled far enough to see them. The content wasn't invisible — it was ranked where nobody looks.&lt;/p&gt;

&lt;p&gt;That distinction matters, because the fixes are different. Bad content needs rewriting. Page-7 content needs authority and links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half my keyword list wasn't real
&lt;/h2&gt;

&lt;p&gt;On two sites, 57–60% of "keywords" had two impressions or fewer across three months. That's not search demand, that's log noise. I nearly built a content plan around numbers that meant nothing.&lt;/p&gt;

&lt;p&gt;Filter used now: three or more impressions before a query counts as a keyword.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest finding was an absence
&lt;/h2&gt;

&lt;p&gt;One cluster of six phrasing variants ("marketing automation software", "...tools", "...platform" and so on) accounted for 2,925 impressions. It ranked 83–91.&lt;/p&gt;

&lt;p&gt;The page existed. It was complete. Nothing pointed at it — so Google treated it as an orphan page with no reason to rank it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone staring at the same numbers
&lt;/h2&gt;

&lt;p&gt;Impressions are not progress. They're a signal that Google knows you exist. Clicks are the only part that turns into money, and clicks come from position.&lt;/p&gt;

&lt;p&gt;Check average position before you touch a single word of content. If it's past 30, you don't have a content problem yet.&lt;/p&gt;




&lt;p&gt;Site builds and SEO diagnostics, if you want help reading your own numbers: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>beginners</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>My 60-point pre-deploy check passed. The homepage was still broken.</title>
      <dc:creator>孙永瑞</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:27:45 +0000</pubDate>
      <link>https://dev.to/toolkitcreators/my-60-point-pre-deploy-check-passed-the-homepage-was-still-broken-58ga</link>
      <guid>https://dev.to/toolkitcreators/my-60-point-pre-deploy-check-passed-the-homepage-was-still-broken-58ga</guid>
      <description>&lt;p&gt;I have a 60-point pre-deploy check. It validates content structure, FAQ coverage, schema markup, affiliate link compliance, image alt text, OG tags, CSS references.&lt;/p&gt;

&lt;p&gt;Every site passed all 60 points.&lt;/p&gt;

&lt;p&gt;Then someone opened the homepage and found raw CSS rendering as visible text at the top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually broken
&lt;/h2&gt;

&lt;p&gt;Four separate visual bugs, all invisible to my checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Raw CSS as body text.&lt;/strong&gt; A &lt;code&gt;&amp;lt;/style&amp;gt;&lt;/code&gt; tag closed early, so ~460 characters of stylesheet sat outside it — displayed as text instead of applied. This included the rule that centered the hero image, which is why the hero image wasn't centered.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A CSS class with no definition.&lt;/strong&gt; The homepage used &lt;code&gt;.hero-visual&lt;/code&gt;, but that class wasn't in the stylesheet at all. The image had no width constraint and no centering.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Another class with no definition.&lt;/strong&gt; Card thumbnails used &lt;code&gt;.card-thumb&lt;/code&gt;, also undefined — so a portrait image (798×942) stretched its card taller than the landscape ones next to it. The row looked broken.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stale CSS cache.&lt;/strong&gt; Fixing the stylesheet did nothing until I bumped the version parameter in the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Three of the four are the same mistake: &lt;strong&gt;the HTML uses a class, the CSS never defines it.&lt;/strong&gt; Typo, incomplete copy-paste during a redesign, a code block pasted past the closing tag. All of it renders fine as far as a validator is concerned — the markup is valid, the class just does nothing.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A check for CSS sitting outside &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags (looks for &lt;code&gt;{&lt;/code&gt; immediately after a closing tag)&lt;/li&gt;
&lt;li&gt;A check that every class used in HTML is actually defined somewhere in the CSS&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;And the one that mattered most: open the page after every deploy.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one isn't automatable in a useful way — and it would have caught all four bugs in about ten seconds.&lt;/p&gt;




&lt;p&gt;I do site builds and SEO diagnostics if you want a second pair of eyes: &lt;a href="https://yongrui-services.pages.dev/" rel="noopener noreferrer"&gt;https://yongrui-services.pages.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
