DEV Community

Sheng Shijie
Sheng Shijie

Posted on

Why your scraper breaks every time a site is redesigned — and how to make it heal itself

Every scraper I've ever written died the same death. Not with an error — with
silence. The site shipped a redesign, my CSS selectors matched nothing, and the
script kept running and returning empty fields. I'd notice days later when the
data looked wrong.

The usual fixes all have a catch:

  • Hand-written selectors are free and fast, but every redesign means a manual rewrite. The maintenance never ends.
  • Cloud "AI scraping" services are robust, but they're expensive, they send the pages you're scraping through someone else's servers, and they run a model call on every single request — your bill scales with your crawl.

I wanted the robustness of the AI approach without paying for it on every page.
So I built scrapemend around one
idea: treat a broken selector as a cache miss, not a crash.

How it works

You describe what you want in plain language — no selectors:

from scrapemend import Scraper, Schema

schema = (
    Schema("product")
    .add("title", "the product name")
    .add("price", "the product price with currency")
)

scraper = Scraper()
result = scraper.extract("https://shop.example/p/1", schema)
print(result["title"], result["price"])
Enter fullscreen mode Exit fullscreen mode

Under the hood:

extract(url, schema)
      │
      ├─ cached selectors exist? ──► apply them
      │                               ├─ all fields matched ──► return (free)
      │                               └─ some field missed ──┐
      │                                                      ▼
      └─ no cache ─────────────────────────────────► LLM heals selectors
                                                      apply, cache, return
Enter fullscreen mode Exit fullscreen mode

The LLM only runs when selectors are missing or have stopped matching. In the
steady state, extraction is just parsing — zero tokens. You pay the model
exactly when a site actually changes, and the repaired selectors are cached so
the next run is free again.

Does it actually survive a redesign?

Here's a real run against two versions of the same page — same Schema, no code
changes:

Run 1 — first visit, no cache        -> HEALED,     178 tokens
Run 2 — same markup again            -> CACHE HIT,     0 tokens
Run 3 — site redesigned (new markup) -> HEALED,     190 tokens
Enter fullscreen mode Exit fullscreen mode

Run 2 is the whole point: once selectors are known, you're not paying anything.
Run 3 is the other half: when the markup changed and the cached selectors broke,
it repaired itself and still returned the right data.

Before the LLM ever sees a page, the HTML is condensed — scripts, styles, and
noise attributes are stripped, keeping only structure and class/id. On a
typical bloated page that cut the prompt by over 90%, which keeps healing cheap.

Try it

pip install scrapemend
Enter fullscreen mode Exit fullscreen mode

It ships with an offline stub so the pipeline runs with no API key, and
provider adapters for OpenAI and DeepSeek when you want real healing. DeepSeek
in particular makes healing almost free.

Code and docs: https://github.com/FengYue427/scrapemend

It's early (0.0.1) and I'd genuinely like feedback on where it breaks. If
you've ever lost a day to a silent scraper, I'd love to know whether this
approach holds up on the sites you work with.

Top comments (5)

Collapse
 
autenai profile image
Auten •

The cache-miss framing is the right one. The failure I'd test first is the opposite of a miss: after a redesign the old selector still matches, just the wrong node. .price now hits the struck-through "was" price, or the first card in a "related products" rail. All fields matched, cache hit, wrong data returned for free, which is the same silent failure you started with.

Two cheap guards that help:

  • Validate each field before trusting the cache, not just "did it match": price has to parse as a number with a currency, and a field that suddenly returns 5 nodes where it used to return 1 counts as a miss.
  • Store a small fingerprint of what the selector hit last time (tag, nearby label text like "Price", rough depth). If the match drifts, heal even though something matched.

The other case worth a test: A/B tests and geo variants. Two live markups means cache, heal, cache, heal on alternate runs. Keeping 2-3 selector sets per field and trying them in order before calling the model stops the ping-pong and keeps the token bill flat.

(From the Auten team: we replay recorded UI steps without a model call and hit the same "matched, but the wrong thing" case on real screens, which is where these come from.)

Collapse
 
fengyue profile image
Sheng Shijie •

Yeap!This is exactly the failure I hadn't thought hard enough about — thank you.
You're right that "matched but wrong node" is the same silent failure in
disguise, and my current cache would happily serve it for free.

The field-level validation is the obvious first fix: a price that no longer
parses as number+currency, or a cardinality change (1 node → 5), should count
as a miss even if the selector still matches. The selector fingerprint (tag +
nearby label + rough depth) is a great idea for catching drift when validation
alone wouldn't — I'm going to prototype both.

And the A/B / geo variant ping-pong is a real one; keeping 2-3 selector sets
per field and trying them in order before calling the model makes a lot of
sense. Mind if I open issues citing these? Would love to get it right.

Collapse
 
autenai profile image
Auten •

Of course, go ahead and open them, no need to credit us.

One small thing for the selector-set list: reorder it by last success. Move whichever set worked most recently to the front, and record which set won on each run. If one set stops winning for a few days, that variant was probably retired, so you can drop it before it ever matches the wrong node by accident.

Collapse
 
sid_mishra profile image
Sid Mishra •

Treating a broken selector as a cache miss is a neat idea. One check that catches the cases healing can’t see: look at each field across the whole run, not per page. If price was filled on 98% of pages last run and 3% this run, the site changed, even if every individual page “succeeded”. The same works the other way for the wrong-match case: if a field that used to parse as a currency suddenly doesn’t, or one selector starts matching five nodes, flag it before the cached selector keeps serving bad data.

Collapse
 
stae_him_ae35092536b300a4 profile image
STAE HIM •

The CACHE HIT path is the useful distinction, though cloud doesn't necessarily mean a model call per page: BrowserAct Cloud also separates Agent build/repair from routine execution. A harder redesign test is when an old selector still matches, but now points to the wrong price. Do you validate field meaning before accepting a cache hit?