Skip to content

feat: extraction honours role=main and strips non-content elements - #212

Merged
matthewjhunter merged 1 commit into
mainfrom
feat/better-extraction
Aug 30, 2026
Merged

feat: extraction honours role=main and strips non-content elements#212
matthewjhunter merged 1 commit into
mainfrom
feat/better-extraction

Conversation

@matthewjhunter

Copy link
Copy Markdown
Owner

Narrowing to <main>/<article> was not enough on real pages, which nest chrome inside them. One Substack post carried four subscribe widgets and an author-bio footer inside its single <article>, and a document search for the article's results section returned the subscription plea.

Two changes, both semantic rather than per-site:

  • contentRoot falls back to an ARIA role="main" landmark when neither <main> nor <article> exists.
  • The extracted subtree has nav, header, footer, aside, form, button, script, style, noscript and template removed, along with anything marked aria-hidden="true" or hidden -- all non-content by HTML's own definition, and content a reader cannot see is not content.

The precedence was corrected by measurement, not reasoning

role="main" was tried above <article> on the theory that a declared landmark beats an inferred one. Against the real Substack post that made the extraction 2KB larger, because the main landmark wraps the article plus its surrounding furniture. A page has one main region but that region can hold more than the piece; <article> is the piece. The unit tests passed either way -- only measuring against real pages caught it.

Measured, before -> after

page before after junk markers
Karpathy gist 80,238 76,141 2 -> 2
Chroma chunking paper 100,770 98,231 1 -> 0
vinish.dev 10,146 8,088 1 -> 0
Substack post 44,928 44,869 3 -> 2

Three improved substantially. The fourth barely moved, and that is the honest limit of this approach: Substack's widgets are <div class="subscribe-widget">, reachable only by the class-name matching this PR deliberately rules out.

Why no class-name matching

Matching "subscribe", "promo", "related" catches more and is how readability heuristics work, but we keep no raw copy of the fetched bytes (docs/document-ingest.md), so an over-eager match silently deletes article text with nothing to recover from. Keeping too much is fixable later by ranking; keeping nothing is not. For the same reason the stripper refuses to empty a document -- a page that is only a footer still yields its text, and there is a test for it.

Task 8418 stays open for the readability-style text-density extraction that would reach the rest, with its own class of silent mistakes.

🤖 Generated with Claude Code

Narrowing to <main>/<article> was not enough on real pages, which nest chrome
inside them: one Substack post carried four subscribe widgets and an
author-bio footer inside its single <article>, and a document search for the
article's results section returned the subscription plea.

Two changes, both semantic rather than per-site. contentRoot now falls back to
an ARIA role="main" landmark when neither <main> nor <article> exists. And
the extracted subtree has nav, header, footer, aside, form, button, script,
style, noscript and template removed, along with anything marked aria-hidden
or hidden -- all non-content by HTML's own definition, and content a reader
cannot see is not content.

The precedence was corrected by measurement, not reasoning. role="main" was
tried above <article> on the theory that a declared landmark beats an
inferred one; against the real Substack post that made the extraction 2KB
LARGER, because the main landmark wraps the article plus its surrounding
furniture. A page has one main region but that region can hold more than the
piece, where <article> is the piece.

Class-name matching is deliberately not done. Matching "subscribe", "promo",
"related" catches more and is how readability heuristics work, but we keep no
raw copy of the fetched bytes, so an over-eager match silently deletes
article text with nothing to recover from. Keeping too much is fixable later
by ranking; keeping nothing is not. For the same reason stripping refuses to
empty a document: a page that is only a footer still yields its text.

Measured over the four pages currently in the corpus, before -> after:
gist 80,238 -> 76,141; Chroma 100,770 -> 98,231; vinish 10,146 -> 8,088;
Substack 44,928 -> 44,869. Three improved substantially. The fourth barely
moved, which is the honest limit of this approach -- Substack's widgets are
divs with class names, reachable only by the matching this commit rules out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthewjhunter
matthewjhunter merged commit 70dba7d into main Aug 30, 2026
8 checks passed
@matthewjhunter
matthewjhunter deleted the feat/better-extraction branch August 30, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant