/Extraction quality

Kerf Online against TinyFish, on 76 addresses

We called both providers on the same fixed set of pages, saved every answer before scoring any of it, and scored against each page's own HTML rather than asking a model. Here is the whole run: why it matters, what we compared, how it was measured, every per-address result, and what the numbers do not show.

Quorum Technologies · run 12 September 2026

Addresses76 scored
Set frozen12 September 2026
Walled5 reported apart
Dropped1
Judgenone

01

A table that arrives as a paragraph has lost the thing that made it a table

Whatever reads a page next, a model or an index, only ever sees what the extractor handed it. Everything after that is downstream of one decision nobody watches.

A pricing table is a grid before it is text. Every figure in it means what it means because of the row and the column it sits in. Flatten it into prose and the numbers survive while the thing that made them answerable does not: ask which tier includes SSO and the model is now guessing from a list of words in a row.

The same is true of a heading. A heading is not decoration, it is the address of a section. Drop it and a retrieval index has nothing to cite and a reader has nothing to jump to, while the prose underneath still reads perfectly well, so nobody notices until an answer comes back wrong.

Why not just measure how clean the output is? Because a cleanliness score rewards throwing things away. An extractor that returns the first three paragraphs of every page scores beautifully on noise and is useless. Anything worth measuring has to count what arrived as well as what did not, which is why the two figures below it are headings kept and headings in order.

02

What we compared

Two products that turn a URL into Markdown. Each was called through its own public API at its own defaults, with no tuning on either side and no retries.

ProviderCalled asHow it works
Kerf OnlinePOST /v1/renderOurs. Renders the page, measures the boxes the browser drew, and reads them by layout rather than by source order. Reads most pages straight from the markup and only opens a browser when the page needs one.
TinyFishFetch APIBrowser-backed rendering. Loads pages in a real browser, strips non-content elements and returns Markdown. Described here as they describe themselves.

We picked TinyFish because they publish their own evaluation of this exact problem, with their method and their URL list in the open. That is rarer than it should be, and it makes a comparison possible at all. The 1,000-character floor below is theirs, kept so the coverage figure means the same thing in both places.

03

The test set

76 addresses across ten kinds of page, plus five behind a bot wall that are reported on their own.

Every address was chosen to still resolve to the same document next year: versioned documentation, published standards, licences, frozen encyclopaedia subjects, and essays nobody edits after publishing. A set built from dated news articles ages out within months, and a benchmark that cannot be run again says nothing about whether anything improved.

CategorynWhat is in it
documentation10API references and guides. Dense prose, code samples, deep heading trees.
reference8Encyclopaedic pages. Long, heavily sectioned, full of tables and footnotes.
standards8Published specifications. Never edited once issued, and numbered throughout.
pricing8Tiers and feature grids. The shape a flattening reader destroys.
repositories8READMEs inside heavy application chrome.
longform12Essays nobody edits after publishing.
spa8Drawn entirely in JavaScript. The pages a fetch without a browser loses.
tables6Pages whose whole point is a table.
legal6Licences and terms. Structured, numbered, never rewritten.
simple2Almost no markup at all. The floor case.

Counts are the addresses that were scored. The five walled addresses are listed separately below.

Were the addresses chosen before anyone was called? Yes, and the harness enforces it rather than asking you to take our word. A freeze step checks every address answers and writes the list to a file with a timestamp; the runner reads only that file. An address cannot be swapped once somebody has seen a result.

04

How we score

Against each page's own HTML, so reading order and structure are measured rather than judged. No model marks this homework, which is what lets anyone run it twice and get the same answer.

MeasureWhat it counts
Pages returnedAt least 1,000 characters came back. Anything shorter is a stub, not an extraction.
Headings keptHow many of the page's own headings arrived at all. This is the measure that punishes throwing content away.
Headings in orderOf every pair of headings the page put in an order, how many came back in that order.
Tables keptOn pages whose source holds a table, whether a table came back rather than prose.
Code keptOn pages whose source holds a <pre> block, whether code came back as code.
CharactersHow much came back. Reported beside the noise count, because either alone can be gamed.
TimeWall clock as the caller sees it, including the render where one was needed.

