Litmus returns twenty signal groups in the same response body as the markdown. One of them is authorship, and of the twenty it is the field most likely to be read as a fact about a person:
"authorship": { "present": true, "name": "Contributors to Wikimedia projects",
"kind": "person", "source": "jsonld" }
That is the real answer for the English Wikipedia article on web scraping, returned by the live endpoint. The article has no single author. The string is a collective, and kind calls it a person.
Nothing in the code is broken here. authorship promises less than the shape of the field suggests, and the gap is worth knowing before an agent acts on it: attribution, a "who said this", a citation of a human being. This post prints the promise, then measures it — 24 fixtures and six real pages, run through the deployed functions.
Three lookups, one classifier
The detection order is fixed and it stops at the first hit:
JSON-LD author / creator -> <meta name="author"> / article:author -> "By ..." line -> none
The JSON-LD pass walks every application/ld+json block, following author, then creator, then @graph. A block that fails to parse is skipped, so a broken block above a usable one costs nothing. The meta pass reads three tag shapes: name="author", the same tag with its attributes in the opposite order, and property="article:author". The third pass is the last resort:
const byline = md.slice(0, 300).match(/^By\s+([A-Z][^\n]{2,60})$/m)?.[1]?.trim() || null;
if (byline) return { present: true, name: byline, kind: classifyAuthorKind(byline), source: "markup" };
Four sources come out the other side: jsonld, meta, markup, none. The source field is the honest part of the response. It says how much authorship structure the publisher emitted, which is a fact about the page. present: true says one thing: a string was found in one of those three places. It does not say the string is a name, that the name is a person, or that the person wrote anything.
kind is a guess about the string
kind is computed from the characters in the name. There is no second lookup, no type from the page, no check against an organisation list:
const ORG_KEYWORDS = /\b(inc|llc|ltd|corp|corporation|plc|gmbh|group|team|staff|editorial|board|news|press|media|agency|wire|network|reuters|associated press|ap)\b/i;
function classifyAuthorKind(name: string): "person" | "organisation" | "unknown" {
const n = (name || "").trim();
if (!n) return "unknown";
if (ORG_KEYWORDS.test(n)) return "organisation";
if (!/\s/.test(n)) return "organisation";
return "person";
}
Two rules, both shape-based. A name containing any word from that list is an organisation. A name with no space in it is an organisation, because a single token is a masthead far more often than it is a person.
The consequences are easy to predict and they all showed up in the fixtures.
@type is never read. A page that publishes {"@type": "Organization", "name": "Morning Brew"} in its own JSON-LD gets kind: "person", because "Morning Brew" is two tokens and contains none of the keywords. The page stated its own answer and the classifier overwrote it with a guess about syllables.
A one-word human name is an organisation. {"@type": "Person", "name": "Plato"} returns kind: "organisation".
A byline carrying a desk name is an organisation. Jane Doe, Staff Writer contains staff, so a person becomes a masthead. Same for News, Press, Media, Editorial, Team.
A profile URL becomes an organisation. article:author is often a link, not a name. content="https://facebook.com/janedoe" comes back as name: "https://facebook.com/janedoe", kind: "organisation" — a URL in the name field, classified by its lack of spaces.
Multi-author markup returns nothing
The common schema.org shape for two authors is an array of Person objects:
"author": [{"@type": "Person", "name": "Jane Doe"},
{"@type": "Person", "name": "John Roe"}]
The extractor's array branch recurses into each element expecting a node that itself holds an author or creator. A Person object does not, so both elements return null and the whole call returns null. The JSON-LD pass moves on; if there is no meta tag and no byline, the answer is present: false on a page that named two people in machine-readable form — the exact case where a signal has the information and refuses to report it.
An empty name in JSON-LD is handled better: it falls through to the meta pass and then to the byline, so a page with a blank author object and a real meta tag still resolves.
The byline path is one regex and 300 characters
The markup source reads the markdown, not the HTML. It takes the first 300 characters, and it wants a line that starts with By , then a capital letter, then up to 60 more characters to the end of the line.
That gives it three properties worth stating plainly. It only sees a byline that survives conversion and lands near the top of the document. It is case-sensitive at the first letter, so by returns nothing. And it captures the rest of the line, because the regex ends at $ and allows 60 characters — so By Jane Doe, The Verge returns name: "Jane Doe, The Verge", and By Jane Doe | Senior Reporter returns the role as part of the name.
None of this is a defect in a signal that describes the markup. It is a defect in reading the name as a name.
Twenty-four fixtures
Each fixture is a minimal page plus a markdown body. The last three columns are what the deployed function returns for that input.
fixture markup it sees name kind source
------------------------- ---------------------------------------------- -------------------------- ------------ ------
jsonld_person author: {@type Person, Jane Doe} Jane Doe person jsonld
jsonld_org_type_ignored author: {@type Organization, Morning Brew} Morning Brew person jsonld
jsonld_org_keyword author: {@type Organization, Acme Media Group} Acme Media Group organisation jsonld
jsonld_single_token author: {@type Person, Plato} Plato organisation jsonld
jsonld_author_array author: [Person Jane Doe, Person John Roe] null unknown none
jsonld_string_author author: "Jane Doe" Jane Doe person jsonld
jsonld_graph author inside @graph Jane Doe person jsonld
jsonld_creator creator: {@type Person, Jane Doe} Jane Doe person jsonld
jsonld_broken_then_valid malformed block, then a usable one Jane Doe person jsonld
jsonld_empty_name empty JSON-LD name, meta author present Jane Doe person meta
meta_author <meta name="author" content="Jane Doe"> Jane Doe person meta
meta_author_reversed same tag, attributes reversed Jane Doe person meta
meta_author_capitalised <meta name="Author"> Jane Doe person meta
meta_single_token content="Reuters" Reuters organisation meta
meta_with_desk content="Jane Doe, Staff Writer" Jane Doe, Staff Writer organisation meta
meta_article_author_url article:author = https://facebook.com/janedoe https://facebook.com/... organisation meta
meta_precedence JSON-LD and meta both present Json Ld person jsonld
byline_md markdown starts "By Jane Doe" Jane Doe person markup
byline_beyond_300 same line after 320 characters null unknown none
byline_lowercase markdown starts "by Jane Doe" null unknown none
byline_with_outlet "By Jane Doe, The Verge" Jane Doe, The Verge person markup
byline_role_pipe "By Jane Doe | Senior Reporter" Jane Doe | Senior Reporter person markup
byline_mid_paragraph "By Jane Doe" mid-sentence null unknown none
none no author anywhere null unknown none
Nineteen of the twenty-four return a name. Five return none, and only one of those five is a page with no author at all. The other four had one and the reader missed it: the multi-author array, the byline past 300 characters, the lowercase by, and the byline inside a sentence.
Of the nineteen names, five are classed as organisations. Two of those five are correct (Acme Media Group, Reuters). Three are not: a person with a one-word name, a person beside a desk name, and a profile URL. One name classed as a person is an organisation (Morning Brew), and one is a collective (Contributors to Wikimedia projects, from the real-page run below).
Six real pages
Six URLs, parsed through the live endpoint. The second group of columns is the page's own HTML, read separately and quoted as bytes.
page http its own author markup name kind source
-------------------------- ---- ---------------------------------------------- -------------------------- ------ ------
en.wikipedia.org/wiki/... 200 ld+json: {@type Organization, Contributors...} Contributors to Wikimedia person jsonld
developer.mozilla.org/... 200 none null unknown none
news.ycombinator.com 200 none null unknown none
example.com 200 none null unknown none
docs.python.org/3/... 200 none null unknown none
simonwillison.net 200 <meta name="author" content="Simon Willison"> Simon Willison person meta
Four of six carry no author metadata whatsoever, and the field reports that correctly. The one page with a meta tag is read correctly, name and kind. The one page with JSON-LD is the one that is wrong: the page states Organization and the field answers person, with a name that is a description of a group of anonymous editors.
That is a fair summary of the surface. On ordinary sites the field is mostly absent. When it is present it is usually right, and when it is wrong it is wrong in a way a reader can see in one glance — a collective called a person, a URL called a name.
What to do with it
Four rules, in order of how much they matter.
- Read
present: falseas information, not as failure. A page with no authorship markup is usually a page nobody signed — documentation, a storefront, a front page of links. For an agent deciding whether a source has an accountable author, absence is a usable answer. - Read
sourcebefore readingname.jsonldmeans the publisher emits structured authorship and a parser can trust the location, if not the semantics.markupmeans a regex found a line that begins withByin the first 300 characters, and the name may include an outlet or a job title.nonemeans there was nothing to find. - Never use
kindas an entity type. It is a shape test over a string.@typefrom the page is discarded, single tokens become organisations, and any byline carrying a desk name becomes one too. Treatkindas "does this string look like a masthead", which is what it measures. - Attribution needs a second source. If a name has to be right — a citation, a quote, a legal notice —
authorshipnarrows the page to one or two candidate strings and stops there. Resolving the string to an entity is a different job, with a different failure surface.
The rest of Litmus is built the same way, and the same caution applies: each group describes the page it was given, deterministically, and none of them sees past the render. structural_trust_score is a ledger of what the page shows, not a verdict; hedge_language counts modal verbs, not honesty. Reading those fields as conclusions is the one thing they cannot support.
Reproducing this
The functions are not retyped. They are sliced out of the deployed intel.ts by declaration, and the slice records the source hash it came from:
python3 build_fns.py # slice computeAuthorship + helpers, record sha256
node --experimental-strip-types probe.mjs # 24 fixtures -> results.json
python3 probe_real.py # 6 pages, admin parses -> real/*.json
python3 truth.py # the same 6 pages' own HTML -> truth.json
python3 rollup.py # every number printed above
The fixtures, the six payloads, the fetched HTML and the scripts are committed beside this post. Two notes on scope: the real-page run used the admin path, so it cost nothing and touched no free tier; and the numbers describe the deployed function as of this post, not a promise about a future one.
The endpoint is nanoparse.app, $0.005 per successful parse, no account and no API key, with the same surface served over MCP at https://nanoparse.app/mcp. Refused reads are never charged. The neighbouring piece in this series reads one field the same way: Sixty Hedges Read Low, One Hedge Read Medium.