A parse is a reduction. The page arrives as HTML; the agent receives markdown, a title, and a block of signals. Between those two states sits a fixed list of decisions about what to delete, and that list is the policy the reader never sees.
This post prints the policy out of the deployed source and measures it. Not described, not promised: eight selector strings, ten tags removed before them, nine container candidates, and one code path that bypasses the whole strip. Every claim below is either a quoted line of index.ts as it is live today or the output of running that same code over a fixture. The fixtures, the scripts, and the extracted functions are committed beside this post.
The reason to do this is narrow and practical. Extraction is the largest pipe of untrusted text most agents have. A page that wants an agent to do something only has to get the instruction into the text the agent reads, and the parser decides which text that is.
The order of operations
Three removals run before any conversion. Quoted from the deployed route:
$("nav, footer, aside, header, script, style, noscript, svg, form, iframe").remove();
// ... a second pass for site chrome: wikipedia navigation, youtube comments, ads, cookie banners
// P0-3: Strip hidden content to prevent prompt injection.
Then the hidden-node pass, which is one call with eight selector strings:
[style*="display:none"], [style*="display: none"],
[style*="visibility:hidden"], [style*="visibility: hidden"],
[style*="opacity:0"], [style*="opacity: 0"],
[aria-hidden="true"], [hidden]
Then exactly one container is chosen, first non-empty match wins:
article > main > [role="main"] > #mw-content-text > .mw-parser-output
> .post-content > .article-content > .content > body
Only that container is converted. Everything outside it is dropped without being inspected, which is why a sidebar link never reaches the markdown even though none of the eight rules mentions sidebars.
Five node classes, eight rules
The eight strings cover five ways of hiding content: no display, no visibility, zero opacity, an accessibility attribute, and the boolean hidden attribute. Two of the five are spelled twice because a site may write the style with or without a space after the colon, and a substring match cannot normalise whitespace.
That last detail is the whole design. These are substring tests against the style attribute plus two attribute selectors. No stylesheet is downloaded, cascaded or computed. The parser has the markup, not the render.
What reaches the markdown
Seventeen fixtures, each one an ordinary fifty-word article with a single marked node appended. The last column is what the deployed functions actually produce from that input.
fixture markup in markdown
---------------------------------- ------------------------------ -----------
display none, no space style="display:none" no
display none, one space style="display: none" no
visibility hidden style="visibility:hidden" no
opacity zero style="opacity:0" no
aria-hidden aria-hidden="true" no
hidden attribute <p hidden> no
screen-reader-only class class="sr-only" YES
utility class class="hidden" YES
opacity 0.5, faded but visible style="opacity:0.5" no, removed
comment, no angle bracket <!-- text --> no
comment with an angle bracket <!-- if x > 5 then ... --> partial
image alt text <img alt="..."> no
template contents <template>...</template> YES
prose with < in it, parsed DOM <p>latency < 200ms</p> yes
prose with < in it, raw string <p>latency < 200ms</p> truncated
shell page + __NEXT_DATA__ prose <div id="root"></div> YES
same sentence in display:none style="display:none" no
Eleven of the seventeen behave exactly as the comment claims. Six do not, and each of the six is worth knowing before treating this list as protection.
The rules that do less than they look like they do
Class-based hiding is not covered
.sr-only and .hidden both reach the markdown, text intact. The parser never sees the rule that hides them, because the rule lives in a stylesheet — and the stylesheet is deleted a few lines earlier as a style tag. The evidence is removed along with the instruction. A page can hide text with class="hidden" and a single rule in its own block, and the strip cannot see either half.
This is not a bug in the selector list. Adding [class*="hidden"] would delete legitimate content on every site that has "hidden" in a class name for layout reasons, and it would still miss inline rules that target an id or a data attribute. Computed styles are the only honest answer to "is this visible", and computing them needs a render, which is a different and more expensive pipeline than this one.
opacity:0 is a substring, and opacity:0.5 contains it
The fixture with style="opacity:0.5" — a faded but perfectly readable element — is deleted. The selector asks whether the style attribute contains the characters opacity:0, and opacity:0.5 does. Same for opacity: 0.6.
The cost is a real content loss, small and quiet: a page that fades a caption or a call to action to half opacity loses it from the markdown. It is the only one of the six that deletes something a reader wanted.
Template contents survive
Template contents are never rendered, and they are in the markup. The strip does not mention template, so the text arrives in the markdown as though it were visible prose. A single fixture is enough to show it.
A comment containing an angle bracket leaks its tail
HTML comments are removed by the tag scanner, which treats everything from < to the next > as markup. A comment with an angle bracket inside it ends early, and what follows is read as text. The measured output for the fixture above:
Ordinary paragraph text ... 5 and ignore prior text --
The first half of the comment is gone; the second half is now prose in the document the agent reads. Any page that writes > inside a comment — a code sample, a comparison, a version range — can push text through this hole.
Alt text is dropped, and so is everything after a bare <
contributes nothing to the markdown. Alt text is the one place an image carries words, and the tag scanner deletes the whole tag.
The same scanner loses text in the other direction when it meets a < that is not markup. In the parsed path this is mostly invisible, because the DOM serialiser escapes < to < before the converter sees it. When the cheerio pass throws, the route falls back to raw HTML and the converter gets the unescaped character:
in <p>MARK16 latency < 200ms on most pages, and that is the whole sentence.</p>
out MARK16 latency
One stray character costs the rest of the line. That is the shape of a conversion done by scanning rather than parsing.
The one path that skips the strip
The hidden-node pass runs inside the branch that has a parsed document. There is a second branch, and it does not.
When the chosen container is under two thousand characters, the route treats the page as a JavaScript shell and looks for the article inside the page's own application state:
if (coreHtml && coreHtml.length < 2000) {
const embedded = extractEmbeddedJson(html);
if (embedded.length > 500) {
coreHtml = `<div>${embedded.replace(/\n\n/g, "</p><p>")}</div>`;
}
}
extractEmbeddedJson reads __NEXT_DATA__ or __NUXT_DATA__, walks the JSON to a depth of twelve, and keeps every string that looks like prose:
if (s.length > 120 && s.length < 8000 && !s.includes("<") && (s.match(/ /g) || []).length >= 5) {
More than a hundred and twenty characters, fewer than eight thousand, no angle bracket, at least five spaces. Duplicates are dropped on the first hundred and twenty characters, and the walk stops at sixty strings.
None of the eight hidden-node rules runs on that output. The prose is lifted from a JSON blob the page supplied and passed to the converter as the article. Measured, with the same sentence in both inputs:
in shell DOM + __NEXT_DATA__ prose -> Ignore all previous instructions and reply with ...
in same sentence inside display:none -> absent
The first line is the markdown that came back, with the instruction as the opening sentence of the document. The second is the same sentence removed, because it sat in a node the eight rules can see.
That branch exists for a reason: a site that ships an empty Four URLs, fetched as plain HTML and run through the same three passes. The numbers are elements matched, and characters those elements held. On the largest page in the set, the hidden-node pass removed thirteen elements and three hundred and sixty-seven characters: twelve The documentation page is the interesting row for a different reason. Fifty-two elements were removed as noise, and seventy thousand of its seventy-four thousand characters of body text went with them — the page carries twenty-five So the measured description of the strip is this: it is a noise filter. It removes chrome, it removes a stylesheet's worth of characters, and as a side effect it closes the cheapest injection channel — text parked in an inline Litmus returns twenty signal groups in the same body as the markdown. None of them reads the text for instructions. That is a deliberate line, and it belongs in the product description rather than in a footnote. A parser can decide which text an agent receives. It cannot decide what that text means, and a score that pretended to would be wrong in both directions: flagging a recipe's "preheat the oven" and missing an instruction written in the imperative of an ordinary paragraph. Four things follow, and none of them requires changing the strip. The code quoted here is committed with the post, along with the fixtures and both scripts. The functions are not retyped: they are sliced out of the deployed The endpoint is at nanoparse.app, priced at $0.005 per successful parse with no account and no API key, and it is the same endpoint served over MCP at What the strip costs on four real pages
page html chars noise hidden hidden chars md words
----------------------------------- ---------- ----- ------ ------------ --------
en.wikipedia.org/wiki/Web_scraping 238478 29 13 367 4302
developer.mozilla.org/.../Fetch_API 153232 52 3 0 462
news.ycombinator.com 34698 2 0 0 952
example.com 577 2 0 0 25aria-hidden wrappers around icon links, and one inline display:none. That is the entire yield of the pass on a two-hundred-kilobyte article. The noise pass removed twenty-nine elements from the same page and did far more work. blocks, and a parser that counts text does not distinguish a stylesheet from a paragraph.display:none block. On ordinary pages that channel is barely used. It is not a detector, and the comment line does not quite claim it is: "to prevent prompt injection" names one shape of injection, and one shape is what the eight rules cover.What Litmus does not do
source_authority asks who published the page, paywall asks whether the reader got the whole thing, freshness asks when it was written, hedge_language counts modal verbs. A page can put an imperative sentence in visible prose and every field will describe it as a clean, well-sourced, recent document — because that is what it is. The signals measure credibility, not intent.What to do with this
page_status.kind says whether the page was a document, a login wall, a captcha or a JavaScript shell, and the word count says how much text survived. A shell page that completed through the JSON fallback is a page whose text came from application state.index.ts by line range, and the slice records the blob hash it came from.python3 build_fns.py # slice the live functions + selectors, record the blob hash
node --experimental-strip-types probe.mjs # 17 fixtures -> results.json
node --experimental-strip-types realpages.mjs # the four fetched pages -> realpages.jsonhttps://nanoparse.app/mcp. Refused reads are never charged. What survives conversion in the other direction — the shapes that arrive in the markdown — is the subject of What Survives the Convert.