Skip to content
Blog/Notes from the edge
Litmus 10 min read

The Page Says Where It Lives. The Fetcher Echoes What You Asked.

NP
NanoParse team

A parse returns the Markdown and twenty signal groups beside it. One of those groups, structured_data, is five fields with four booleans and a single string. The booleans say whether a page declared something. The string is the page's own claim about where it lives, copied out of a <link rel="canonical"> tag — and it is the only value in the group that can disagree with the URL that was requested.

That disagreement is measurable, and it is worth measuring twice, because a second group answers the same question: redirects. On the same response, for the same URLs, one field reported a change of address and the other reported none.

This post prints the deployed function, isolates each of its rules with 27 fixtures, then runs 16 real URLs through the live endpoint. Every payload, fixture and script is committed beside the post.

Four booleans and one string

Two real responses from the live endpoint on 2026-11-13. The first is https://www.nasa.gov/:

"structured_data": { "json_ld": true, "schema_types": ["Organization", "WebSite", "ImageObject", "WebPage", "Person", "Article"], "open_graph": true, "canonical": "https://www.nasa.gov/", "meta_description": true }

The second is https://kubernetes.io/docs/concepts/, a documentation index:

"structured_data": { "json_ld": false, "schema_types": [], "open_graph": true, "canonical": null, "meta_description": false }

Both are ordinary pages with different amounts of metadata, and the group reports each without ranking it. canonical: null means no canonical tag was found, not that the page has no canonical URL — a documentation site can serve the same page under three paths and say nothing about which one is authoritative.

The function, printed from the deployed source

computeStructuredData is 42 lines of worker/worker/src/intel.ts, sliced out by declaration rather than retyped, and run under node --experimental-strip-types for every fixture below:

