Abstract / Overview

ParserGPT extracts structured rows from irregular websites and produces clean CSVs. It separates learning from execution. A small learner infers per-domain selectors. A deterministic runner applies those selectors at scale. An LLM fills only missing fields under strict schema control. The pipeline is auditable, typed, and cost-aware.

Assumption: Python 3.11+, FastAPI, Postgres, Playwright, LangChain, and LangGraph.

ParserGPT

Conceptual Background

Web pages change often. DOMs differ by template and device. Client scripts hydrate content late. Naive LLM parsing of whole pages is slow and expensive. Full-rule scrapers drift and break.

ParserGPT uses two roles:

This split balances speed and resilience. Deterministic selectors are fast and repeatable. LLM usage becomes targeted and bounded.

Step-by-Step Walkthrough

Code / JSON Snippets

Minimal code. Replace placeholders before use.

FastAPI surface: job creation and CSV download

# app/main.py
import os, asyncio, csv, json
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Literal

app = FastAPI()

class Field(BaseModel):
    name: str
    dtype: Literal["string", "number", "date", "string[]"]

class JobIn(BaseModel):
    start_url: str
    allowed_domains: List[str] = []
    max_depth: int = 1
    max_pages: int = 25
    field_spec: List[Field]

@app.post("/jobs")
async def create_job(job: JobIn):
    job_id = 1  # replace with DB insert and autoincrement id
    asyncio.create_task(run_job(job_id, job))
    return {"job_id": job_id, "status": "started"}

@app.get("/jobs/{job_id}/csv")
def download_csv(job_id: int):
    path = f"job_{job_id}.csv"
    if not os.path.exists(path):
        raise HTTPException(404, "CSV not ready")
    return FileResponse(path, media_type="text/csv", filename=os.path.basename(path))

async def run_job(job_id: int, job: JobIn):
    # 1) sample pages
    samples = await sample_pages(job.start_url, job.allowed_domains, max_pages=min(5, job.max_pages))
    # 2) learn adapter
    adapter = await learn_adapter(job.field_spec, samples)
    # 3) crawl and extract
    rows = await crawl_and_extract(job, adapter)
    # 4) write CSV
    write_csv(f"job_{job_id}.csv", [f.model_dump() if hasattr(f, "model_dump") else f for f in job.field_spec], rows)

def write_csv(path: str, field_spec, rows: List[dict]):
    names = [f["name"] for f in field_spec]
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=names)
        w.writeheader()
        for r in rows:
            w.writerow({k: (", ".join(v) if isinstance(v, list) else v) for k, v in r.items() if k in names})

HTTP first, browser if blocked

# sampling.py
import httpx, asyncio, urllib.parse
from typing import List, Tuple, Set
from playwright.async_api import async_playwright

async def fetch_http(url: str) -> str:
    async with httpx.AsyncClient(follow_redirects=True, timeout=30.0, http2=True) as c:
        r = await c.get(url, headers={"User-Agent": "Mozilla/5.0"})
        if r.status_code == 200 and "text/html" in r.headers.get("content-type", ""):
            return r.text
    return ""

async def fetch_browser(url: str) -> str:
    async with async_playwright() as p:
        ctx = await p.chromium.launch_persistent_context(
            user_data_dir="./pw-profile",
            headless=True,
            args=["--disable-blink-features=AutomationControlled"]
        )
        page = await ctx.new_page()
        await page.add_init_script("Object.defineProperty(navigator,'webdriver',{get:()=>undefined});")
        await page.goto(url, wait_until="load", timeout=60000)
        await page.wait_for_timeout(1200)
        html = await page.content()
        await ctx.close()
        return html

def same_domain(url: str, allowed_domains: List[str]) -> bool:
    host = urllib.parse.urlparse(url).netloc
    return any(host.endswith(d) for d in allowed_domains) if allowed_domains else True

async def sample_pages(start_url: str, allowed_domains: List[str], max_pages: int = 5) -> List[Tuple[str, str]]:
    seen: Set[str] = set()
    out: List[Tuple[str, str]] = []
    queue = [start_url]
    while queue and len(out) < max_pages:
        url = queue.pop(0)
        if url in seen or not same_domain(url, allowed_domains):
            continue
        seen.add(url)
        html = await fetch_http(url)
        if not html:
            html = await fetch_browser(url)
        if html:
            out.append((url, html))
        # intentionally skip link expansion to stay minimal
    return out

Learner: propose a compact adapter under strict JSON

# learner.py
from typing import Dict, List, Tuple
from pydantic import BaseModel, Field
from langchain.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

class Adapter(BaseModel):
    url_patterns: Dict[str, List[str]] = Field(default_factory=dict)
    selectors: Dict[str, Dict[str, str]] = Field(default_factory=dict)
    tests: List[Dict[str, Dict[str, str]]] = Field(default_factory=list)

