Extracting Structured Data from HTML with LLMs
When language-model extraction beats CSS selectors, when it doesn't, and how to keep it accurate and cheap.
The case against selectors
A selector encodes a page's current DOM into your codebase. Every redesign, A/B test, localization variant and promotional banner is a chance for it to break, and it almost never breaks loudly — you get `None`, which flows through your pipeline and shows up as a hole in a chart days later.
The cost is not writing the selectors, it's owning them. Ten sites is a hundred selectors is a permanent maintenance queue. The team that told us they had "about 40 scrapers" actually had one full-time engineer's worth of breakage triage, which is the number that matters.
Language-model extraction reads a page roughly the way a person does, so cosmetic changes don't register. The trade-offs are real though: it costs more per page, adds a second or two of latency, and it can be confidently wrong in ways a selector never is. This article is mostly about managing those three things.
Make it cheap: clean before you extract
A typical e-commerce page is 400–900 KB of HTML. The part containing your fields is usually under 20 KB. Everything else is scripts, inline styles, SVG sprites, nav, footer, cookie banners, tracking pixels and analytics blobs — and you are billed per token for all of it, at scale, forever.
Cleaning first is not an optimization, it's the difference between viable and not. Stripping non-content nodes removes 80–95% of tokens on most pages, and accuracy usually goes up rather than down because the model has less to be distracted by. Converting the remainder to markdown typically halves it again.
raw_html_tokens = 210_000 # a real product page, not a strawman
cleaned_html_tokens = 14_500 # scripts/styles/nav/footer removed
markdown_tokens = 6_200 # cleaned HTML converted to markdown
INPUT_COST_PER_MTOK = 0.15 # substitute your model's rate
for label, tokens in [("raw", raw_html_tokens),
("cleaned", cleaned_html_tokens),
("markdown", markdown_tokens)]:
per_page = tokens / 1_000_000 * INPUT_COST_PER_MTOK
print(f"{label:<9} ${per_page:.5f}/page ${per_page * 1_000_000:>10,.0f} / 1M pages")
# raw $0.03150 $ 31,500 / 1M pages
# cleaned $0.00218 $ 2,175 / 1M pages
# markdown $0.00093 $ 930 / 1M pagesSame model, same schema, same accuracy target — a 34x cost difference decided entirely by what you put in the prompt.
Narrow the region before you narrow the tokens
Cleaning is generic. If you can cheaply identify the region likely to hold your fields, do that first — it's a bigger win than any prompt tuning. A JSON-LD `Product` block, an `<article>` element, a `main` landmark or a container with a `data-product-id` attribute will each get you from a page to a fragment for the cost of one DOM query.
The escape hatch matters too: when the cheap heuristic finds nothing, fall back to the cleaned full page rather than failing. A hybrid that hits the fast path 85% of the time and the slow path 15% is dramatically cheaper than one that always takes the slow path, and dramatically more reliable than one that only has a fast path.
import { JSDOM } from "jsdom";
const REGION_HINTS = ["script[type='application/ld+json']", "[itemtype*='Product']",
"main", "article", "[data-product-id]"];
export function candidateRegion(html) {
const { document } = new JSDOM(html).window;
document.querySelectorAll("script:not([type='application/ld+json']),style,nav,footer,svg,noscript")
.forEach((el) => el.remove());
for (const sel of REGION_HINTS) {
const el = document.querySelector(sel);
if (el && el.textContent.trim().length > 200) {
return { html: el.outerHTML, strategy: sel }; // fast path
}
}
return { html: document.body.innerHTML, strategy: "full-body" }; // fallback
}Design the schema like an API contract
Loose schemas are where LLM extraction goes wrong. `"price": "string"` will happily accept `"$1,299.00 (was $1,499)"` and your downstream sum breaks in a way nobody traces back to here. Be strict, be explicit about units and formats, and make nullability a deliberate decision per field rather than a default.
Two rules that catch most of the pain. Separate value from currency, always. And prefer explicit `null` over an empty string, so "the model couldn't find it" is distinguishable from "the page says it's blank".
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class Product(BaseModel):
name: str = Field(min_length=2, max_length=300)
price: float | None = Field(default=None, gt=0, lt=1_000_000,
description="Numeric only. No symbols, no thousands separators.")
currency: str | None = Field(default=None, pattern=r"^[A-Z]{3}$",
description="ISO 4217, e.g. USD, EUR, GBP.")
availability: Literal["in_stock", "out_of_stock", "preorder", "unknown"] = "unknown"
sku: str | None = None
@field_validator("price")
@classmethod
def sane(cls, v):
if v is not None and round(v, 2) != v:
raise ValueError("price must have at most 2 decimals")
return v
# Validation failure is a caught error, not a silently wrong row.
product = Product.model_validate(llm_output)const { data, confidence } = await irmu.extract({
url: "https://example.com/product/42",
schema: {
name: "string",
price: "number",
currency: "string",
availability: "string",
},
});
if (confidence.price < 0.8) await queueForReview(data);Route by confidence instead of trusting everything
The useful mental model is straight-through processing. Most records are unambiguous and should flow through untouched. A minority are genuinely hard — a bundle price, a page mid-deploy, a locale that writes dates backwards — and those should go to a queue where a human spends ten seconds.
Per-field confidence makes that routing possible. Don't gate on a single overall score: a record can have a perfect name and a nonsense price, and you care enormously about one of those. In practice teams land somewhere around 95–98% straight-through with a small review queue, and the queue is also your best source of eval cases.
CRITICAL = {"price": 0.90, "currency": 0.90, "sku": 0.85}
ADVISORY = {"name": 0.60, "availability": 0.70}
def route(data: dict, confidence: dict):
for field, floor in CRITICAL.items():
if confidence.get(field, 0) < floor:
return "review", f"{field} below {floor}"
warnings = [f for f, floor in ADVISORY.items() if confidence.get(f, 0) < floor]
return ("accept_with_warnings", warnings) if warnings else ("accept", None)You need an eval set, and it can be small
The thing that separates teams who trust their extraction from teams who nervously spot-check it is a fixed set of pages with hand-verified answers. Fifty is enough to start. Include the awkward ones: a page with two prices, a sold-out variant, a right-to-left locale, a page with a cookie wall, one that's mid-A/B-test.
Run it on every prompt change, model change and cleaning-pipeline change. Without it you cannot tell whether your clever token reduction cost you three points of accuracy, and you will find out from a customer instead.
import json, statistics
cases = [json.loads(l) for l in open("evals/products.jsonl")]
def score(expected: dict, got: dict) -> dict:
return {k: (expected[k] == got.get(k)) for k in expected}
results = [score(c["expected"], extract(c["html"])) for c in cases]
by_field = {
field: statistics.mean(r[field] for r in results)
for field in results[0]
}
for field, acc in sorted(by_field.items(), key=lambda kv: kv[1]):
flag = "FAIL" if acc < 0.95 else "ok"
print(f"{field:<14} {acc:6.1%} {flag}")When selectors still win
If you scrape one stable page shape millions of times a day and you own the monitoring for it, selectors are cheaper and will stay cheaper. Write the selector, add an alert on null rate, move on. Using a language model there is paying a premium for flexibility you're not using.
The moment you're covering many different sites, or one site that reorganizes often, or you need fields that only exist in prose, extraction wins on total cost of ownership even at a higher per-request price — because the cost you're actually optimizing is engineer-hours, not tokens.
The strongest setup is neither purist: selectors on the fast path where they're stable, extraction as the fallback when they return null, and the null rate as the metric that tells you which sites have drifted. You get selector economics with LLM resilience, and the fallback rate doubles as your redesign detector.
def get_price(html: str, url: str) -> float | None:
if (v := css_price(html)) is not None: # cheap, deterministic
metrics.increment("extract.selector_hit")
return v
metrics.increment("extract.llm_fallback", tags={"host": host(url)})
return llm_price(html) # resilient, pricier
# Alert when llm_fallback rate for a host jumps — that host just redesigned.Keep reading
How to Scrape Google Maps (2026 Guide)
Collect business listings, ratings and reviews from Google Maps reliably — without maintaining a browser farm.
GuidesScrape Amazon Product Data with Python
A practical walkthrough for pulling prices, buy box, stock and reviews from Amazon at scale.
EngineeringCloudflare Bypass: What Actually Works in 2026
A technical look at Turnstile, TLS fingerprinting and why most open-source bypasses stopped working.
Start building with Irmu today
200 free credits every month, no card required. Every API, every integration, one key.