Why 1,000 characters? It is TinyFish's own floor, taken from their published evaluation and kept so that the coverage figure means the same thing in both places. It exists because some providers report success while returning a truncated excerpt.

Was any threshold chosen to flatter anybody? No. The floor is inherited, and every other measure is a count with no threshold to tune. The one judgement call, that only a <pre> block counts as code and inline <code> does not, was made against our own interest: it was found because the first scoring run showed us losing on code retention, and looking at why showed the other provider wrapping single words like title in fenced blocks and being credited for it.

05

What came back

Every figure below is produced by the scorer in the kerf repository and nothing on this page is typed by hand.

MeasureKerfTinyFishWhat it means
Pages returned76/7675/76At least 1,000 characters came back.
Headings kept90%76%Share of the page's own headings that arrived at all.
Headings in order100%100%Of every pair the page ordered, how many survived in that order.
Tables kept as tables28/298/29Of the pages whose source holds a table.
Code kept as code38/3836/38Of the pages whose source holds a <pre> block.
Median characters19,65418,008More of the document came back, at the same measured noise.
Median time a page587ms1,724msWall clock, as the caller sees it. Less is better.

Median figures across 76 scored addresses. ● marks the better of the two.

05b

By kind of page

Pages returned, per category. The set is built so no one kind of page can carry the result.

CategoryKerfTinyFishWhat is in it
documentation10/1010/10API references and guides. Dense prose, code samples, deep heading trees.
reference8/88/8Encyclopaedic pages. Long, heavily sectioned, full of tables and footnotes.
standards8/88/8Published specifications. Never edited once issued, and numbered throughout.
pricing8/88/8Tiers and feature grids. The shape a flattening reader destroys.
repositories8/88/8READMEs inside heavy application chrome.
longform12/1212/12Essays nobody edits after publishing.
spa8/88/8Drawn entirely in JavaScript. The pages a fetch without a browser loses.
tables6/65/6Pages whose whole point is a table.
legal6/66/6Licences and terms. Structured, numbered, never rewritten.
simple2/22/2Almost no markup at all. The floor case.

06

One page in detail

A summary table can hide what a difference actually looks like, so here is a single address, start to finish.

RFC 9110, the HTTP semantics specification. A long, heavily numbered document: the kind of page an agent is asked about precisely because nobody wants to read it.

rfc-editor.org/rfc/rfc9110.htmlKerfTinyFish
Characters returned442,5875,325
Headings kept100%1%
Table kept as a table
Code kept as code
Time1,306ms3,814ms

One address, both providers, from the same run.

Both fetches count as successful. Both clear the character floor, and a coverage-only scoreboard would record one point each. But one of them contains the specification and the other contains a fragment of it: the shorter answer is section 6.4 and its two subsections, and nothing before or after them.

That is what “headings kept” is for. Ask either result which request methods are idempotent, which the specification answers in section 9.2.2, and only one of them contains that section. Nothing about the character count on its own would have warned you which.

07

Every address, and what each one returned

The whole dataset. Characters returned, time taken, whether the page cleared the floor, how much of its heading structure survived, and whether a table stayed a table.

