Affiliate disclosure: We earn a commission if you sign up through our links. This does not influence our test results or editorial scores. Full disclosure →

Structured Data Extraction from HTML with LLMs: Using GPT-4o and Claude

Last reviewed: 2026-05-01 · 11 min read · WebScrapingTool.net

Why LLMs for data extraction?

CSS selectors and XPath work reliably when HTML structure is consistent. They break when:

  • The same data appears in different HTML structures across pages (common on product detail pages from different vendors)
  • Text fields have inconsistent formats (“£29.99”, “GBP 29.99”, “29 pounds 99 pence”)
  • Data is embedded in paragraph text rather than structured elements
  • Page layouts vary by region, device, or A/B test

LLMs excel precisely where selectors fail: understanding natural language, handling format variation, and extracting meaning from unstructured text.

The basic extraction pattern

The core pattern: fetch the HTML, extract the relevant text, pass it to an LLM with a structured output instruction.

import anthropic
import json
import requests
from bs4 import BeautifulSoup

client = anthropic.Anthropic()

def extract_product_data(url: str) -> dict:
    # Fetch and clean the HTML
    response = requests.get(url, headers={"User-Agent": "Mozilla/5.0 ..."})
    soup = BeautifulSoup(response.text, "html.parser")
    
    # Remove noise: scripts, styles, nav, footer
    for tag in soup(["script", "style", "nav", "footer", "header"]):
        tag.decompose()
    
    # Get clean text (much cheaper than sending full HTML)
    page_text = soup.get_text(separator="\n", strip=True)
    
    # Truncate to relevant portion (most products are in first 2000 chars of clean text)
    relevant_text = page_text[:3000]
    
    # Extract with Claude
    message = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=500,
        messages=[
            {
                "role": "user",
                "content": f"""Extract structured product data from this page text.
Return ONLY valid JSON with these fields: name, price_gbp (number), sku, in_stock (boolean), rating (number or null).
If a field cannot be found, use null.

Page text:
{relevant_text}"""
            }
        ]
    )
    
    response_text = message.content[0].text.strip()
    # Remove markdown code blocks if present
    if response_text.startswith("```"):
        response_text = response_text.split("```")[1]
        if response_text.startswith("json"):
            response_text = response_text[4:]
    
    return json.loads(response_text)

# Usage
product = extract_product_data("https://example-store.com/product/widget-42")
print(product)
# {"name": "Premium Widget 42", "price_gbp": 29.99, "sku": "WGT-042", "in_stock": true, "rating": 4.5}

Using GPT-4o with structured outputs

OpenAI’s GPT-4o with JSON mode guarantees valid JSON output:

from openai import OpenAI
from pydantic import BaseModel
import requests
from bs4 import BeautifulSoup

client = OpenAI()

class ProductData(BaseModel):
    name: str
    price_gbp: float | None
    sku: str | None
    in_stock: bool
    rating: float | None
    description: str | None

def extract_with_gpt4o(page_text: str) -> ProductData:
    response = client.beta.chat.completions.parse(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Extract structured product data from the provided page text."},
            {"role": "user", "content": page_text[:4000]}
        ],
        response_format=ProductData,
    )
    return response.choices[0].message.parsed

OpenAI’s Structured Outputs mode with Pydantic ensures the response matches your schema exactly — no JSON parsing errors.

Cost analysis (2026 pricing)

For a scraping project processing 10,000 product pages:

Claude claude-sonnet-4-6 (claude-sonnet-4-6):

  • Input: ~1,000 tokens/page (cleaned text) × 10,000 = 10M tokens
  • Output: ~200 tokens/page × 10,000 = 2M tokens
  • Cost: $3/M input + $15/M output = $30 + $30 = $60 total

GPT-4o:

  • Same token counts
  • Cost: $5/M input + $15/M output = $50 + $30 = $80 total

CSS selector-based extraction (no LLM):

  • Zero API cost; compute cost negligible
  • $0 total

