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

snippets/extract-main-content-from-a-web-page/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/extract-main-content-from-a-web-page/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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.

  1. 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 »

  2. 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 »

  3. 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 »

  4. 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 »

  5. 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

  1. RecommendedN0Boilerplate removal, with the share of links measuredFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata