Extract the main content from a web page
Get an article’s text without the menu, the cookie banner, the related items and the footer.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the problem is solved, measured and shipped: trafilatura on one side, Mozilla’s Readability on the other. What this entry adds fits in two rules, and both exist because the libraries stay silent where they should speak. The first refuses to return an empty string as an answer: on a page built by its own JavaScript, the report says there was nothing to take. The second settles a measured disagreement between them: on a category page made of forty links, trafilatura returns nothing and Readability returns the link text as an article — so the share of the text sitting inside links is measured on both sides, and above half the page is not an article.
Boilerplate removal, with the share of links measured
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Keep the article and drop everything around it.
Rung N0. Separating the body of a page from its menus, its footer, its cookie
banner and its « you may also like » is a solved problem with a name — boilerplate
removal — and two mature implementations: `trafilatura` here, Mozilla's
`Readability` in JavaScript. Both work on the tag tree, weighing text density
against link density, and both run offline in a few milliseconds.
They are not the same algorithm and they do not return the same characters. The
titles differ, the line breaks differ, and one keeps the heading inside the body
while the other puts it in the title. What they agree on is the part that
matters, and the test of this entry says it that way: the article's sentences
are in, the navigation and the footer are out.
Two things this file adds. The first is the refusal to hand back an empty
string as if it were an answer, and to confuse two situations while doing it.
A page whose body is built by its own JavaScript arrives here as an empty
shell, and that is not the same thing as a press brief of a hundred and
twenty-five characters that came out whole. The two have two reasons: one says
nothing came out, the other says how short what came out is, and says nothing
about why.
The second settles a disagreement between the two libraries. On a category page
— forty links and nothing else — `trafilatura` returns nothing and `Readability`
returns the link text as though it were an article. So the share of the page's
text that sits inside links is measured here, in both languages, and above half
the page is not an article whatever the extractor said.
"""
from __future__ import annotations
import trafilatura
# The length under which the report says so, in characters. It is a line of
# information handed to the caller, not a verdict: a press brief and the
# documentation of one function are both genuinely below it, and both come
# back whole, with their text and their title. Nothing is dropped and nothing
# is diagnosed because of it.
MIN_CHARACTERS = 200
# Above this share of the page's text inside links, the page is a list of
# links — a category, an index, a tag page — and not an article. Measured
# rather than guessed, because the two extractors do not agree on their own.
MAX_LINK_SHARE = 0.5
def read_article(html, *, min_characters: int = MIN_CHARACTERS) -> dict:
"""
The body of the page, its title, and whether it is worth reading.
`reason` tells a caller that came back empty-handed which of the two
happened. Nothing at all came out — the page is a shell, and that calls
for another tool, not for a better extractor. Or something came out and it
is short, in which case the reason says how short, and nothing else: a
short page is a short page, not a broken one.
"""
if not isinstance(html, str):
return _report(None, "", f"expected HTML, not {type(html).__name__}")
try:
text = trafilatura.extract(html, output_format="txt", include_comments=False,
include_tables=False, favor_precision=True) or ""
metadata = trafilatura.extract_metadata(html)
share = _link_share(html)
except Exception: # noqa: BLE001 - a page that cannot be parsed is an answer too
return _report(None, "", "this page could not be parsed")
title = getattr(metadata, "title", None) if metadata else None
if share > MAX_LINK_SHARE:
return _report(title, "", "this page is a list of links, not an article")
text = text.strip()
if not text:
return _report(title, "", "nothing was extracted: this page may be built by its own JavaScript")
if len(text) < min_characters:
return _report(title, text, f"this page is short: {len(text)} characters")
return _report(title, text, None)
def _link_share(html: str) -> float:
"""
What share of the page's text sits inside a link.
Both sides are stripped, and the result is capped at one: a link nested
inside another — which a permissive parser produces on malformed markup —
is counted twice, and a share above one is not a share.
"""
from lxml import html as lxml_html
tree = lxml_html.fromstring(html)
for element in tree.iter("script", "style"):
element.text = None
total = len("".join(tree.itertext()).strip())
inside = sum(len("".join(link.itertext()).strip()) for link in tree.iter("a"))
return min(inside / total, 1.0) if total else 0.0
def _report(title, text: str, reason: str | None) -> dict:
return {"title": title, "text": text, "characters": len(text), "reason": reason}JavaScript
/**
* Keep the article and drop everything around it.
*
* Rung N0. Separating the body of a page from its menus, its footer, its
* cookie banner and its « you may also like » is a solved problem with a name
* — boilerplate removal — and two mature implementations: Mozilla's
* `Readability` here, `trafilatura` in Python. Both work on the tag tree,
* weighing text density against link density, and both run offline in a few
* milliseconds.
*
* They are not the same algorithm and they do not return the same characters.
* The titles differ, the line breaks differ, and one keeps the heading inside
* the body while the other puts it in the title. What they agree on is the
* part that matters, and the test of this entry says it that way: the
* article's sentences are in, the navigation and the footer are out.
*
* Two things this file adds. The first is the refusal to hand back an empty
* string as if it were an answer, and to confuse two situations while doing
* it. A page whose body is built by its own JavaScript arrives here as an
* empty shell, and that is not the same thing as a press brief of a hundred
* and twenty-five characters that came out whole. The two have two reasons:
* one says nothing came out, the other says how short what came out is, and
* says nothing about why.
*
* The second settles a disagreement between the two libraries. On a category
* page — forty links and nothing else — `trafilatura` returns nothing and
* `Readability` returns the link text as though it were an article. So the
* share of the page's text that sits inside links is measured here, in both
* languages, and above half the page is not an article whatever the extractor
* said.
*/
import { Readability } from '@mozilla/readability';
// `Readability` needs a document, and Node has none. `linkedom` builds one
// without a browser engine; `jsdom` would do as well and costs more to start.
import { parseHTML } from 'linkedom';
// The length under which the report says so, in characters. It is a line of
// information handed to the caller, not a verdict: a press brief and the
// documentation of one function are both genuinely below it, and both come
// back whole, with their text and their title. Nothing is dropped and nothing
// is diagnosed because of it.
export const MIN_CHARACTERS = 200;
// Above this share of the page's text inside links, the page is a list of
// links — a category, an index, a tag page — and not an article. Measured
// rather than guessed, because the two extractors do not agree on their own.
export const MAX_LINK_SHARE = 0.5;
/**
* The body of the page, its title, and whether it is worth reading.
*
* `reason` tells a caller that came back empty-handed which of the two
* happened. Nothing at all came out — the page is a shell, and that calls for
* another tool, not for a better extractor. Or something came out and it is
* short, in which case the reason says how short, and nothing else: a short
* page is a short page, not a broken one.
*
* @param {string} html
* @param {{minCharacters?: number}} [options]
*/
export function readArticle(html, { minCharacters = MIN_CHARACTERS } = {}) {
if (typeof html !== 'string') {
return report(null, '', `expected HTML, not ${kindOf(html)}`);
}
let article;
let fallbackTitle = null;
let share = 0;
try {
const { document } = parseHTML(html);
// Both kept before parsing: `Readability` empties the document as it works.
fallbackTitle = document.title || null;
share = linkShare(document);
article = new Readability(document).parse();
} catch {
return report(null, '', 'this page could not be parsed');
}
const title = article?.title || fallbackTitle;
if (share > MAX_LINK_SHARE) {
return report(title, '', 'this page is a list of links, not an article');
}
const text = (article?.textContent ?? '').trim();
if (text.length === 0) {
return report(title, '', 'nothing was extracted: this page may be built by its own JavaScript');
}
if (text.length < minCharacters) {
return report(title, text, `this page is short: ${text.length} characters`);
}
return report(title, text, null);
}
/**
* What share of the page's text sits inside a link.
*
* Both sides are stripped, and the result is capped at one: a link nested
* inside another — which a permissive parser produces on malformed markup —
* is counted twice, and a share above one is not a share.
*/
function linkShare(document) {
for (const element of document.querySelectorAll('script, style')) element.textContent = '';
const total = (document.documentElement?.textContent ?? '').trim().length;
const inside = [...document.querySelectorAll('a')]
.reduce((n, link) => n + (link.textContent ?? '').trim().length, 0);
return total ? Math.min(inside / total, 1) : 0;
}
const kindOf = (value) => (value === null ? 'null' : typeof value);
const report = (title, text, reason) => ({
title, text, characters: text.length, reason,
});Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
Four pages built for this demonstration. The outputs are the snippet’s own, run when the site is built.
A local news article, with everything around it
Given
The menu, the cookie banner, the related items and the footer are dropped.
What it returns
Installée rue des Lilas depuis 1926, la boulangerie Martin a vu passer quatre générations de clients et trois guerres. Son four à bois, classé, tourne encore tous les matins à cinq heures.Clémentine Martin, qui a repris le commerce en 2019, raconte que la farine vient toujours du même moulin, à quarante kilomètres de là, et que la recette du pain de campagne n’a pas bougé d’une ligne depuis son arrière-grand-père.
What it concludes
417 characters kept
The menu, the cookie banner, the related items and the footer are dropped.
« La boulangerie Martin fête ses cent ans »
A category page: forty links and nothing else
Given
More than half the text sits inside links: this is not an article.
What it returns
(0)
What it concludes
Nothing to read
this page is a list of links, not an article
« Boulangeries »
The same article, followed by its comments
Given
Readers’ comments are not part of the article.
What it returns
Installée rue des Lilas depuis 1926, la boulangerie Martin a vu passer quatre générations de clients et trois guerres. Son four à bois, classé, tourne encore tous les matins à cinq heures.Clémentine Martin, qui a repris le commerce en 2019, raconte que la farine vient toujours du même moulin, à quarante kilomètres de là, et que la recette du pain de campagne n’a pas bougé d’une ligne depuis son arrière-grand-père.
What it concludes
417 characters kept
Readers’ comments are not part of the article.
« La boulangerie Martin fête ses cent ans »
A six-word news brief
Given
Below the floor: the report says so instead of returning a short string.
What it returns
La boulangerie Martin a cent ans.
What it concludes
Nothing to read
this page is short: 33 characters
« La boulangerie Martin fête ses cent ans »
A page whose body is built by its own JavaScript
Given
The shell arrives empty.
What it returns
(0)
What it concludes
Nothing to read
nothing was extracted: this page may be built by its own JavaScript
« Application »
Here it breaksThere is nothing to extract: the HTML served holds one empty tag and a script. No extractor can read what the browser has not built yet, and the answer is not a better extractor — it is a headless browser, or the endpoint the site itself uses to fill the page.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- No specific scope added: the page is read inside your own process and is sent to nobody
- Scraping a third-party site is a matter of its terms of use and of applicable law, which the technique used does not change
Breaking point
A page whose body is built by its own JavaScript arrives as an empty shell: the HTML served holds one empty tag and a script, and nothing comes out of it. The witness is in the same test: the same article served as HTML comes back with both its paragraphs, and without the menu or the footer.
When to move up a level
There is no level above this one for that page. When the report says there was nothing to extract, what follows is not a better extractor: it is a headless browser, or the endpoint the site itself uses to fill its page.
The four options
- RecommendedN0Boilerplate removal, with the share of links measuredFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
That is already what this level does. The two libraries named here are not hand-written rules but block classifiers, trained and evaluated on public corpora of annotated pages: text density against link density is a feature set, and the threshold comes from measurement. Adding a “classic model” level would be describing the same thing twice.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A small self-hosted specialised model would have to receive the whole page, hundreds of kilobytes per document, to produce a split that two libraries of a few megabytes produce on a CPU in milliseconds. And it would have nothing more to say about the one case that resists — the empty page its JavaScript has not filled yet — because there is nothing to read in what you would send it.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Cost is the first argument: a news page is hundreds of kilobytes of markup, and cutting it down to fit inside one call already requires knowing what to keep. The second is that the result cannot be checked: an extractor returns pieces of the document, found character for character in the source, where a model returns a text that resembles it. On a corpus you archive, that is the difference between a quotation and a paraphrase.
Further reading
- trafilatura — la bibliothèque Python, et ses évaluations comparées
- Readability — la bibliothèque de Mozilla, telle qu’employée par le mode lecture de Firefox
- linkedom — le moteur de document léger qui permet d’exécuter Readability hors d’un navigateur
- trafilatura — l’évaluation comparée des extracteurs sur des corpus annotés
Where the figures come from
- trafilatura sur PyPI (consulté) : version 2.2.0, publiée le 31 juillet 2026, licence Apache 2.0
- @mozilla/readability sur son dépôt (consulté) : version 0.6.0, publiée le 3 mars 2025, licence Apache 2.0, aucune dépendance ; c’est la bibliothèque du mode lecture de Firefox
- linkedom sur son dépôt (consulté) : version 0.18.13, publiée le 7 juillet 2026, licence ISC ; il fournit le document dont Readability a besoin hors navigateur
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.