Blog

What Survives the Convert: HTML In, Markdown Out

· NanoParse team

An agent that asks for a web page does not receive the page. It receives Markdown, a title, and a set of signals — and all three are the output of a chain of decisions about what to keep. Those decisions are usually invisible, which is why a parse that looks fine in a demo can be wrong in a way nobody notices until the model cites a navigation link as a source.

This is a run with the decisions left in. Eight URLs, eight page classes, every payload saved, every count computed from the Markdown that came back rather than estimated.

Method

Eight URLs were submitted to the live endpoint on 2026-10-07 through the operator path used for measurement — the same renderer the paid path uses, with the free-tier counter bypassed so the sample was not capped at ten. URLs were chosen by class before the run. Seven rendered; one was refused, and its response is quoted in full below.

Each URL is one POST, and the response to that POST is three fields. The finished job is four:

POST  <url>          ->  202  {"jobId":"6d706c27-…","status":"queued",
                               "statusUrl":"https://nanoparse.app/status?jobId=6d706c27-…"}
GET   <statusUrl>    ->  200  {"status":"completed","markdown":"…",
                               "metadata":{…},"litmus":{…}}

The Markdown and the fifteen signals arrive in the same body. A caller that wants both does not make a second request to grade the page it just read.

The run

class          host                    words   chars  H1  tables  links  boiler  content_type
reference      en.wikipedia.org         2993   37087   2       1    353    0.08  academic_paper
documentation  developer.mozilla.org    6187   59781   2       2    292    0.39  tutorial
documentation  www.rust-lang.org         369    4081   1       0     14    0.33  tutorial
commerce       www.cloudflare.com         -       -    -       -      -       -  refused: consent wall
academic       arxiv.org                 821   17299   1       1    150    0.06  academic_paper
code           github.com                378    4494   4       1     37    0.06  documentation
news           www.bbc.com              1262    9671   2       0     53    0.02  tutorial
forum          lobste.rs                 668   20837   1       0    216    0.02  general

words and chars are counted on the returned Markdown. H1 is the number of lines matching a top-level heading. tables counts GFM separator rows (| --- |), so it is the number of tables the source page actually had. boiler is the litmus boilerplate_ratio. content_type is the litmus classifier.

Structure does survive

Tables survive as tables. This is a two-column table lifted from the MDN Array reference, cells and links intact:

| Mutating method | Non-mutating alternative |
| --- | --- |
| [copyWithin()](/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/copyWithin) | No one-method alternative |
| [fill()](/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fill) | No one-method alternative |
| [pop()](/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/pop) | [slice(0, -1)](/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice) |

That renders in any Markdown viewer without a plugin, and the links inside the cells still point at the source site. Links are the bulk of the output on some pages: the Wikipedia article came back with 353 of them, the MDN page with 292, a link aggregator with 216.

Code does not

Code is not fenced. The quickstart block from the GitHub repository page came back as plain lines with no fence around them:

To get started quickly with a new project, run the command below:

npm create cloudflare@latest
pnpm create cloudflare@latest
# or
yarn create cloudflare@latest

Two things follow, and both are visible in the counts. First, the # or lines are indistinguishable from headings to a Markdown renderer, because nothing marks them as code. Second, the page </code> lands as the first H1, above whatever heading the page itself opens with. That GitHub page reports four H1 lines: the title, the section heading, and <code># or</code> twice.</p> <p>None of this is a failed render. The text is all there. It is the shape of the output: prose and code arrive as the same plain text, separated only by the whitespace the source page used. A consumer that pipes the Markdown into a renderer gets headings where the page had comments; a consumer that keeps it as text loses nothing.</p> <h2>The labels are hints</h2> <p><code>content_type</code> read the Wikipedia article about Markdown as <code>academic_paper</code>, a news homepage as <code>tutorial</code>, and a link aggregator as <code>general</code>. The classifier is a heuristic — useful as a filter, wrong often enough that it should not be the thing a pipeline branches on. The same goes for <code>source.type</code> and the authority score: they are cheap priors, not verdicts.</p> <p>Downstream logic that needs to be sure should assert on the fields that are exact — the Markdown, the counts, the status enum — and treat the labels as tie-breakers.</p> <h2>Completed is not the same as useful</h2> <p>Two of the seven rendered pages came back at under 400 words. The Rust learn page returned 369 words from 4,081 characters; the GitHub repository landing page returned 378. Both report <code>status: completed</code>, because the render finished and content was extracted. Neither is a page an agent should be handed without a check.</p> <p>The fields that flag it are the dull ones. A word count below a few hundred on a page that is supposed to be an article is the tell. <code>boilerplate_ratio</code> says how much of what came back is chrome rather than content — 0.33 for the thin Rust page, 0.39 for the MDN reference that is mostly prose, against 0.02 for the two pages that returned almost pure link lists. Read the count and the ratio before trusting a parse; both cost nothing and both are in the body.</p> <h2>A refusal is named, and it is free</h2> <p>One URL out of eight was refused before any content was returned. The whole response:</p> <pre><code>{ "status": "failed", "error_code": "blocked", "message": "This page serves a cookie/consent wall instead of its content. We never click through consent banners, so there was nothing real to return. You were NOT charged.", "http_status": 403, "blocked_by": "consent_wall:markup" }</code></pre> <p>Three useful things are in that body, and none of them is a stack trace. <code>error_code</code> is the branch a caller switches on — <code>blocked</code> here, and <code>content_insufficient</code> when a page renders but yields almost nothing. <code>blocked_by</code> narrows it to the actual cause (<code>consent_wall:markup</code>). And the response states the billing outcome rather than leaving it to be inferred. Failed parses are never charged — settlement happens only after a successful render, so a page that refuses the reader costs nothing.</p> <h2>Reading a parse like a source</h2> <p>The output of a parse is one of three things: the Markdown and its signals, a named refusal, or a submit that has not finished. Everything needed to tell them apart is in the response, and every number above was computed from it.</p> <p>The full result set — the eight payloads, the runner, and the summary — is committed beside this post. The endpoint is at <a href="https://nanoparse.app">nanoparse.app</a>, priced at $0.005 USDC on Base per successful parse with no account and no API key, and it is the same endpoint served over MCP at <code>https://nanoparse.app/mcp</code>.</p> </article> </div> <div class="bottom-nav"> <a href="/blog">← All posts</a> <a href="/">NanoParse home</a> </div> <footer> <p>Built on x402.</p> <p style="margin-top:4px">© 2026 NanoParse — Parse the Web</p> <p style="margin-top:8px"> <a href="/">nanoparse.app</a> · <a href="/quickstart">Quick Start</a> · <a href="https://github.com/nanoparse-dev/nanoparse-mcp">MCP</a> · <a href="/blog">Blog</a> </p> </footer> </body> </html>