LLMs cost $60-80 per 10,000 pages. CSS selectors cost $0. The LLM investment is only worth it when:

  • CSS selectors are unreliable (inconsistent HTML)
  • The time to maintain selectors as layouts change exceeds $60 per maintenance cycle
  • The quality improvement justifies the cost

When LLMs clearly beat CSS selectors

Multi-source aggregation: You are scraping 200 different e-commerce sites, each with different HTML structure. Writing 200 custom selectors is impractical. One LLM prompt works across all of them.

Free-text fields with format variation: Prices written as “$29.99”, “USD 29.99”, “Twenty-nine dollars and ninety-nine cents”, or “29.99 USD”. A selector can extract the text; an LLM can parse any format into a float.

Extracting from paragraph text: “Delivery typically takes 3-5 business days” — no selector can reliably extract “3-5 business days” from this. An LLM understands it.

Tables without column headers: Some legacy sites have data in tables with no headers. An LLM can understand the context (“this appears to be a technical specifications table”) where a selector just sees rows and cells.

Implicit data: “Our products ship from the UK” → ships_from: "UK" — an LLM can infer this from natural language; a selector cannot.

When LLMs are not worth it

Consistent HTML structure: If every product page on a site uses the same template, a Scrapy spider with CSS selectors is 100x cheaper. Use selectors.

Very high volume: At 10M pages/month, LLM costs become $60,000-$80,000/month. Unless the data quality improvement justifies this, use selectors or hybrid approaches.

Real-time requirements: LLM API calls add 0.5-3 seconds per page. For applications requiring fresh data at high speed, this latency is unacceptable.

Hybrid extraction: selectors + LLM fallback

The most practical architecture for production use:

def extract_product(url: str) -> dict:
    response = requests.get(url, headers=get_headers())
    soup = BeautifulSoup(response.text, "html.parser")
    
    # Try CSS selector first (fast, free)
    name = soup.select_one("h1.product-title")
    price = soup.select_one("span.price")
    
    if name and price:
        # Selector-based extraction succeeded
        return {
            "name": name.get_text(strip=True),
            "price": parse_price(price.get_text(strip=True))
        }
    
    # Selector failed — fall back to LLM
    page_text = soup.get_text(separator="\n", strip=True)[:3000]
    return extract_with_llm(page_text)

This pattern uses free selectors for the majority of pages (consistent layouts) and LLM fallback only for unusual cases. Cost drops significantly compared to LLM-only extraction.

Reducing token costs

LLM pricing is per token. Sending full HTML to an LLM is expensive — most HTML is noise.

Pre-processing to reduce tokens:

def clean_for_llm(html: str, max_chars: int = 3000) -> str:
    soup = BeautifulSoup(html, "html.parser")
    
    # Remove structural noise
    for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
        tag.decompose()
    
    # Get clean text
    text = soup.get_text(separator="\n", strip=True)
    
    # Remove consecutive blank lines
    import re
    text = re.sub(r'\n{3,}', '\n\n', text)
    
    return text[:max_chars]

A 100KB HTML page typically reduces to 2,000-4,000 characters of relevant text — a 10-30x token reduction.

Prompt engineering for extraction

Be specific about the output format: “Return ONLY valid JSON” reduces the risk of the LLM returning explanation text alongside the JSON.

Provide examples in the prompt (few-shot):

Example input text: "Widget Pro — £49.99 — In Stock — SKU: WP-001"
Example output: {"name": "Widget Pro", "price_gbp": 49.99, "in_stock": true, "sku": "WP-001"}

Now extract from: [actual page text]

Specify null handling: “If a field cannot be found, use null rather than guessing.” This prevents hallucination of data that is not present.

Validate outputs: Always validate LLM-extracted data with type checks and range checks. A price field returning a string instead of a float, or a rating field returning 47.5 instead of 4.75, should trigger a re-extraction or manual review flag.

Recommended APIs — skip the DIY wall

ScraperAPI

8.5/10

✓ Indie devs with a deadline

From $49/mo

Read review →

Apify

8.8/10

✓ No-code teams

From $49/mo

Read review →

Zyte

9/10

✓ Enterprise + compliance

From $450/mo

Read review →

Go deeper