AddressK charsT charsK msT msK headingsT headingsK tblT tbl
doc.rust-lang.org/std/vec/struct.Vec.html227,993206,5811,0776,0416%4%
docs.python.org/3.12/library/asyncio-task.html38,70240,8833631,99173%73%
docs.python.org/3.12/library/pathlib.html44,41145,2873982,77577%77%
docs.stripe.com/webhooks24,61323,2771,1521,59791%91%
fastapi.tiangolo.com/tutorial/background-tasks12,0717,3272699950%0%
nodejs.org/docs/latest-v22.x/api/fs.html194,131220,4431,1115,540100%93%
pkg.go.dev/net/http163,988146,4961,1533,57899%88%
playwright.dev/docs/locators24,40822,6223442,247100%100%
www.postgresql.org/docs/16/datatype-json.html30,20730,1987042,16911%0%
www.postgresql.org/docs/16/sql-select.html63,28171,9003762,908100%0%
creativecommons.org/licenses/by-sa/4.0/legalcode.en18,98918,1532029,51290%79%
gdpr-info.eu/art-17-gdpr2,9912,7111611,447
opensource.org/license/mit1,3221,0665,6371,20133%0%
policies.google.com/terms36,73928,3511,0491,47391%91%
www.apache.org/licenses/LICENSE-2.011,1569,2931651,36950%0%
www.gnu.org/licenses/gpl-3.0.en.html35,58435,0918341,263100%100%
blog.codinghorror.com/the-best-code-is-no-code-at-all9,3524,18717094079%0%
blog.regehr.org/archives/21343,02121,5677102,90877%69%
danluu.com/everything-is-broken19,64717,8633,5601,469100%100%
go.dev/blog/loopvar-preview7,7547,8272571,247100%100%
martinfowler.com/articles/microservices.html50,06341,5705891,41688%54%
martinfowler.com/bliki/TechnicalDebt.html7,2766,28940592460%40%
overreacted.io/a-complete-guide-to-useeffect65,53265,0813952,301100%100%
www.joelonsoftware.com/2000/04/06/things-you-should-ne8,9698,37410575560%20%
www.kalzumeus.com/2012/01/23/salary-negotiation41,65340,0895,2691,45789%61%
www.paulgraham.com/ds.html25,24025,215563778
www.paulgraham.com/makersschedule.html6,8596,5756211,740
www.rfc-editor.org/rfc/rfc1925.html4,3704,2043,279824
github.com/pricing24,38520,8096692,88678%63%
linear.app/pricing2,6152,4248,8632,004
stripe.com/gb/pricing24,59321,8115651,93686%3%
supabase.com/pricing19,66017,8212221,97187%87%
vercel.com/pricing13,26712,7533612,42150%38%
www.atlassian.com/software/jira/pricing14,0654,1695,76718,810
www.cloudflare.com/plans6,2874,2506882,03156%44%
www.notion.com/pricing21,75721,5773261,85363%75%
developer.mozilla.org/en-US/docs/Web/API/Fetch_API3,0342,764230880100%75%
developer.mozilla.org/en-US/docs/Web/CSS/Reference/Pro9,5438,9732671,483100%75%
developer.mozilla.org/en-US/docs/Web/HTTP/Reference/St14,23814,459186814100%78%
en.wikipedia.org/wiki/Dot-com_bubble53,98727,6313602,526100%100%
en.wikipedia.org/wiki/List_of_countries_by_GDP_(nomina31,88119,4853183,197
en.wikipedia.org/wiki/PageRank55,13760,5154072,410100%100%
en.wikipedia.org/wiki/Transmission_Control_Protocol103,44095,1505312,941100%97%
en.wikipedia.org/wiki/Web_scraping30,11327,7842461,510100%92%
github.com/curl/curl8,0632,3715,1651,48058%67%
github.com/microsoft/playwright11,9738,6696671,48191%81%
github.com/openssl/openssl10,4535,6297311,63590%80%
github.com/pallets/flask5,4012,1375,0371,46967%83%
github.com/postgres/postgres5,3241,8534,3221,68950%80%
github.com/rust-lang/rust8,1952,4164,9032,74869%69%
github.com/sqlite/sqlite21,39220,2577431,30990%90%
github.com/vercel/next.js12,9052,3776,4201,74069%69%
motherfuckingwebsite.com3,3822,7365541,14088%0%
text.npr.org1,5641,5041,444792
astro.build9,0894,7232271,92591%91%
bun.sh/docs/installation7,0775,3463191,349100%100%
caniuse.com/css-grid2,4652,1171,2871,09892%83%
nextjs.org/docs/app/getting-started/installation18,58911,2703201,14377%77%
react.dev/learn/thinking-in-react19,49621,2156,2421,70890%90%
tailwindcss.com8,0826,0764791,53778%78%
vite.dev/guide9,1618,8401531,80783%83%
www.figma.com/pricing29,83720,5956391,99281%15%
datatracker.ietf.org/doc/html/rfc7231239,452218,3285931,908100%1%
html.spec.whatwg.org/multipage/introduction.html54,22947,6635,1012,171100%0%
semver.org17,74915,5251471,121100%0%
www.rfc-editor.org/rfc/rfc6749.html164,085162,0286,9381,975
www.rfc-editor.org/rfc/rfc8259.html28,52728,0863,4591,170
www.rfc-editor.org/rfc/rfc9110.html442,5875,3251,3063,814100%1%
www.rfc-editor.org/rfc/rfc9309.html22,80122,6733134,369100%100%
www.w3.org/TR/WCAG22133,78594,2674622,45299%0%
books.toscrape.com3,7174006671,58283%6%
developer.mozilla.org/en-US/docs/Web/HTTP/Reference/He31,72232,5212701,179100%93%
en.wikipedia.org/wiki/Comparison_of_file_systems84,76481,1845854,710100%86%
en.wikipedia.org/wiki/List_of_countries_by_population_27,13619,8316782,495100%25%
unicode.org/emoji/charts/full-emoji-list.html136,26689,3292,39016,973
www.iana.org/assignments/http-status-codes4,4823,8481031,206

● kept · ○ lost · – the source has none. Order is blank where too few headings came back to say anything.

08

The five behind a wall

These answer 403 to anyone. They are reported here and counted nowhere: a provider that returns nothing because a site said no has not done anything wrong, and one that returns a paywall preview has not done anything right.

AddressKerfTinyFishKerf, asked again 12 September 2026
www.nytimes.com/2026/04/28/opinion/ezra-klein.htmlnothingnothing424 site_refused
www.bloomberg.com/markets12,04028311,581 characters
www.glassdoor.com/Reviews/index.htmnothing9,642424 site_refused
www.indeed.com/career-advice11,347nothing11,347 characters
www.crunchbase.com/organization/anthropicnothingnothing424 site_refused

Characters returned in the run, and what Kerf answers now. No column feeds any figure above.

In the run, three of Kerf's five came back as an HTTP 502 from our own edge. The site had refused, but the edge reported that as its own failure, and behind the CDN the refusal never reached the caller. That was our error, and the run records it as it happened. We fixed it and asked Kerf again on 12 September 2026: the same three now answer 424 site_refused, which names the site's answer instead of hiding it, and the other two still extract. Nothing in this table feeds any figure above, then or now.

09

What this does not show

Three steps, kept separate so that no result can change the set and no threshold can be chosen after the fact.

01

Freeze

Every address is checked and written to a file with a timestamp. The runner reads only that file, so an address cannot be swapped once somebody has seen a result.

02

Run

Each provider is called on each address and what comes back is saved verbatim, next to the page’s own HTML. Nothing is scored at this stage.

03

Score

The saved answers are read and measured. Thresholds are a judgement call, so scoring re-runs against the same files as often as the argument needs.

Everything is scored against each page's own HTML, so reading order and structure are measured rather than judged. No model marks this homework, which is what lets anyone run it twice and get the same answer.

One address was dropped. A page is removed only when the whole document, markup included, is smaller than the 1,000-character floor. example.com is 559 bytes end to end, so no extractor alive can pass it and both providers fail it however good they are. The rule keys on a property of the page rather than on anything either provider did. It was added after this run rather than before it, which is the weaker position, and it removed the same single row from both.

The run asks Kerf to skip its robots.txt check. Honouring robots.txt is the default on every other request Kerf makes. The other provider evidently does not honour it, and leaving ours on scored our own compliance as our failure on the pages that disallow a crawler. This measures extraction against extraction.

Heading order is only scored where at least three headings came back. Fewer than that and there are not enough pairs to say anything. That slightly flatters a provider which drops headings, because dropping them can take a page below the threshold instead of scoring it badly. Headings kept is the measure that catches exactly that, which is why both are reported and neither summarises the other.

The address list is new. An earlier run of this comparison was lost with the machine it lived on, so these figures are a fresh measurement rather than a continuation of it.

10

Do it yourself

The harness, the frozen address list and the scores are in the kerf repository.

node bench/headtohead/freeze.mjs
KERF_TOKEN=… TINYFISH_KEY=… node bench/headtohead/run.mjs
node bench/headtohead/score.mjs

Run 12 September 2026 against a set frozen 12 September 2026. TinyFish is called through its own public API at its own defaults; we have tuned nothing on either side and there were no retries. Naming another product is not a claim about anything except what it returned on these addresses on this day.