A price collector I run had stopped bringing anything home. Not crashed — collected. Every service was up, the scheduler was firing on time, the database was accepting writes, and the failure streak counter sat at zero. The only thing wrong was that both workers had returned zero rows on every run since 11:31 the previous morning, roughly 685 runs in a row.
Zero is a legal answer. A category can genuinely have nothing new today. So the collector had been reporting "zero results" as a warning, and warnings didn't page anyone. A system with no errors and no data looks, from the outside, exactly like a quiet week.
The site hadn't blocked me — it had moved
The target had replaced its listing pages with a rebuilt front end on a different subdomain, redirecting the old URLs to the new ones. No block, no captcha, no rate limit. My fetcher followed the redirect, got a perfectly healthy page, and found that every selector it knew — the product card, the detail link, the pagination — matched nothing. Zero cards parsed, zero errors raised, zero rows written, and a green dashboard.
Two fixes came out of that, and only one of them is about this site. The narrow one is a new parser. The broad one is that a long run of zeroes is now promoted to an error after a threshold, because the absence of data is data.
Rewriting it so the next site is cheaper
Rather than patch the selectors in place, I pulled the whole thing into a reusable layer: fetch, adapter, normalize, quality-check, validate, deduplicate, sink. The site-specific knowledge now lives in one adapter file and a handful of saved page fixtures, so the next site is an adapter plus fixtures rather than another bespoke scraper.
Testing against saved pages rather than the live site is what made the rest of the session possible. I could run the same 150 product cards through three different HTML parsers, compare old pipeline against new on identical input, and see differences that had nothing to do with the rewrite.
What the comparison turned up
Thirty of those 150 cards priced differently under the new rules — all in one category, all cases where the old code had been quietly picking up a card-issuer promotional price as the item's price, typically 5–10% below the real one. That's a data-quality bug that had been running in production for months, invisible because nothing about it ever failed.
Sixteen items didn't match anything in the database, which turned out to be a schema problem rather than a collection one: the products table identifies rows by name, with no stable ID column, so a vendor renaming a product creates a new row and there is no way to prove afterward that it's the same thing. Fifteen were genuinely new; one was a rename I could only confirm by reading it.
One more detail I liked: after the rewrite, a dry run against the live database re-identified all 54 previously known items in that category, with zero invalid records. The rewrite could be trusted precisely because I had a way to check it against something other than itself.
The part worth keeping
The scraper breaking is not the interesting event — scrapers break whenever a site ships a redesign, and that's not a bug you can prevent. The interesting event is that it broke into a shape my monitoring was built to ignore. Everything I watch for was working correctly. What I wasn't watching for was a number that stayed the same for 685 runs.