{"slug": "building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change", "title": "Building a Self-Healing Scraper: Let AI Fix Your Selectors When Sites Change", "summary": "A developer has outlined a self-healing web scraping architecture that uses an LLM only as a fallback when hardcoded CSS selectors break, rather than for every request. The approach validates extracted data against a Pydantic schema to detect silent failures, then trims HTML by 60–80% and asks a model to recover the value and generate a replacement selector, which is verified and saved for subsequent runs. The design keeps the expensive model call on the exception path so stable sites never incur LLM costs.", "body_md": "Your scraper doesn't fail with a stack trace. It fails silently.\n\nA site ships a redesign, renames one CSS class, and your `.css(\"span.price\")` quietly starts returning `None`. Nothing crashes. The pipeline keeps running, writing rows of empty prices into your database, and you find out a week later when someone downstream asks why the dashboard is flat.\n\nThe hard part of scraping was never writing the first version. It's keeping it alive as the target keeps moving. In this tutorial we'll build a scraper that notices when its own selectors break, uses an LLM to find the data again **and generate a fresh selector**, then saves that selector so the next run is cheap again.\n\nThe key idea, before we write any code: the LLM is not doing the scraping. It only runs when the normal path breaks. That one constraint is what keeps this practical instead of a way to set money on fire.\n\nHere's the whole control flow:\n\n```\nrequest → try SAVED selectors\n             │\n     ┌───────┴────────┐\n   works            fails validation\n     │                    │\n  return         trim HTML → ask LLM for value + new selector\n                            │\n                     verify the new selector actually works\n                            │\n                     save it → return\n```\n\nThe expensive branch — trimming HTML, calling a model — is the exception, not the rule. On a stable site it never fires. It only kicks in on the day a site changes, and after it heals once, every following request is back on the fast path.\n\nStart with something completely ordinary. `httpx` to fetch, `parsel` to parse, hardcoded selectors.\n\n``` python\nimport httpx\nfrom parsel import Selector\n\nDEFAULT_SELECTORS = {\n    \"title\": \"h1.product-title\",\n    \"price\": \"span.price\",\n}\n\ndef extract(html: str, selectors: dict) -> dict:\n    tree = Selector(html)\n    return {\n        field: tree.css(f\"{sel}::text\").get()\n        for field, sel in selectors.items()\n    }\n```\n\nNothing new here. This is the scraper you already write. The interesting part is how we decide whether its output is any good.\n\nMost people check for breakage with `if result is None`. That catches a selector that matches nothing, but it misses the nastier case: a selector that matches the *wrong* element and returns plausible-looking garbage.\n\nA schema is a much stronger health check. Define what a valid result looks like with `pydantic`, and let validation be your detector:\n\n``` python\nfrom pydantic import BaseModel, ValidationError, field_validator\n\nclass Product(BaseModel):\n    title: str\n    price: float\n\n    @field_validator(\"title\")\n    @classmethod\n    def title_not_blank(cls, v: str) -> str:\n        if not v or not v.strip():\n            raise ValueError(\"empty title\")\n        return v.strip()\n\ndef validate(raw: dict) -> Product | None:\n    clean = {}\n    for key, value in raw.items():\n        if value is None:\n            return None\n        if key == \"price\":\n            value = \"\".join(c for c in value if c.isdigit() or c == \".\")\n        clean[key] = value\n    try:\n        return Product(**clean)\n    except ValidationError:\n        return None\n```\n\nNow \"is my scraper healthy?\" has a precise answer: `validate(extract(...))` returns a `Product`, or it returns `None`. A price of `\"Add to cart\"` fails the `float` coercion. An empty title fails the validator. That `None` is our trigger.\n\nWhen validation fails, we hand the problem to a model. Two things matter here: cost control and trust.\n\n**Cost control** means never sending raw HTML. A product page is mostly `<script>`, `<style>`, and inline SVG — none of which the model needs, all of which you pay for by the token. Strip it first:\n\n``` php\nfrom bs4 import BeautifulSoup\n\ndef trim_html(html: str) -> str:\n    soup = BeautifulSoup(html, \"html.parser\")\n    for tag in soup([\"script\", \"style\", \"svg\", \"noscript\", \"head\"]):\n        tag.decompose()\n    return str(soup)\n```\n\nOn a typical product page this cuts the payload by 60–80%.\n\n**Trust** means the prompt asks for two things — the value *and* a selector — so we can verify the answer instead of taking it on faith:\n\n``` python\nimport json\nfrom openai import OpenAI\n\nclient = OpenAI()  # reads OPENAI_API_KEY\n\nHEAL_PROMPT = \"\"\"You are a web-scraping repair tool.\nFind one field in the HTML below and return a CSS selector that reaches it.\n\nField: {field}\nWhat it is: {hint}\n\nReturn ONLY JSON, no prose:\n{{\"value\": \"<exact text you found>\", \"selector\": \"<CSS selector for the element>\"}}\n\nHTML:\n{html}\n\"\"\"\n\ndef heal_field(field: str, hint: str, html: str) -> dict:\n    resp = client.chat.completions.create(\n        model=\"gpt-4o-mini\",          # cheap model is fine — see below\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n        messages=[{\n            \"role\": \"user\",\n            \"content\": HEAL_PROMPT.format(field=field, hint=hint, html=html),\n        }],\n    )\n    return json.loads(resp.choices[0].message.content)\n```\n\nThe model can absolutely hallucinate a selector that doesn't exist, or a value that isn't on the page. That's fine, because we never trust it directly. We verify.\n\nThis is the section that makes the whole thing \"self-healing\" rather than \"call an LLM every time.\"\n\nBefore saving a healed selector, we run it against the real HTML and confirm it produces the value the model claimed. Only a selector that actually works gets persisted:\n\n``` python\nfrom parsel import Selector\n\ndef verify(html: str, selector: str, expected: str) -> bool:\n    got = Selector(html).css(f\"{selector}::text\").get()\n    return got is not None and expected.strip() in got.strip()\n```\n\nAnd here's the loop that ties every piece together:\n\n``` python\nimport json\nfrom pathlib import Path\n\nSELECTOR_STORE = Path(\"selectors.json\")\n\nFIELD_HINTS = {\n    \"title\": \"the product's name or title\",\n    \"price\": \"the product's price, digits only\",\n}\n\ndef load_selectors() -> dict:\n    if SELECTOR_STORE.exists():\n        return json.loads(SELECTOR_STORE.read_text())\n    return dict(DEFAULT_SELECTORS)\n\ndef save_selectors(selectors: dict) -> None:\n    SELECTOR_STORE.write_text(json.dumps(selectors, indent=2))\n\ndef scrape(html: str) -> Product:\n    selectors = load_selectors()\n\n    # Fast path: the selectors we already have\n    product = validate(extract(html, selectors))\n    if product is not None:\n        return product\n\n    # Slow path: heal\n    trimmed = trim_html(html)\n    new_selectors = dict(selectors)\n    values = {}\n    for field in Product.model_fields:\n        result = heal_field(field, FIELD_HINTS[field], trimmed)\n        if verify(html, result[\"selector\"], result[\"value\"]):\n            new_selectors[field] = result[\"selector\"]  # keep only verified fixes\n        values[field] = result[\"value\"]\n\n    product = validate(values)\n    if product is None:\n        raise ValueError(\"healing failed: LLM output did not match schema\")\n\n    save_selectors(new_selectors)  # <-- next run is cheap again\n    return product\n```\n\nTrace one lifecycle:\n\n`span.price` misses, validation returns `None`, the healing branch fires, the model finds the price under its new markup, the selector verifies, and it's written to `selectors.json`.\nYou pay for the LLM exactly once per break, not once per request. That's the whole trick.\n\nThis pattern is genuinely useful, and it is not magic. Where it bites:\n\n**Cost still scales with churn.** Healing only on failure is cheap on a stable site and expensive on a site that reshuffles its DOM daily. If your target ships A/B tests that rotate markup constantly, you'll be in the healing branch a lot. Measure your break rate before you assume this is free.\n\n**Validation is not optional.** Everything here depends on the schema being strict enough to catch a wrong answer. A loose schema (`title: str` with no other checks) will happily accept the model's hallucination and persist a broken selector. Your validator is the only thing standing between \"self-healing\" and \"self-corrupting.\" Spend time on it.\n\n**It fixes layout, not access.** Self-healing repairs selectors when the *structure* changes. It does nothing about anti-bot blocking, JavaScript that hasn't rendered yet, or rate limits. Those are separate problems and this pattern won't touch them.\n\n**Sometimes you shouldn't.** If the site is stable, plain selectors are simpler and free — don't add an LLM to a problem you don't have. And if there's an official API, that's almost always cheaper and more reliable than healing a scraper against a hostile frontend.\n\n**Which LLM should I use?**\n\nA cheap one. `gpt-4o-mini`, Claude Haiku, or any small model is fine, because your `pydantic` validation catches bad output regardless of which model produced it. Reach for a bigger model only if the small one keeps failing verification on genuinely complex pages.\n\n**Does this work on JavaScript-heavy sites?**\n\nOnly after the page is rendered. Healing fixes selectors; it doesn't execute JavaScript. Put a headless browser (Playwright) in front to get rendered HTML, then feed that into the same loop.\n\n**What about anti-bot protection?**\n\nDifferent problem. Self-healing handles layout changes, not blocking. If you're getting challenged or banned, that's an access issue to solve before extraction ever runs.\n\n**How much does one heal actually cost?**\n\nA trimmed product page is usually a few thousand tokens. At small-model prices that's a fraction of a cent per field, and it happens once per break — not per request. The expensive scenario is a high break rate, not a high request count.\n\n**Can I use XPath instead of CSS selectors?**\n\nYes. Same loop — ask the model for an XPath, swap `.css()` for `.xpath()` in `extract` and `verify`. Nothing else changes.\n\nA self-healing scraper isn't a smarter scraper — it's a scraper with a fallback that costs money, guarded by validation so it only spends that money when it has to, and a memory so it doesn't spend it twice for the same break. That combination — cheap by default, self-repairing on change, honest about its limits — is what turns a scraper from something you babysit into something that mostly runs itself.\n\nThe full runnable code is in the repo below. Clone it, point it at a site you actually maintain, break a selector on purpose, and watch it heal.", "url": "https://wpnews.pro/news/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change", "canonical_source": "https://dev.to/ahmed_shamandy_17cae30af0/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change-2maj", "published_at": "2026-09-18 00:57:06+00:00", "updated_at": "2026-09-18 01:53:05.035217+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-agents"], "entities": ["Pydantic", "httpx", "parsel", "BeautifulSoup"], "alternates": {"html": "https://wpnews.pro/news/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change", "markdown": "https://wpnews.pro/news/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change.md", "text": "https://wpnews.pro/news/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change.txt", "jsonld": "https://wpnews.pro/news/building-a-self-healing-scraper-let-ai-fix-your-selectors-when-sites-change.jsonld"}}