Isoler le contenu principal d’une page web
Obtenir le texte d’un article sans le menu, le bandeau de cookies, les articles voisins et le pied de page.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que le problème est résolu, mesuré et livré : `trafilatura` d’un côté, le `Readability` de Mozilla de l’autre. Ce que la fiche ajoute tient en deux règles, et toutes deux existent parce que les bibliothèques se taisent là où il faudrait parler. La première refuse de rendre une chaîne vide comme une réponse : sur une page construite par son JavaScript, le rapport dit qu’il n’y avait rien à prendre. La seconde tranche un désaccord mesuré entre les deux : sur une page de catégorie faite de quarante liens, `trafilatura` ne rend rien et `Readability` rend le texte des liens comme un article — la part du texte à l’intérieur des liens est donc mesurée des deux côtés, et au-delà de la moitié la page n’est pas un article.
Retrait du texte de remplissage, et mesure de la part des liens
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Garder l'article et jeter tout ce qui l'entoure.
Niveau N0. Séparer le corps d'une page de ses menus, de son pied, de son
bandeau de cookies et de son « vous aimerez aussi » est un problème résolu
qui porte un nom — le retrait du texte de remplissage — et qui a deux mises
en œuvre mûres : `trafilatura` ici, le `Readability` de Mozilla en JavaScript. Les deux travaillent sur
l'arbre des balises, en pesant la densité de texte contre la densité de
liens, et les deux tournent hors ligne en quelques millisecondes.
Ce ne sont pas le même algorithme et ils ne rendent pas les mêmes caractères.
Les titres diffèrent, les retours à la ligne diffèrent, et l'un garde le
titre dans le corps quand l'autre le met à part. Ce sur quoi ils s'accordent
est la part qui compte, et le test de cette fiche le dit ainsi : les phrases
de l'article sont dedans, la navigation et le pied de page sont dehors.
Deux choses que ce fichier ajoute. La première est le refus de rendre une
chaîne vide comme si c'était une réponse, et de confondre deux situations en
le faisant. Une page dont le corps est construit par son propre JavaScript
arrive ici en coquille vide, et ce n'est pas la même chose qu'une brève de
presse de cent vingt-cinq caractères sortie entière. Les deux ont deux
raisons : l'une dit que rien n'est sorti, l'autre dit combien ce qui est
sorti est court, et ne dit rien de pourquoi.
La seconde tranche un désaccord entre les deux bibliothèques. Sur une page de
catégorie — quarante liens et rien d'autre —, `trafilatura` ne rend rien et
`Readability` rend le texte des liens comme si c'était un article. La part du
texte de la page qui se trouve à l'intérieur de liens est donc mesurée ici,
dans les deux langages, et au-delà de la moitié la page n'est pas un article,
quoi qu'en ait dit l'extracteur.
"""
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
/**
* Garder l'article et jeter tout ce qui l'entoure.
*
* Niveau N0. Séparer le corps d'une page de ses menus, de son pied, de son
* bandeau de cookies et de son « vous aimerez aussi » est un problème résolu
* qui porte un nom — le retrait du texte de remplissage — et qui a deux mises
* en œuvre mûres : le `Readability` de Mozilla ici, `trafilatura` en Python. Les deux travaillent sur
* l'arbre des balises, en pesant la densité de texte contre la densité de
* liens, et les deux tournent hors ligne en quelques millisecondes.
*
* Ce ne sont pas le même algorithme et ils ne rendent pas les mêmes caractères.
* Les titres diffèrent, les retours à la ligne diffèrent, et l'un garde le
* titre dans le corps quand l'autre le met à part. Ce sur quoi ils s'accordent
* est la part qui compte, et le test de cette fiche le dit ainsi : les phrases
* de l'article sont dedans, la navigation et le pied de page sont dehors.
*
* Deux choses que ce fichier ajoute. La première est le refus de rendre une
* chaîne vide comme si c'était une réponse, et de confondre deux situations en
* le faisant. Une page dont le corps est construit par son propre JavaScript
* arrive ici en coquille vide, et ce n'est pas la même chose qu'une brève de
* presse de cent vingt-cinq caractères sortie entière. Les deux ont deux
* raisons : l'une dit que rien n'est sorti, l'autre dit combien ce qui est
* sorti est court, et ne dit rien de pourquoi.
*
* La seconde tranche un désaccord entre les deux bibliothèques. Sur une page de
* catégorie — quarante liens et rien d'autre —, `trafilatura` ne rend rien et
* `Readability` rend le texte des liens comme si c'était un article. La part du
* texte de la page qui se trouve à l'intérieur de liens est donc mesurée ici,
* dans les deux langages, et au-delà de la moitié la page n'est pas un article,
* quoi qu'en ait dit l'extracteur.
*/
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,
});Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
Quatre pages fabriquées pour cette démonstration. Les sorties sont celles de l’extrait, exécutées à la construction du site.
Un article de presse locale, avec tout ce qui l’entoure
Donné
Le menu, le bandeau de cookies, les articles voisins et le pied de page sont jetés.
Ce qu’il renvoie
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.
Ce qu’il en conclut
417 caractères gardés
Le menu, le bandeau de cookies, les articles voisins et le pied de page sont jetés.
« La boulangerie Martin fête ses cent ans »
Une page de catégorie : quarante liens et rien d’autre
Donné
Plus de la moitié du texte est dans des liens : ce n’est pas un article.
Ce qu’il renvoie
(0)
Ce qu’il en conclut
Rien à lire
this page is a list of links, not an article
« Boulangeries »
Le même article, suivi de ses commentaires
Donné
Les commentaires des lecteurs ne font pas partie de l’article.
Ce qu’il renvoie
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.
Ce qu’il en conclut
417 caractères gardés
Les commentaires des lecteurs ne font pas partie de l’article.
« La boulangerie Martin fête ses cent ans »
Une brève de six mots
Donné
Sous le plancher : le rapport le dit au lieu de rendre une chaîne courte.
Ce qu’il renvoie
La boulangerie Martin a cent ans.
Ce qu’il en conclut
Rien à lire
this page is short: 33 characters
« La boulangerie Martin fête ses cent ans »
Une page dont le corps est construit par son JavaScript
Donné
La coquille arrive vide.
Ce qu’il renvoie
(0)
Ce qu’il en conclut
Rien à lire
nothing was extracted: this page may be built by its own JavaScript
« Application »
Là, ça casseIl n’y a rien à extraire : le HTML servi ne contient qu’une balise vide et un script. Aucun extracteur ne peut lire ce que le navigateur n’a pas encore construit, et la réponse n’est pas un meilleur extracteur — c’est un navigateur sans affichage, ou le point d’accès que le site utilise lui-même pour remplir sa page.
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : la page est lue dans votre processus et n’est transmise à personne
- L’aspiration d’un site tiers relève de ses conditions d’utilisation et du droit applicable, que la technique employée ne change pas
Point de rupture
Une page dont le corps est construit par son propre JavaScript arrive en coquille vide : le HTML servi ne porte qu’une balise vide et un script, et rien n’en sort. Le témoin est dans le même test : le même article servi en HTML ressort avec ses deux paragraphes, et sans le menu ni le pied de page.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus pour cette page-là. Quand le rapport dit qu’il n’y avait rien à extraire, la suite n’est pas un meilleur extracteur : c’est un navigateur sans affichage, ou le point d’accès que le site emploie lui-même pour remplir sa page.
Les quatre options
- RecommandéN0Retrait du texte de remplissage, et mesure de la part des liensGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
C’est déjà ce que fait ce niveau. Les deux bibliothèques nommées ici ne sont pas des règles écrites à la main mais des classifieurs de blocs, entraînés et évalués sur des corpus publics de pages annotées : la densité de texte contre la densité de liens est un jeu de traits, et le seuil vient de la mesure. Ajouter un niveau « modèle classique » reviendrait à décrire deux fois la même chose.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Un petit modèle spécialisé auto-hébergé devrait recevoir la page entière, soit des centaines de kilooctets par document, pour produire un découpage que deux bibliothèques de quelques mégaoctets produisent sur un processeur en quelques millisecondes. Et il n’aurait rien de plus à dire sur le seul cas qui résiste — la page vide que son JavaScript n’a pas encore remplie —, parce qu’il n’y a rien à lire dans ce qu’on lui enverrait.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Le coût est le premier argument : une page de presse fait des centaines de kilooctets de balisage, et la découper pour tenir dans un appel demande déjà de savoir quoi garder. Le second est qu’on ne peut pas vérifier le résultat : un extracteur rend des morceaux du document, qu’on retrouve caractère pour caractère dans la source, là où un modèle rend un texte qui lui ressemble. Sur un corpus qu’on archive, la différence est celle entre une citation et une paraphrase.
Pour aller plus loin
- 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
Source des chiffres
- 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
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.