The scraper had been green for eleven weeks. Every run exited 0, every run wrote rows to the warehouse, the dashboard showed a healthy line. Then someone in the pricing team asked why 40% of products suddenly had no price. They'd been shipping decisions on that data for most of a week.
Nothing had failed. That's the whole problem. The site had quietly moved the price into a different element during an A/B test, our selector returned null, and the pipeline did exactly what we told it to: it wrote null and moved on. A scrape can succeed structurally and fail semantically, and almost nobody monitors for the second kind.
Exit 0 is not the same as correct
I've written about scrapers that return zero results and still exit clean. Silent drift is the same disease at a different stage. The request went through. The HTML parsed. The loop ran. Every layer reported success because every layer only knows about its own job, and none of them know what a good record is supposed to look like.
Markup drift is the number one way scrapers rot, and it rarely announces itself with a crash. A crash would be a gift. A crash pages someone. What actually happens is subtler: a retailer ships a redesign, or rolls out a variant to 10% of traffic, or renames a CSS class in a build step, and one field goes dark while the other nine keep flowing. Your fill rate drops from 99% to 61% and the only thing that changed color is a number in a spreadsheet three teams away.
Validate the output, not the input
The instinct is to harden the parser. More selectors, more try/catch, more defensive HTML handling. That helps, but it's aiming at the wrong target. You cannot assert your way to correctness on input you don't control. What you can control is a contract on the output: after a run, a record is only allowed to exist if it meets a schema you defined.
The cheapest version is a canary check on a sample. Pull N records from the run, assert the fields that must never be null actually aren't, and fail loudly if too few pass.
function assertHealthy(records, { sample = 200, minFillRate = 0.9 } = {}) {
const batch = records.slice(0, sample);
const required = ["title", "price", "currency"];
const fillRate = {};
for (const field of required) {
const filled = batch.filter((r) => r[field] != null && r[field] !== "").length;
fillRate[field] = filled / batch.length;
}
const failed = required.filter((f) => fillRate[f] < minFillRate);
if (failed.length) {
throw new Error(
`Fill-rate check failed: ${failed
.map((f) => `${f}=${(fillRate[f] * 100).toFixed(1)}%`)
.join(", ")}`
);
}
return fillRate;
}
Now a run that produces 40% null prices doesn't exit 0. It throws, and throwing is a thing your alerting already understands. You've converted a silent semantic failure into a loud structural one, which is the only kind of failure your on-call rotation can actually see.
A static threshold is a starting point, not the answer
minFillRate: 0.9 is a guess, and guesses age badly. Some fields are legitimately sparse. Not every product has a discount, not every listing has a review count, and a hard 90% floor on an optional field will page you at 3am for nothing. The failure you actually care about is not "this field is low," it's "this field is lower than it was yesterday."
So track fill rate per field over time and compare each run to a rolling baseline. The alert fires on the delta, not the absolute.
function driftAlarm(field, current, history, { drop = 0.15 } = {}) {
// history: last ~14 runs of fill rate for this field
const baseline = history.reduce((a, b) => a + b, 0) / history.length;
if (baseline - current > drop) {
return `${field} fill-rate ${(current * 100).toFixed(1)}% vs baseline ${(
baseline * 100
).toFixed(1)}%, likely markup drift`;
}
return null;
}
A field that has hovered at 12% for two weeks and is still at 12% is fine. A field that lived at 99% and dropped to 61% overnight is a selector that just died, and you'll know within one run instead of one quarter. Persist these numbers somewhere boring, a small table or even a JSON blob per run, and the baseline builds itself.
Don't let one dead selector take the record down
The last piece is structural. If a single field extraction is the only path to that field, then the day it breaks, the field is simply gone. The fix is to run several extraction strategies per field and take the first that produces a valid value. A machine-readable data block on the page, a couple of markup patterns, a fallback derived from a neighbor. When one strategy dies to a redesign, another usually still stands, and the record survives with its price intact.
This is the shape I lean on in production. Multiple independent ways to reach each field, a schema contract on the way out, and fill-rate tracking so I find out from my own monitoring before a downstream team finds out from a broken report. The strategies do the resilience. The output validation does the alerting. You need both, because resilient extraction that you never measure will still drift eventually, and it won't tell you either.
The mental model that fixes this: your scraper's job is not to finish. It's to produce records that pass a contract, and anything short of that is a failure even when the process exits 0.
I'm building Cartpie, an e-commerce product-data platform where this multi-strategy extraction and output validation is the whole point, and I publish scrapers on Apify that are built the same way.
Top comments (0)