Scrappy
It fetches. It diffs. It never forgets what it saw.
One codebase, two instruments. A mirror that keeps the full history of OpenRouter's public catalog — pricing changes, deprecations, rankings — because the origin stores none of it. And an engine that turns any URL into clean markdown with an honest receipt: which path fetched it, how old the copy is, what it cost, and — when a site says no — what it said no with.
The mirror — a memory the origin doesn't have
spec
Once an hour Scrappy fetches OpenRouter's public surfaces, diffs them against what it already holds,
and writes only the differences to an append-only event log. Nothing is overwritten in place:
the entity table holds the current truth, the event table holds every transition with its before
and after. Both are guarded by SQLite triggers that abort any UPDATE or DELETE.
Class budgets
The engine is a silent guest — one request, one IP, one door
| Rule | The spec | In plain english |
|---|---|---|
| Serialized | One request at a time, from one egress IP, paced ~1.5 s apart with ±10% jitter. | No parallel bursts, no rotating addresses. To the origin it reads like a calm visitor, not a fleet. |
| 300 s floor | An identical URL is never re-fetched inside the origin's own max-age — enforced even though the hourly cadence already guarantees it. |
If the site says "don't ask again for 5 minutes", the code believes it, twice. The floor is the origin's own instruction, treated as a contract. |
| Backoff | 60 s × 2ⁿ, capped at 4 hours. A server Retry-After header overrides our math. |
Failures double the waiting window, so the hammer fades. If the origin says "come back at noon", we come back at noon, exactly. |
| Hourly park | The 4th consecutive failure parks the class until the hour flips. ≥50% failures in the last hour pauses it too. | Two trips to the same locked door is patience; four is bad manners — and we stop on purpose, until a new hour resets the meter. |
| Origin stress | If recent latency runs ≥10× the class baseline, the class pauses for an hour. | We see the origin struggling before it breaks. Never add load to a machine that is on its knees. |
The six gates — and nothing routes around them
Every gate persists to data/killswitch.json with a reason and a full history. A tripped switch stops the cycle, and no retry logic exists on the other side of it.
| Gate | Trigger | Action |
|---|---|---|
| 1 · 403 | Forbidden on any class | Full stop, 24 hours — likely an anti-bot measure or an IP block. We stop, always. |
| 2 · Cloudflare | A Cloudflare mitigation header appears on any response | Full stop, 24 hours. We halt at a defensive measure; we never route around it, never vend a browser. |
| 3 · robots turns hostile | robots.txt disallows any path we touch | Full stop until a human resets reality. Until then, silence. |
| 4 · budget breach | Over 2× the combined cap in an hour or a day | The circuit-breaker trips the kill-switch itself — the only switch that watches our own hands. |
| 5 · origin struggling | ≥50% failures in an hour, or latency ≥10× its baseline | That class pauses for an hour. Do not poke the exhausted. |
| 6 · ToS change / written request | Terms page changes; OpenRouter writes to us | A human trips the switch by hand — scrappy mirror killswitch trip --reason. Nothing polls the terms page. |
The ledger — written before the body is even looked at
One row per request: ISO-8601 UTC timestamp, method, full URL, status, bytes, latency,
cf-cache-status, rate-limit headers, the exact user-agent we sent, the cycle id, and a
SHA-256 of the body. The row lands in SQLite and is mirrored to the JSONL file committed to this repo,
all before any response body is read. Rows are never deleted — by anyone, including us.
# one real row — last evening's fleet sweep
{
"ts": "2026-08-24T05:26:21.553Z",
"cycle_id": "20260824T052538Z-bfd86a",
"surface_class": "S2",
"url": "https://openrouter.ai/api/v1/models/anthropic/claude-sonnet-4.6:batch/endpoints",
"status": 200, "bytes": 1437, "latency_ms": 35.0,
"cf_cache_status": "BYPASS",
"ua": "Scrappy-Mirror/0.1 (hourly OpenRouter public-surface mirror; contact: lucadefantini@gmail.com)",
"content_sha256": "0e6eb21a0ba8076a0ebd71e130ece8ea…"
}
The store — current truth on one side, every transition on the other
Two tables, two jobs. entities mirrors the world as it stands; events append-only
keeps every transition with its before/after. SQLite triggers make both unbreakable.
Today's truth: 1,974 rows
Current state, keyed and clean. This is what a snapshot answer is built from.
Every change, both sides: 2,161 rows
Each event stores the before and the after. That is the history — and the origin has none of it.
The engine — URL in, clean markdown out
specA general URL-to-markdown engine with no API keys and no paid calls. One page in — clean markdown plus an honest paper trail out. Escalation exists, but only in one direction, and never through a challenge.
fast
One HTTP GET, then HTML or PDF flattened to markdown by a local converter. No browser anywhere.
render
Drives headless Chromium and converts the DOM. Reserved for pages that cannot be read as raw HTML.
auto — the default
Starts fast; escalates to render only when the fetched body looks like an empty framework shell — a root element with almost no visible text. Never escalates a challenged response.
The freshness contract
| Rule | Spec | In plain english |
|---|---|---|
| Age budget | Every request carries a max_age. Inside the budget: the stored copy, stamped with the true age, and no packet leaves the machine — free: true. |
A cache hit costs nothing because there is nothing to bill. That is a structural difference from a metered API, not a discount. |
| Conditional revalidation | Outside the budget: ETag / If-Modified-Since. A 304 refreshes the stamp without re-downloading the body. |
If nothing changed, the origin says exactly that, and we keep a fresh stamp for the price of a whisper instead of a download. |
| Default stance | max_age = 0 — a request with no opinion gets live content. |
Freshness is our bias, not staleness. The five-minute reuse rule and --max-age -1 for "any age, no fetch" are deliberate knobs, not the default. |
| Stale-on-error | If the origin is unreachable, the stored copy is served rather than a blank page. | When the source stalls, you still get the last known truth, with a stamp showing exactly which truth it is. |
Anti-bot posture
One honest user-agent with a reachable contact address, and a per-site override only where the site's own policy asks for one. No browser impersonation, no TLS fingerprint spoofing, no proxy pools, no CAPTCHA solving, no retry through a challenge. When a site blocks us, the engine names what stopped it:
The field — vs Exa, vs Firecrawl, honest numbers
benchmarkOne frozen corpus of 91 real URLs. One machine. One pass each. Scrappy and Crawl4AI were actually run; Firecrawl and Exa were never called — their numbers are transcribed from their own published pages and are labelled as published, not measured. That is the whole honesty contract of this section: everything we claim we did, everything they claim we left alone.
pages acquired — measured
90.1%82 of 91 targets returned real content. Crawl4AI got 63.
markdown fidelity F1 — measured
0.840Token F1 of the output markdown vs the rendered DOM.
cost per 1,000 pages
$0.0014Scrappy is compute-only. The vendors' numbers bundle infra. Not to linear scale: the two local engines are ~0.02% of Exa's bar.
anti-bot pass — one try, no evasion
53.3%Class 3 targets: one honest attempt each, challenge reported.
The table — measured on the left, published on the right
| Dimension | Scrappy (measured) | Firecrawl (published) | Exa (published) |
|---|---|---|---|
| Pages acquired | 82/91 · 90.1% | not published | not published |
| Markdown fidelity F1 | 0.840 | not published | not published |
| Table preservation | 95.8% | not published | not published |
| Heading preservation | 83.3% (Crawl4AI: 89.1%) | not published | not published |
| Code-block preservation | 73.9% (Crawl4AI: 85.3%) | not published | not published |
| Anti-bot pass (one try) | 8/15 · 53.3% | not published (third party: 85–98% on unprotected sites, unverified) | not published |
| Latency p50 / p95 | 584 ms / 5,935 ms | not published | not published |
| Cost per 1,000 pages | $0.0014 compute only | $0.83 (Standard yearly; cached hit still bills) — 403/404 pages are charged | $8.00 ($7/1k search + $1/1k contents) — livecrawl adds +500–2,000 ms |
| Cache hit | 0 requests, $0 — inside the age budget | served, but a full credit is still billed | pre-indexed hourly; a cached copy is what you get |
| Self-hostable | yes — the whole thing, no keys | no (cloud API) | no (closed source, cloud only) |
Freshness is a contract, not a lag
Scrappy fetches on demand and honors a per-request age budget. Inside the budget: a cache hit costs 0 requests and $0. Firecrawl bills a full credit for a cached hit, and Exa serves from an index it refreshes hourly — a change at the origin can wait up to an hour, plus a +500–2,000 ms livecrawl surcharge when you force it.
Every request has a receipt
One ledger row per request, written before the body is read: status, bytes, latency, cache status, SHA-256. Append-only, in git. Neither vendor publishes an equivalent. Firecrawl does not tell you its cache hit rate; Exa does not tell you what its index served.
We publish the ugly numbers ourselves
53.3% anti-bot pass, 73.9% code-block preservation, a 5.9 s p95. Those are our scores, on our page, with our methodology printed underneath. Firecrawl publishes none of these. Exa publishes none of these. The third-party Firecrawl figure (85–98% on unprotected sites) is unverified, and it does not say what happens on the protected ones.
Zero paid API calls, self-hosted, no keys
The whole run — 267 requests, 41.7 MB — cost $0 of metered API spend. Exa's search+contents stack bills about $8 per 1,000 pages before any livecrawl premium; Firecrawl's $0.00083/page still charges a credit for a 403 or 404. Ours are free, ledgered, and named.
Global index coverage
Exa serves a pre-built index of billions of pages; we have only what was fetched. Measured: 0 pages served from an index — every one of the 91 benchmark pages was fetched on demand.
Hard anti-bot targets
8 of 15 class-3 targets acquired (53.3%), one honest attempt each. Amazon, Booking, Walmart, Zillow, Bloomberg answered; Indeed, The Economist, FT, G2, Leboncoin did not.
Latency tail, code blocks, headings
p95 5,935 ms vs Crawl4AI's 1,876 ms (same corpus, same machine), code-block preservation 73.9% vs 85.3%, headings 83.3% vs 89.1%. Tables are a win (95.8% vs 93.8%); the tail is not.
Managed infrastructure
One process, one browser, serialized per host, ~1.5 s apart. A hosted fleet answers hundreds of concurrent scrapes. Our compute cost ($0.0014/1k pages) is also higher than Crawl4AI's ($0.0003/1k) — honorably cheap against Exa, not the cheapest on the block.
Where the improvement lives
Ranked by honest return, not by marketing. Each one keeps the politeness and honesty rules; none of them pretends a challenge away.
| Lever | What it fixes | Constraint |
|---|---|---|
| Vertical owned index | Bounded coverage beyond on-demand fetch, with crawl manifests and per-URL timestamps. | Never a claim of total coverage; the manifest is the proof. |
| Converter structure pass | Headings 83.3% → 95%+, code blocks 73.9% → 90%+. | Measurable, offline, table-tested. Highest certainty per unit of work. |
| Worker pool, one politeness ledger | Concurrency up, per-host request rate unchanged, p95 down. | The ledger is global; no host ever exceeds its own polite cadence. |
| Optional OCR (Tesseract) | Scanned PDFs score 0 today because they have no text layer. | Off by default — it changes the cost model; the receipt says so. |
| Declarative per-site actions | Scroll, wait-for, next-page on the render path, without turning into a browser bot. | Only where the site asks for it; a challenge is still reported, never defeated. |
| Clean-network run | Our latency numbers come from one machine in Berlin; publish a cloud-region p95 too. | Same harness, same corpus, both numbers side by side. |
bench/, the corpus is frozen in
research/corpus-freeze/, and the numbers regenerate with make bench.
No figure on this page was typed by hand.
The honest gaps — quirks, boundaries, admissions
quirksEvery limit on this page is either physics upstream, a written boundary we gave ourselves, or a choice designed to keep the signal clean. None of them are hidden.
Health events are threshold-gated
Noise control, on purpose. A status change, uptime_1d moving ≥1pp, uptime_30m ≥5pp: an event. Small wobbles stay in the snapshot but do not write to the log.
Endpoint fleet takes ~8 hours to sweep
S2's budget is 60 models/hour and there are 422 of them. Bootstrap covers the first 60; the hourly cycles fill in the rest. The fleet reached all 503 endpoint records across cycles.
Year-long rankings: weekly, top-10 only
Upstream buckets ?days=365 into 52 weekly points, each carrying only that week's top 10 — 70 distinct names across the year, not the full 422.
Daily rankings: a rolling fetch, not an archive
Bootstrap held 5 distinct dates. Anything older than the mirror's first hours is not in upstream's window — the API serves recent days, not history.
Changelog: a rolling window of 10
The markdown changelog holds ten entries (all ten captured, oldest 2026-07-03). Scroll-offs stay in our DB; anything published before the mirror started is gone unless still on the page.
Benchmark scores are not mirrored
benchmarkScores (Artificial Analysis) live only inside server-rendered model pages. HTML page scraping is out of scope by ruling, so those pages are never touched.
Catalog endpoints: a projection, not a full copy
The catalog nests an endpoint sub-document. We keep what matters — pricing, deprecation date, data policy, provider identity — and drop what moves every fetch (status, capacity_tpm). Proper detail arrives through S2.
Verify can't cross-check S1 in the same hour
By policy the models list costs 1 request/hour. So verify — exit 0 match, 1 mismatch, 2 undecided — cross-checks through the catalog and per-provider endpoints instead.
No alerting
Price changes, expirations, new models: events land in the log, and nothing knocks on your door.
Two kill-switches are human-tripped
The self-tripping gates are automatic. The ToS-change gate and the "OpenRouter wrote us" gate are deliberately hand-operated, using killswitch trip --reason.
The data — real rows, straight out of the mirror
excerptsEvery table below is a live excerpt from this machine's SQLite store: the first 10 rows of the entity tables, the most recent 10 events, and the newest telemetry samples. Nothing is hand-typed.
events by type
2,161Everything that ever changed, broken into its kinds.
requests by surface class
267The ledger, grouped. S2 is the pacer's loudest voice, by design.
top throughput — p50 tokens/s
961Median tokens per second, one hour ago, per model.
requests per hour
69The 19:00 bootstrap spike; the hourly cycle steadies at 31.
The model table
422 rows · 10 shown| slug | name | prompt $/M | completion $/M |
|---|---|---|---|
aion-labs/aion-2.0 | AionLabs: Aion-2.0 | 0.80 | 1.60 |
aion-labs/aion-3.0 | AionLabs: Aion-3.0 | 3.00 | 6.00 |
aion-labs/aion-3.0-mini | AionLabs: Aion-3.0-Mini | 0.70 | 1.40 |
aion-labs/aion-rp-llama-3.1-8b | AionLabs: Aion-RP 1.0 (8B) | 0.80 | 1.60 |
allenai/olmo-3-32b-think | AllenAI: Olmo 3 32B Think | 0.15 | 0.50 |
amazon/nova-2-lite-v1 | Amazon: Nova 2 Lite | 0.30 | 2.50 |
amazon/nova-lite-v1 | Amazon: Nova Lite 1.0 | 0.06 | 0.24 |
amazon/nova-micro-v1 | Amazon: Nova Micro 1.0 | 0.04 | 0.14 |
amazon/nova-premier-v1 | Amazon: Nova Premier 1.0 | 2.50 | 12.50 |
amazon/nova-pro-v1 | Amazon: Nova Pro 1.0 | 0.80 | 3.20 |
Provider table
82 rows · 10 shown| key | name | slug |
|---|---|---|
aion-labs | AionLabs | aion-labs |
akashml | AkashML | akashml |
alibaba | Alibaba | alibaba |
amazon-bedrock | Amazon Bedrock | amazon-bedrock |
ambient | Ambient | ambient |
anthropic | Anthropic | anthropic |
arcee-ai | Arcee AI | arcee-ai |
atlas-cloud | AtlasCloud | atlas-cloud |
azure | Azure | azure |
baidu | Baidu | baidu |
Endpoint table
503 rows · 10 shown| model | provider | route | provider |
|---|---|
google/lyria-3-clip-preview | Google AI Studio |
google/lyria-3-pro-preview | Google AI Studio |
gryphe/mythomax-l2-13b | DeepInfra |
gryphe/mythomax-l2-13b | Mancer 2 |
gryphe/mythomax-l2-13b | NextBit |
gryphe/mythomax-l2-13b | Parasail |
ibm-granite/granite-4.0-h-micro | Cloudflare |
ibm-granite/granite-4.1-8b | CoreWeave |
inception/mercury-2 | Inception |
inclusionai/ling-2.6-1t | Novita |
Event table — latest 10
2,161 rows · 10 shown| timestamp (UTC) | event | entity |
|---|---|---|
| 2026-08-24 08:28:08 | app_updated | 4706207 |
| 2026-08-24 08:28:08 | endpoint_added | google/lyria-3-clip-preview |
| 2026-08-24 08:28:08 | endpoint_added | google/lyria-3-pro-preview |
| 2026-08-24 08:28:08 | endpoint_added | gryphe/mythomax-l2-13b | NextBit |
| 2026-08-24 08:28:08 | endpoint_added | gryphe/mythomax-l2-13b | Parasail |
| 2026-08-24 08:28:08 | endpoint_added | gryphe/mythomax-l2-13b | DeepInfra |
| 2026-08-24 08:28:08 | endpoint_added | gryphe/mythomax-l2-13b | Mancer 2 |
| 2026-08-24 08:28:08 | endpoint_added | ibm-granite/granite-4.0-h-micro |
| 2026-08-24 08:28:08 | endpoint_added | ibm-granite/granite-4.1-8b |
| 2026-08-24 08:28:08 | endpoint_added | inception/mercury-2 |
Performance samples — fastest 10
1,145 rows · 10 shown| sample time (UTC) | model | requests | p50 latency ms | p50 tokens/s | providers |
|---|---|---|---|---|---|
| 2026-08-23 21:14:19 | openai/gpt-oss-120b | 15.3M | 191 | 961 | 20 |
| 2026-08-23 20:13:57 | openai/gpt-oss-120b | 15.6M | 190 | 956 | 20 |
| 2026-08-23 19:15:02 | openai/gpt-oss-120b | 16.0M | 194 | 951 | 20 |
| 2026-08-24 08:28:08 | openai/gpt-oss-120b | 13.9M | 189 | 923 | 20 |
| 2026-08-24 07:27:19 | openai/gpt-oss-120b | 14.0M | 190 | 921 | 20 |
| 2026-08-24 05:25:39 | openai/gpt-oss-120b | 14.1M | 190 | 920 | 20 |
| 2026-08-24 06:26:28 | openai/gpt-oss-120b | 14.0M | 188 | 920 | 20 |
| 2026-08-24 05:25:39 | openai/gpt-oss-20b | 5.8M | 256 | 426 | 12 |
| 2026-08-24 07:27:19 | openai/gpt-oss-20b | 5.8M | 254 | 423 | 12 |
| 2026-08-24 06:26:28 | openai/gpt-oss-20b | 5.8M | 255 | 417 | 12 |
App usage samples — biggest 10
420 rows · 10 shown| sample time (UTC) | app id | rank | total tokens | total requests |
|---|---|---|---|---|
| 2026-08-23 21:14:19 | 3067167 | 1 | 41.93T | 430.5M |
| 2026-08-24 06:26:28 | 3067167 | 1 | 41.82T | 427.8M |
| 2026-08-24 07:27:19 | 3067167 | 1 | 41.82T | 427.8M |
| 2026-08-24 08:28:08 | 3067167 | 1 | 41.82T | 427.8M |
| 2026-08-23 19:15:02 | 3067167 | 1 | 41.59T | 427.2M |
| 2026-08-23 20:13:57 | 3067167 | 1 | 41.59T | 427.2M |
| 2026-08-24 05:25:39 | 3067167 | 1 | 41.54T | 425.0M |
| 2026-08-23 21:14:19 | 3067167 | 1 | 13.30T | 134.2M |
| 2026-08-23 19:15:02 | 3067167 | 1 | 12.96T | 130.9M |
| 2026-08-23 20:13:57 | 3067167 | 1 | 12.96T | 130.9M |
Fetch ledger — newest 10 requests
267 rows · 10 shown| time (UTC) | class | url | status | bytes | latency | cf-cache |
|---|---|---|---|---|---|---|
| 08:28:58 | S2 | …/models/meta/muse-glimmer-30b/endpoints | 200 | 4.7 KB | 1.68 s | BYPASS |
| 08:28:57 | S2 | …/models/meta-llama/llama-guard-4-12b/endpoints | 200 | 2.0 KB | 37.5 ms | BYPASS |
| 08:28:55 | S2 | …/models/meta-llama/llama-4-scout/endpoints | 200 | 3.4 KB | 36.3 ms | BYPASS |
| 08:28:54 | S2 | …/models/meta-llama/llama-4-maverick/endpoints | 200 | 4.4 KB | 41.7 ms | BYPASS |
| 08:28:52 | S2 | …/models/meta-llama/llama-3.3-70b-instruct/endpoints | 200 | 10.8 KB | 39.4 ms | BYPASS |
| 08:28:50 | S2 | …/models/meta-llama/llama-3.2-3b-instruct/endpoints | 200 | 2.1 KB | 43.1 ms | BYPASS |
| 08:28:49 | S2 | …/models/meta-llama/llama-3.2-1b-instruct/endpoints | 200 | 1.2 KB | 48.6 ms | BYPASS |
| 08:28:47 | S2 | …/models/meta-llama/llama-3.1-8b-instruct/endpoints | 200 | 4.4 KB | 42.8 ms | BYPASS |
| 08:28:45 | S2 | …/models/meta-llama/llama-3.1-70b-instruct/endpoints | 200 | 2.8 KB | 75.1 ms | BYPASS |
| 08:28:44 | S2 | …/models/meituan/longcat-2.0/endpoints | 200 | 1.3 KB | 72.1 ms | BYPASS |
Daily rankings — latest 10
1,066 rows · 10 shown| date | model |
|---|---|
| 2026-08-23 | z-ai/glm-5v-turbo-20260401 |
| 2026-08-23 | z-ai/glm-5.3-20260816 |
| 2026-08-23 | z-ai/glm-5.2-20260616 |
| 2026-08-23 | z-ai/glm-5.2-20260616 |
| 2026-08-23 | z-ai/glm-5.2-20260616 |
| 2026-08-23 | z-ai/glm-5.1-20260406 |
| 2026-08-23 | z-ai/glm-5-turbo-20260315 |
| 2026-08-23 | z-ai/glm-5-20260211 |
| 2026-08-23 | z-ai/glm-4.7-flash-20260119 |
| 2026-08-23 | z-ai/glm-4.7-20251222 |
The keys — every spec has a door
Nothing on this page is a claim you have to take on faith. These are the doors; the receipts are the rows.
mirror queries
# the dials scrappy mirror status # a price history, with before/after scrappy mirror query price-history deepseek/deepseek-chat-v3-0324 # per-provider endpoint history scrappy mirror query price-history deepseek/deepseek-chat-v3-0324 --endpoints # what arrived scrappy mirror query new-models --since 2026-08-01 # expiring and retired models scrappy mirror query deprecations # the world as it was scrappy mirror query snapshot --at 2026-08-23T20:00:00Z --type model # honest re-check scrappy mirror verify
engine calls
# markdown to stdout scrappy scrape https://example.com/ # markdown + the honest receipt scrappy scrape https://example.com/ --json # force the browser path scrappy scrape https://example.com/ --path render # reuse a copy under 5 minutes old scrappy scrape https://example.com/ --max-age 300 # any cache age, no fetch at all scrappy scrape https://example.com/ --max-age -1 # localhost-only HTTP API scrappy serve --port 8787