
Scrape structured data from any website using LLM-powered extraction.
trawl lets you define what you want semantically — not with CSS selectors — and it figures out how to extract it. When a site redesigns, trawl re-derives the extraction strategy automatically. The LLM is called once per site structure, not once per page. Steady-state scraping is pure Go at full speed with zero API cost.
curl -fsSL https://raw.githubusercontent.com/akdavidsson/trawl/main/install.sh | sh
Or with Go:
go install github.com/akdavidsson/trawl@latest
Or build from source:
git clone https://github.com/akdavidsson/trawl
cd trawl
go build -o trawl .
export GOOGLE_GEMINI_APIKEY=AIzaSy...
# Or, if using Anthropic:
export ANTHROPIC_API_KEY=sk-ant-...
# Extract product data as JSON
trawl "https://books.toscrape.com" --fields "title, price, rating, in_stock"
# Output as CSV
trawl "https://books.toscrape.com" --fields "title, price" --format csv
# Preview the extraction plan without extracting
trawl "https://books.toscrape.com" --fields "title, price" --plan
trawl [url] [flags]
# Simple field extraction
trawl "https://example.com/products" --fields "name, price, rating, url" --format json
# Use a YAML schema for precise control
trawl "https://example.com/products" --schema products.yaml --format csv
# Natural language query — trawl infers field names from the data
trawl "https://example.com/products" --query "extract all product listings with names, prices, and stock status"
# Target a specific section on a page with multiple data tables
trawl "https://openrouter.ai/rankings" --query "Market Share" --fields "rank, name, tokens" --js
# Save to a file
trawl "https://example.com/products" --fields "name, price" --output products.json
# Streaming JSONL output, pipe to jq
trawl "https://news.example.com" --fields "headline, date, author" --format jsonl | jq '.headline'
# Re-use a previously derived strategy (no LLM call)
trawl "https://example.com/products" --strategy cached-strategy.json --format csv
# Verbose output to see the full pipeline
trawl "https://example.com/products" --fields "name, price" -v
# JS-rendered pages (React, Next.js, Vue, Svelte, etc.)
trawl "https://example.com/spa" --fields "name, value" --js
# Iframe-embedded apps (e.g. HuggingFace Spaces) — extra wait for content to load
trawl "https://huggingface.co/spaces/open-llm-leaderboard/open_llm_leaderboard" \
--fields "rank, model, average" --js --wait 10s
# Custom headers and cookies
trawl "https://example.com/dashboard" --fields "metric, value" \
--headers "Authorization: Bearer token123" \
--cookie "session=abc123"
| Flag | Short | Default | Description |
|---|---|---|---|
--fields |
-f |
Comma-separated field names to extract | |
--query |
-q |
Natural language description of what to extract | |
--schema |
-s |
Path to YAML schema file |
--query is especially useful for pages with multiple data sections. The query text is matched against section headings and HTML IDs to prioritize the right data region, and is passed to the LLM so it can select the most relevant section. When used together with --fields, the query guides section selection while the fields define the output structure.
| Flag | Short | Default | Description |
|---|---|---|---|
--format |
json |
Output format: json, jsonl, csv, parquet |
|
--output |
-o |
stdout | Write output to file instead of stdout |
| Flag | Short | Default | Description |
|---|---|---|---|
--max-pages |
-n |
1 |
Maximum pages to crawl |
--paginate |
Auto-detect and follow pagination | ||
--concurrency |
-c |
10 |
Number of concurrent workers |
--delay |
1s |
Delay between requests to same domain | |
--no-robots |
Ignore robots.txt (use responsibly) | ||
--js |
Enable headless browser for JS-rendered pages | ||
--wait |
0 |
Extra time to wait after page load with --js (e.g. 5s) |
|
--timeout |
30s |
Per-request timeout | |
--headers |
Custom headers ("Key: Value" format) |
||
--cookie |
Cookie string to include |
| Flag | Default | Description |
|---|---|---|
--strategy |
Path to a cached extraction strategy JSON file | |
--plan |
Dry run: show the LLM-derived extraction plan, don't extract | |
--no-cache |
Don't cache or use cached strategies | |
--no-heal |
Disable self-healing (don't re-derive on failure) |
| Flag | Default | Description |
|---|---|---|
--model |
LLM model to use (default depends on API key) | |
--no-llm |
Disable LLM, use heuristic extraction only |
| Flag | Short | Description |
|---|---|---|
--verbose |
-v |
Verbose output (show strategy derivation, health stats) |
--help |
-h |
Help |
URL ──► Fetch ──► Detect Data Regions ──► LLM Strategy Derivation ──► Extraction Strategy
│
▼
Output (JSON/CSV/JSONL) ◄────────────────────── Apply Strategy via CSS Selectors (Go)
│
[Strategy fails?]
│
Re-derive from new HTML
--js, uses a headless Chromium browser to render JavaScript. The browser automatically:.animate-pulse, etc.) are resolved, ensuring React/Next.js SPAs finish rendering.id attributes are captured for context.container_selector to scope extraction to the correct page section, attribute mappings, transforms, and fallback selectors. If the selectors fail validation against the page, a retry with feedback is attempted automatically.The LLM is called once to figure out the selectors. Every subsequent page with the same structure uses the cached strategy — pure Go, no API calls, no token cost.
The LLM returns a JSON strategy like this:
{
"site_pattern": "https://example.com/products/*",
"container_selector": "#product-list",
"item_selector": "div.product-card",
"fields": [
{
"name": "name",
"selector": "h2.product-title",
"attribute": "text",
"type": "string",
"fallbacks": ["h3.title", ".product-name"]
},
{
"name": "price",
"selector": "span.price",
"attribute": "text",
"transform": "parse_price",
"type": "float"
}
],
"pagination": {
"type": "next_link",
"selector": "a.next-page",
"has_more": "a.next-page"
},
"confidence": 0.95,
"fingerprint": "a8f3e2b1..."
}
container_selector scopes extraction to a specific page section. This is critical for pages with multiple similar data tables (e.g. "Top Models" and "Market Share" on the same page both using div.grid items). The LLM uses HTML id attributes (e.g. #market-share) and section headings to pick the right container.item_selector matches each repeating data item within the container.text, href, src, or any HTML attribute), an optional transform (parse_price, parse_date, trim, parse_int, parse_float), and fallback selectors for resilience.Extract page
│
├── All fields populated ──────────── continue
│
├── Some fields empty (< 70%) ─────── re-derive strategy via LLM
│ └── use new strategy if it improves success rate
│
└── Total failure (0 items matched) ── re-derive strategy via LLM
└── resume with new strategy
When a site redesigns, the structural fingerprint changes, the cached strategy is bypassed, and trawl automatically derives a new one. No manual intervention needed.
Many modern sites render content with JavaScript or embed apps inside iframes. trawl handles both:
--js launches a headless Chromium browser (auto-downloaded on first use via rod) to render the page before extraction..animate-pulse, [class*="skeleton"]) are resolved. This ensures data-heavy SPAs built with React, Next.js, Vue, etc. are fully rendered.--wait adds extra wait time after all automatic detection for edge cases (e.g. --wait 5s).--js.# JS-rendered SPA
trawl "https://example.com/react-app" --fields "name, value" --js
# Iframe-embedded app with extra wait
trawl "https://huggingface.co/spaces/open-llm-leaderboard/open_llm_leaderboard" \
--fields "rank, model, average" --js --wait 10s
If trawl detects that a page appears to be JavaScript-rendered but --js wasn't used, it will suggest adding it in the error message.
Pages often contain multiple data tables or lists (e.g. "Top Models", "Market Share", "Top Apps" on a rankings page). trawl handles this through:
id attributes for context.--query is provided, regions whose section heading or id matches the query are prioritized (e.g. --query "Market Share" matches id="market-share").container_selector (preferring #section-id selectors) to scope extraction to the correct section.# Target the "Market Share" section specifically
trawl "https://openrouter.ai/rankings" --query "Market Share" --fields "rank, name, tokens" --js
# Target the main leaderboard
trawl "https://openrouter.ai/rankings" --query "LLM Leaderboard" --fields "rank, name, tokens" --js
Use --plan to see what trawl will do without extracting:
$ trawl "https://example.com/products" --fields "name, price" --plan
Strategy for https://example.com/products
Container: #product-list
Item selector: div.product-card
Fields:
name: h2.product-title -> text (string)
price: span.price -> text -> parse_price (float)
Pagination: a.next-page -> href (next_link)
Confidence: 0.95
Fingerprint: a8f3e2b1
Items found: 24
For complex or recurring extractions, define a YAML schema:
```yaml name: product_listing url: "https://example.com/products/*" fields: - name: product_name type: string description: "The product's display name" - name: price type: float description: "Price in local currency" - name: currency type: string
$ claude mcp add trawl \
-- python -m otcore.mcp_server <graph>