parser = PydanticOutputParser(pydantic_object=Adapter)
PROMPT = ChatPromptTemplate.from_messages([
    ("system", "Infer minimal, robust CSS/XPath/regex selectors for the fields. Return ONLY valid JSON."),
    ("human", "FIELDS: {fields}\nEXAMPLES:\n{examples}\n{format_instructions}")
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

async def learn_adapter(field_spec: List[Dict], samples: List[Tuple[str, str]]) -> Dict:
    examples = "\n\n".join(f"URL: {u}\nHTML:\n{h[:6000]}" for u, h in samples)
    msg = {
        "fields": ", ".join(f"{f['name']}:{f['dtype']}" for f in field_spec),
        "examples": examples,
        "format_instructions": parser.get_format_instructions()
    }
    draft = await (PROMPT | llm | parser).ainvoke(msg)
    return draft.model_dump()

Deterministic extraction using the adapter

# extractor.py
from bs4 import BeautifulSoup
import re

def cast_value(dtype: str, value):
    if dtype == "string[]":
        return value if isinstance(value, list) else ([value] if value else [])
    if dtype in {"string", "date"}:
        return "" if value is None else str(value)
    if dtype == "number":
        try:
            return float(str(value).replace(",", "").strip())
        except Exception:
            return None
    return value

def extract_with_adapter(html: str, adapter: dict, field_spec: list) -> dict:
    soup = BeautifulSoup(html, "lxml")
    text = soup.get_text(" ", strip=True)
    out = {}
    for f in field_spec:
        name = f["name"]; dtype = f["dtype"]
        sel = adapter.get("selectors", {}).get(name, {})
        vals = []
        if sel.get("css"):
            vals += [e.get_text(" ", strip=True) for e in soup.select(sel["css"])]
        if sel.get("xpath"):
            # optional: integrate lxml xpath if needed
            pass
        if sel.get("regex"):
            vals += re.findall(sel["regex"], text, flags=re.I)
        val = vals if dtype == "string[]" else (vals[0] if vals else "")
        out[name] = cast_value(dtype, val)
    return out

Fallback: fill only missing or invalid fields with strict JSON

# fallback.py
from typing import List, Dict
from pydantic import BaseModel
from langchain.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

class Row(BaseModel):
    # customize fields for your schema
    title: str = ""
    price: str = ""
    features: List[str] = []

parser = PydanticOutputParser(pydantic_object=Row)
PROMPT = ChatPromptTemplate.from_messages([
    ("system", "Extract only requested fields. Return ONLY valid JSON that matches the schema."),
    ("human", "URL: {url}\nHTML:\n{snippet}\n{format_instructions}")
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

async def llm_fill(url: str, html: str) -> Dict:
    data = await (PROMPT | llm | parser).ainvoke({
        "url": url, "snippet": html[:12000],
        "format_instructions": parser.get_format_instructions()
    })
    return data.model_dump()

def merge_fallback(deterministic: Dict, fallback: Dict) -> Dict:
    out = deterministic.copy()
    for k, v in fallback.items():
        if k not in out: 
            out[k] = v
            continue
        if isinstance(out[k], list):
            out[k] = out[k] if out[k] else v
        else:
            out[k] = out[k] if out[k] not in ("", None) else v
    return out

Runner: crawl, extract, fallback, persist

# runner.py
from typing import List, Dict, Tuple
from .extractor import extract_with_adapter
from .fallback import llm_fill, merge_fallback

async def crawl_and_extract(job, adapter) -> List[Dict]:
    rows: List[Dict] = []
    for url, html in await sample_pages(job.start_url, job.allowed_domains, job.max_pages):
        row = extract_with_adapter(html, adapter, [f.model_dump() if hasattr(f, "model_dump") else f for f in job.field_spec])
        # naive validation
        missing = any(v in ("", None, []) for v in row.values())
        if missing:
            filled = await llm_fill(url, html)
            row = merge_fallback(row, filled)
        rows.append(row)
    return rows

Adapter example (minified)

{
  "domain": "example.com",
  "url_patterns": {
    "detail": ["*/product/*"],
    "list": ["*/products/*", "*category*"]
  },
  "selectors": {
    "title": { "css": "h1.page-title", "xpath": "", "regex": "" },
    "price": { "css": ".price", "xpath": "", "regex": "(\\d+[\\.,]\\d{2})" },
    "features": { "css": ".features li", "xpath": "", "regex": "" }
  },
  "tests": [
    { "url": "https://example.com/product/sku-123", "expects": { "title": "Demo" } }
  ]
}

Sample workflow JSON (copy-ready)

{
  "job": {
    "start_url": "https://example.com/products",
    "allowed_domains": ["example.com"],
    "max_depth": 1,
    "max_pages": 50,
    "field_spec": [
      {"name": "title", "dtype": "string"},
      {"name": "price", "dtype": "string"},
      {"name": "features", "dtype": "string[]"}
    ],
    "strategy": {
      "sampling": ["http", "browser_if_blocked"],
      "learning": {"loops": 3, "coverage_threshold": 0.9},
      "runner": {"concurrency": 8, "retry": 2},
      "fallback": {"enable": true, "fields": ["title", "price", "features"]}
    },
    "storage": {
      "postgres_url": "postgres://USER:PASS@HOST:5432/DB",
      "keep_raw_html": true,
      "csv_path": "job_42.csv"
    },
    "audit": {
      "save_adapter": "adapters/example.com.v1.json",
      "tests_autogen": true
    }
  }
}

Docs snippet: FAQ JSON-LD for your documentation page

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{
    "@type": "Question",
    "name": "What is ParserGPT?",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "A two-part system that learns per-domain selectors and runs deterministic extraction with LLM fallback."
    }
  },{
    "@type": "Question",
    "name": "How are CSVs created?",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "Rows are extracted using adapters, normalized by dtype, stored, and exported as CSV."
    }
  }]
}
</script>

Use Cases / Scenarios

Limitations / Considerations

Fixes (common pitfalls with solutions)

Diagram

parsergpt-adapter-runner-fallback-pipeline

Conclusion

Treat scraping as compile-then-execute. Learn per-domain selectors once. Run fast and deterministically. Use LLMs only when rules leave gaps. Validate every generation against a schema. Version adapters and store evidence. The result is predictable cost, auditable output, and clean CSVs at scale.\

References

https://medium.com/@ayush.shrivastava016/parsergpt-public-beta-coming-soon-turn-messy-websites-into-clean-csvs-dd8c7199ca97