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 (1)
Ran your fill-rate canary against the case it is most likely to meet in the wild, and it does not fire.
assertHealthy([])returns{title: NaN, price: NaN, currency: NaN}and throws nothing:batch.lengthis 0, so every rate is 0/0, andNaN < 0.9evaluates to false, sofailedcomes back empty and the function exits clean. I checked that it discriminates rather than being broken outright — 100 records with price null in 40 percent of them throws correctly on the same code. It is only the empty run that walks through.That matters because an empty run is not a rare shape. This week I measured a keyless job API where one invalid value in the
limitparameter returned HTTP 200 with a 2-byte body and 0 records, from a board carrying 388 postings. No error key, no warning field, nothing but a 200. A nightly job pinned to that parameter writes "0 vacancies" and the pipeline stays green, and a canary is the last thing standing between that and the warehouse. Guardingbatch.lengthbefore the division is two lines, and it converts the quietest failure you can have into the loud one your post is arguing for.The delta alarm has a direction problem sitting next to it.
baseline - current > droponly fires downward, and the mirror case is real: on a different keyless API the same kind of invalid value removed the ceiling instead of collapsing it — 200 records against a documented max of 20, 1,219,458 bytes, still HTTP 200. Fill rate there is a clean 100 percent on every required field, so both of your checks pass while the run returns ten times the intended volume and the extra rows are perfectly well-formed. On both APIs the behaviour is consistent with the value being parsed rather than validated, though that is inferred from the numbers — I have not read either source.So the thing I would add to the output contract is row count itself, tracked against the same rolling baseline you already build and alarmed in both directions. Fill rate is a ratio, and a ratio cannot see the size of the thing it is a ratio of. Your 40-percent-null price run, my 0-record run and my 200-record run all pass a check that only reads proportions.