export function computeStructuredData(html: string): { json_ld: boolean; schema_types: string[]; open_graph: boolean; canonical: string | null; meta_description: boolean; } { const src = String(html || ""); const schemaTypes = new Set<string>(); let jsonLd = false; const ldRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; let m: RegExpExecArray | null; while ((m = ldRe.exec(src)) !== null) { try { const data = JSON.parse(m[1].trim()); if (data && typeof data === "object") { jsonLd = true; collectSchemaTypes(data, schemaTypes); } } catch { /* malformed JSON-LD — ignore */ } } const canonical = src.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)?.[0]?.match(/href=["']([^"']+)["']/i)?.[1] || null; return { json_ld: jsonLd, schema_types: Array.from(schemaTypes).slice(0, 20), open_graph: /<meta[^>]+property=["']og:(title|type)["']/i.test(src), canonical, meta_description: /<meta[^>]+name=["']description["']/i.test(src), }; }

Four properties are visible in the code before any test runs.

The string is copied, not resolved. canonical is whatever sits inside the href attribute. A relative canonical stays relative.

JSON-LD is parsed, not scanned. A malformed block throws, is swallowed, and contributes nothing — including the json_ld: true flag.

Types are collected from two keys only. collectSchemaTypes recurses through @graph and mainEntity, so a @type nested under author or offers is invisible.

The output is capped. Twenty schema types, in first-seen order.

Twenty-seven fixtures, one rule each

Each fixture is a minimal HTML string built to test exactly one branch. Every row is the deployed function's output:

fixture ld schema types og canonical desc ------------------------- ----- ------------------------ ----- ----------------------- ---- none false - false null false jsonld_article true Article false null false jsonld_graph true Organization,WebSite,... false null false jsonld_type_array true Article,NewsArticle false null false jsonld_mainentity true WebPage,Question false null false jsonld_nested_author true Article false null false jsonld_malformed false - false null false jsonld_bare_string false - false null false jsonld_two_scripts true TechArticle false null false jsonld_single_quotes_tag true FAQPage false null false jsonld_not_ldjson false - false null false jsonld_25_types true first 20 of 25 false null false og_title false - true null false og_image_only false - false null false og_as_name false - false null false og_uppercase false - true null false desc_meta false - false null true desc_only_og false - false null false desc_uppercase false - false null true canonical_plain false - false https://site.test/a false canonical_href_first false - false https://site.test/a false canonical_rel_extra false - false null false canonical_unquoted false - false null false canonical_relative false - false /a/b false canonical_two false - false https://site.test/first false canonical_multiline false - false https://site.test/a false canonical_uppercase false - false https://site.test/a false

Twenty-seven of twenty-seven matched the expected output. The rows worth reading twice:

jsonld_nested_author declares {"@type":"Article","author":{"@type":"Person"}}. The result is ["Article"] — the Person is not collected, because author is not a traversed key. A schema graph that describes authorship properly can still report one type.

canonical_rel_extra uses rel="canonical alternate" and returns null. The regex requires the quote to close immediately after canonical, so a tag using the specification's own allowance for a space-separated rel list is invisible.

canonical_unquoted uses <link rel=canonical href=https://site.test/a> and returns null. Both attribute values must be quoted.

canonical_relative uses href="/a/b" and returns "/a/b". A client comparing that to redirects.final_url with === will always mismatch, on a page that is telling the truth.

canonical_two puts two canonical tags in one document and returns the first in source order. Nothing reports that a second one existed.

canonical_uppercase passes: REL="CANONICAL" matches, because the whole pattern is case-insensitive. Attribute names are flexible; attribute values are not.

Twelve pages, measured

Sixteen URLs went through POST /admin/parse on 2026-11-13. Admin parses bypass quota and payment, so the run cost nothing and touched no free bucket. Thirteen completed; the three refusals each named their reason and charged nothing:

outcome count example and reason ---------- ----- --------------------------------------------------------- completed 13 every page below blocked 1 npmjs.com/package/hono — bot check (cloudflare challenge) consent wall 1 www.bbc.com/news — cookie/consent wall, nothing real returned not found 1 github.com/nanoparse-dev/nanoparse — 404/410

Thirteen reads cover twelve distinct pages: the http:// and https:// versions of one Wikipedia article were both submitted. Coverage, by field:

page words ld og desc canonical ------------------------------------------ ----- ----- -- ---- --------- en.wikipedia.org/wiki/web_scraping 4157 true T F yes en.wikipedia.org/wiki/Web_scraping (http) 4157 true T F yes stripe.com/pricing 3792 true T T yes www.nasa.gov/ 453 true T T yes developer.mozilla.org/.../Fetch 806 false F T yes docs.python.org/3/library/json.html 3852 false T T yes arxiv.org/abs/1706.03762 698 false T T yes www.postgresql.org/docs/.../sql-select 10721 false T F yes kubernetes.io/docs/concepts/ 272 false T F no example.com 135 false F F no news.ycombinator.com 928 false F F no plato.stanford.edu/entries/...-intelligence 29266 false F F no www.rfc-editor.org/rfc/rfc9110.html 66479 false F T no

JSON-LD was present on 4 of the 13 reads, three distinct pages. Open Graph on 8. A meta description on 6. A canonical link on 8.

The length column is the argument against reading any of this as a quality signal. The largest document in the run, RFC 9110 at 66,479 words, declares no JSON-LD, no Open Graph and no canonical. The Stanford Encyclopedia entry at 29,266 words declares none of the three either. Both are reference documents that render perfectly well; neither publisher added machine-readable declarations, and the group reports that as an absence rather than a defect.

What the canonical disagreed with

Of the eight reads carrying a canonical, two named a URL different from the one submitted, and the two kinds of difference call for different client behaviour.

requested https://en.wikipedia.org/wiki/web_scraping canonical https://en.wikipedia.org/wiki/Web_scraping difference case only — same path, same document requested https://developer.mozilla.org/en-US/docs/Web/API/Fetch canonical https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch difference a different path — the page names its own new address

A case-only difference is a deduplication fact: the two strings are one page, and a citation cache keyed on the raw requested URL will store the same document twice. The MDN difference is a provenance fact: the requested path is a consolidated redirect, and the page itself says so.

Both were confirmed against the page's own HTML, fetched separately with a plain HTTP client and compared field by field. The canonical string the endpoint returned was byte-identical to the canonical tag in the page source on all 13 reads, and JSON-LD presence agreed on all 13. The values are the publisher's, not the parser's inference.

The other field that answers the same question

redirects reports three values — final_url, hops, cross_domain — and on all 13 reads it reported hops: 0. On 11 reads final_url was byte-identical to the URL submitted; on the other two it differed only by a trailing slash. That includes three URLs a plain HTTP client sees answer with a 301:

curl -sI https://developer.mozilla.org/en-US/docs/Web/API/Fetch 301 -> https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch payload "redirects": {"final_url": "https://developer.mozilla.org/en-US/docs/Web/API/Fetch", "hops": 0, "cross_domain": false} curl -sI https://en.wikipedia.org/wiki/web_scraping 301 -> https://en.wikipedia.org/wiki/Web_scraping payload "redirects": {"final_url": "https://en.wikipedia.org/wiki/web_scraping", "hops": 0, "cross_domain": false} curl -sI http://en.wikipedia.org/wiki/Web_scraping 301 -> https://en.wikipedia.org/wiki/Web_scraping payload "redirects": {"final_url": "http://en.wikipedia.org/wiki/Web_scraping", "hops": 0, "cross_domain": false}

The third row is the clearest: the scheme in the reported final_url is the scheme that was requested, on a page served over TLS that says so in its own canonical.

The source explains the shape without needing a guess. In the queue consumer, finalUrl begins as null and redirectHops as 0. Hops are incremented by a response listener that only exists inside the browser-render branch. When the direct-HTTP fast path produces the HTML, the browser never starts, so nothing assigns either variable, and computeRedirects falls back:

const final = (typeof finalUrl === "string" && finalUrl) ? finalUrl : original; const h = (typeof hops === "number" && hops >= 0) ? hops : 0; return { final_url: final, hops: h, cross_domain: extractDomain(final) !== extractDomain(original) };

hops: 0 therefore means "no hop was counted", which is not the same as "no hop happened". The practical rule is short: read redirects as a report of what the fetch layer observed, and read structured_data.canonical when the exact URL matters — for a citation, a deduplication key, or a link a reader will click. When the two disagree, the canonical is the publisher's statement and the hop count is a counter that may have had nothing to count.

What the group cannot see

  • **Schema types outside @graph and mainEntity.** A Person nested under author is not collected, so schema_types is a partial view of a graph that may be complete and valid.
  • A second canonical tag. First in source order wins, silently.
  • **A canonical written with a rel list or without quotes.** Both appear in the wild and both return null.
  • The target of a relative canonical. The string is returned as written.
  • Whether a declaration is true. json_ld: true means JSON-LD parsed. It does not mean the Article it declares is an article, and schema_types: [] means "nothing declared", never "nothing true".

Using the group without a model call

Four rules, in the order they change a decision.

function addressOf(job: { litmus: any }) { const sd = job.litmus.structured_data; const rx = job.litmus.redirects; // 1. Identity: prefer the page's own canonical, resolved against the reported URL. const canonical = sd.canonical ? new URL(sd.canonical, rx.final_url).toString() // handles a relative href : null; // 2. Deduplication key: normalise case and trailing slash before comparing. const key = (canonical || rx.final_url).replace(/\/+$/, "").toLowerCase(); // 3. Do not infer a redirect from hops: 0 — it is a count, not a verdict. // Compare the canonical path with the requested path instead. const moved = canonical ? new URL(canonical).pathname !== new URL(rx.final_url).pathname : false; // 4. Read the three declarations separately: og and description answer // different questions, and an empty schema_types means nothing was declared. return { canonical, key, moved, declared: sd.json_ld, social: sd.open_graph, described: sd.meta_description, }; }

The first rule is the one that matters most. new URL(sd.canonical, rx.final_url) resolves a relative canonical against the URL the fetch reports, which is the only way to turn "/a/b" into something comparable — the endpoint returns the string as the page wrote it, and a client that string-compares without resolving calls every relative canonical a mismatch.

The third rule is a correction to a natural reading. A caller that wants to know whether a page moved has to compare paths, not read a hop counter, because a hop counter that ran on a path without a browser is a counter with nothing to count.

Reproducing this

The function is not retyped. It is sliced from intel.ts by declaration and the slice records the source hash:

python3 build_fns.py # slice computeStructuredData + collectSchemaTypes node --experimental-strip-types fixtures.mjs # 27 fixtures -> results.json python3 probe_real.py # 16 URLs through /admin/parse -> real/ python3 rollup.py # every count printed above python3 truth.py # each page's own HTML, fetched independently

The slice is 42 lines at blob 7b3eca00, source sha256 d773ba97…, unchanged since the live deploy. The 27 fixtures, all 13 payloads, the three refusals, the independent page fetches, the redirect headers and the scripts are committed beside this post.

Two scope notes. The run used the admin path, so it cost nothing and consumed no free tier. And every number here describes the deployed function as of this post — a later version can change any rule above without changing this page.

The endpoint is https://nanoparse.app, $$0.005 per successful parse, no account and no API key, with the same surface served over MCP at /mcp. Refused reads are never charged. The earlier survey of all six metadata groups is /blog/2026-10-19-page-you-asked-for, and the neighbouring deep dives read one field apiece: /blog/2026-11-07-authorship-field and /blog/2026-11-10-page-status-default.

Litmus2,161 words

Test the page your agent just read.

Clean Markdown and 20 trust signals, $0.005 per parse.

Run a test

Your agent deserves better data.

Clean Markdown and twenty deterministic trust signals, priced per parse.

10 free parses a month · no account · no API key