Retour au blog
Guides
Mihai MaximLast updated on May 12, 202627 min read

Python Extraire du texte à partir de HTML

Python Extraire du texte à partir de HTML
En bref : pour extraire du texte d'un fichier HTML en Python, analysez le balisage à l'aide d'un véritable analyseur syntaxique (BeautifulSoup, lxml.htmlou html-text), supprimez les scripts, les styles et les éléments de présentation du site, puis normalisez les espaces et l'Unicode avant d'enregistrer. Ce guide compare les principales bibliothèques, corrige les pièges courants du nettoyage et se termine par un crawler fonctionnel qui génère du JSONL ainsi que des .txt .

Introduction

La plupart des équipes qui souhaitent extraire du texte d’un fichier HTML en Python commencent par une ligne de code, se heurtent à un mur dès qu’une vraie page s’affiche, puis passent un après-midi à découvrir que get_text() renvoie joyeusement du JavaScript, des bannières de cookies et 47 occurrences du mot « S'abonner ». La solution ne réside pas dans une autre bibliothèque magique. Il s'agit d'un workflow clair : analyser, nettoyer, extraire, normaliser, enregistrer.

Le HTML est le code source d’une page web. Il mélange le contenu réel que vous recherchez (titres, paragraphes, éléments de liste) avec des balises structurelles, des scripts, des styles et des métadonnées dont le navigateur a besoin, mais pas vous. Le texte extrait est la partie visible et lisible par l’homme de cette page, une fois les balises supprimées. Tout ce qui parcourt le DOM (l’arborescence de nœuds qu’un analyseur construit à partir du HTML brut) peut le faire si vous lui indiquez quels nœuds conserver.

Ce guide s'adresse aux développeurs Python, aux ingénieurs de données et aux praticiens du TALN qui recherchent du code exécutable, des valeurs par défaut raisonnables et des compromis honnêtes. Nous comparerons les bibliothèques qui comptent vraiment (BeautifulSoup, lxml.html plus html-text, Parsel et les expressions régulières), créer des utilitaires de nettoyage et de normalisation que vous pourrez réutiliser, puis assembler ces éléments pour former un petit robot d'indexation. Les pages rendues en JavaScript, les pièges liés à l'encodage et un tableau de dépannage « symptôme-solution » seront abordés au fil de la lecture.

Que signifie réellement « extraire du texte HTML en Python » ?

Lorsque vous dites que vous voulez extraire du texte d’un fichier HTML avec Python, vous voulez en réalité dire : parcourir le document analysé, conserver les nœuds de texte visibles et jeter tout le reste. Les navigateurs le font implicitement chaque fois qu’ils affichent une page. En tant que développeurs, nous devons être explicites.

Quelques définitions méritent d'être précisées pour que la suite de l'article ait du sens :

  • Le HTML est la source brute : balises, attributs, styles en ligne, scripts et métadonnées, ainsi que le contenu réel pris en sandwich entre eux.
  • Les balises sont des marqueurs individuels tels que <p> et </p>. Les éléments sont des balises plus tout ce qui se trouve à l'intérieur.
  • Le DOM (Document Object Model) est l'arborescence qu'un analyseur syntaxique construit à partir de cette source. Chaque élément, attribut et nœud de texte devient un nœud dans l'arborescence.
  • Le texte extrait correspond au contenu lisible par l'utilisateur au niveau des feuilles : titres, paragraphes, éléments de liste, étiquettes, une fois le balisage supprimé.

L'extraction de texte fonctionne en parcourant ce DOM et en ne collectant que les nœuds de texte, tout en ignorant des éléments tels que <script> et <style>. Différentes bibliothèques exposent ce parcours de manière différente, mais le modèle mental reste le même. Si vous gardez à l’esprit que l’analyse, le nettoyage, l’extraction et la normalisation constituent quatre étapes distinctes, vous pouvez passer de BeautifulSoup à lxml, html-textet même des piles non Python sans avoir à réapprendre le problème.

La raison pour laquelle vous extrayez du texte a également son importance. Un index de recherche peut tolérer une simple chaîne de caractères plate. Un pipeline d’ingestion LLM souhaite généralement que les paragraphes soient conservés. Une exportation à des fins d’analyse voudra probablement que les titres et le corps du texte soient séparés. Décidez-en dès le début, car cela détermine quelle bibliothèque et quelle stratégie d’extraction sont les plus pertinentes.

Choisir une bibliothèque : BeautifulSoup, lxml, html-text, Parsel ou regex

Il n'y a pas de réponse unique « idéale » pour l'extraction de texte HTML en Python, mais il existe de bons choix par défaut et de mauvais choix. Voici comment les principales options se positionnent dans la pratique.

BeautifulSoup (bs4) est le point de départ habituel. Il est tolérant envers le code HTML incorrect, dispose d’une interface API réduite (find, find_all, select, get_text) et est accessible aux lecteurs qui n’ont jamais utilisé XPath. C’est le bon choix pour le scraping ponctuel, les prototypes et la plupart des tâches de production qui ne sont pas limitées par la vitesse de l’analyseur. Les deux pièges courants sont d’oublier de supprimer <script> et <style> avant d'appeler get_text(), et de laisser le html.parser maintien en place alors qu'ils pourraient installer lxml et passer 'lxml'.

lxml.html est l'option rapide, stricte et basée sur le C. Elle utilise libxml2 en arrière-plan, expose à la fois les sélecteurs CSS et XPath, et c'est ce vers quoi vous vous tournez lorsque vous analysez des milliers de pages par minute ou que vous avez besoin d'une manipulation précise du DOM. Le compromis est une courbe d'apprentissage légèrement plus raide et une tolérance moindre pour le balisage mal formé que BeautifulSoup. Selon la documentation de lxml, il peut analyser du HTML corrompu grâce à son html module, mais BeautifulSoup reste plus indulgent lorsque l'entrée est vraiment chaotique.

html-text est un petit utilitaire qui s'appuie sur lxml et produit du texte brut propre avec une gestion judicieuse des espaces. C'est le bon choix lorsque vous souhaitez principalement « extraire du texte lisible de ce bloc » avec un post-traitement minimal, et que vous n'avez pas besoin de requêtes complexes. Il n'isole pas de manière fiable le corps principal de l'article par lui-même, il s'associe donc bien avec un <main> ou <article> sélecteur.

Parsel est la bibliothèque riche en sélecteurs qui alimente Scrapy. Elle excelle lorsque vous souhaitez des champs structurés (titre, prix, auteur) via CSS ou XPath, et non lorsque vous souhaitez nettoyer un bloc de texte. Au moment de la rédaction de cet article, son rythme de publication publique est relativement calme ; vérifiez donc que la version sur PyPI convient toujours à votre pile avant de l'adopter pour un nouveau projet.

Regex n'est pas un analyseur syntaxique. Utilisez-le pour nettoyer des chaînes déjà extraites (NBSP, espaces enchaînés, guillemets typographiques) et acceptez que toute tentative de faire correspondre du HTML imbriqué avec re échouera dès que le balisage deviendra complexe.

Tableau comparatif et règles de décision

Bibliothèque

Idéale pour

Avantages

Inconvénients

Appel type

BeautifulSoup

La plupart des tâches de scraping et d'analyse

API intuitive et tolérante, documentation de qualité

Plus lent que lxml sur de très gros volumes

BeautifulSoup(html, 'lxml').get_text(' ')

lxml.html

Grands volumes, XPath, manipulation avancée du DOM

Très rapide, strict, prise en charge de XPath

Moins tolérant envers le HTML corrompu

lxml.html.fromstring(html).text_content()

html-text

Texte brut propre avec un minimum d'effort

Heuristiques d'espaces et de visibilité intégrées

Pas de sélection de contenu en soi

html_text.extract_text(html)

Parsel

Extraction de champs structurés

Combinaison de CSS et XPath, compatible avec Scrapy

Rythme de publication plus lent, surdimensionné pour le texte brut

Selector(text=html).xpath('//p//text()')

Regex

Petit nettoyage sur le texte déjà extrait

Intégré, rapide sur les chaînes courtes

Plante sur du HTML imbriqué ou incohérent

re.sub(r'\s+', ' ', text)

Règles de décision rapide : si vous débutez dans le scraping, commencez par BeautifulSoup. Si vous avez seulement besoin de texte propre sans requêtes, optez pour html-text. Si vous analysez des dizaines de milliers de pages ou si vous avez besoin d'XPath, optez pour lxml.html. Si vous avez davantage besoin de champs de saisie que de texte, utilisez Parsel. Considérez les expressions régulières comme un outil de nettoyage, jamais comme un analyseur.

Un exemple de code HTML réutilisable pour chaque exemple

Tous les exemples ci-dessous utilisent le même extrait de code désordonné afin que vous puissiez comparer les bibliothèques de manière équitable. Enregistrez-le sous sample.html ou attribuez-le à une chaîne :

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>How to brew filter coffee</title>
  <style>.ad{color:red}</style>
  <script>window.analytics={track:()=>{}}</script>
</head>
<body>
  <header><nav>Home &middot; Recipes &middot; About</nav></header>
  <aside class="ad">Buy our new grinder!</aside>
  <main>
    <article>
      <h1>How&nbsp;to brew filter coffee</h1>
      <p>Start with <strong>fresh beans</strong> ground medium-coarse.</p>
      <ul>
        <li>Use a 1:16 ratio.</li>
        <li>Bloom for 30&nbsp;seconds.</li>
      </ul>
      <p class="hidden">Secret affiliate link block.</p>
      <div aria-hidden="true">Hidden cookie banner copy.</div>
    </article>
  </main>
  <footer>&copy; 2026 Coffee Co. Privacy. Terms.</footer>
</body>
</html>

Il présente les quatre problèmes classiques : une balise script et une balise style, des éléments de mise en page (<header>, <nav>, <footer>, une publicité <aside>), un espace insécable dans le texte et deux blocs masqués (.hidden et [aria-hidden="true"]). Si une bibliothèque gère cela proprement, elle gérera la plupart des cas que vous lui présenterez en situation réelle.

Extraire du texte avec BeautifulSoup (étape par étape)

BeautifulSoup est la bibliothèque par défaut pour une bonne raison : son API est concise, ses modes de défaillance sont évidents, et les quatre mêmes étapes couvrent presque toutes les tâches d'extraction de texte à partir de HTML en Python.

Installez les bases :

pip install beautifulsoup4 lxml requests

Nous utilisons lxml comme backend d'analyse. Le 'lxml' backend est généralement considéré comme plus rapide et plus strict que celui de la bibliothèque standard html.parser, bien que l'écart exact dépende de la taille de l'entrée et de la structure du document ; effectuez un benchmark sur vos propres données si cela a de l'importance.

Étape 1 : analysez le code avec un véritable analyseur. N'utilisez jamais d'expressions régulières sur du code HTML complet. Transmettez d'abord le balisage à BeautifulSoup.

import requests
from bs4 import BeautifulSoup

resp = requests.get("https://example.com/coffee", timeout=20.0)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")

Étape 2 : éliminez le bruit évident. Les scripts et les feuilles de style ne sont que du bruit pour l'extraction de texte. Supprimez-les avant toute autre chose, sinon leur contenu se retrouvera directement dans votre sortie.

for tag in soup(["script", "style", "noscript"]):
    tag.decompose()

Utilisez decompose() plutôt que extract() ou unwrap() lorsque vous souhaitez supprimer la balise et ses enfants. extract() supprime le nœud mais vous conservez une référence ; unwrap() conserve le contenu. Pour le bruit, decompose() c'est ce qu'il vous faut.

Étape 3 : extraire le texte. get_text() aplatit le DOM restant en une seule chaîne. Les deux arguments importants sont separator et strip. Sans séparateur, BeautifulSoup fusionne les éléments en ligne adjacents, de sorte que <strong>fresh</strong>beans deviendrait freshbeans. Passez un espace (ou un retour à la ligne) pour séparer les mots, et strip=True pour supprimer les espaces autour de chaque nœud.

text = soup.get_text(separator=" ", strip=True)

Étape 4 : nettoyage léger. À ce stade, vous disposez d'un texte brut qui contient encore des espaces irréguliers, des espaces insécables et éventuellement plusieurs lignes vides. Confiez la normalisation à une fonction d'aide dédiée (voir la section sur la normalisation plus loin) et concentrez cette étape sur l'extraction.

L'application des quatre étapes à notre exemple donne un résultat similaire à ceci :

Home · Recipes · About Buy our new grinder! How to brew filter coffee Start with fresh beans ground medium-coarse. Use a 1:16 ratio. Bloom for 30 seconds. Secret affiliate link block. Hidden cookie banner copy. © 2026 Coffee Co. Privacy. Terms.

Les scripts et les styles ont disparu, mais la mise en page, les publicités et le contenu masqué apparaissent encore. C'est le problème que les sections suivantes vont résoudre.

Extraction de texte propre avec lxml.html et html-text

Lorsque vous n'avez pas besoin de la convivialité de BeautifulSoup et que vous recherchez la rapidité, lxml.html plus html-text constitue une combinaison puissante. lxml vous fournit l'arborescence analysée ; html-text vous fournit un texte bien normalisé à partir de celui-ci sans que vous ayez à écrire votre propre parcours.

pip install lxml html-text

Une version minimale lxml.htmlde la même extraction ressemble à ceci :

import lxml.html

tree = lxml.html.fromstring(html_source)
for tag in tree.xpath("//script | //style | //noscript"):
    tag.drop_tree()
text = tree.text_content()

text_content() parcourt le DOM et concatène les nœuds de texte, mais n'ajoute pas de séparateurs entre les éléments de niveau bloc. Les titres, les paragraphes et les éléments de liste finissent par être collés les uns aux autres. C'est exactement la lacune que html-text comble.

import html_text

text = html_text.extract_text(html_source)

En interne, html-text analyse avec lxml, applique certaines heuristiques concernant le contenu masqué (il examine des modèles courants tels que display:none, aria-hiddenet les noms de classes conventionnels), puis insère des espaces là où les éléments de niveau bloc créeraient visuellement des sauts de ligne. Le résultat est bien plus proche de ce que l'utilisateur voit dans un navigateur que le code brut text_content().

Il convient d'être honnête quant à ses limites. html-textLes heuristiques de visibilité de sont basées sur des modèles, et non sur le rendu du navigateur. Les styles en ligne définis via CSS dans une feuille de style externe, les attributs hidden ou les commutateurs de test A/B sont invisibles pour un analyseur statique. Si vous avez besoin d’une visibilité telle qu’elle est réellement rendue, vous aurez besoin d’un navigateur sans interface graphique, dont nous parlerons plus tard.

html-text ne sépare pas non plus l'article principal de lui-même. Il émettra volontiers la navigation et le pied de page si vous lui fournissez la page complète. Combinez-le avec un <main> ou <article> sélecteur (tree.cssselect('main')[0]) lorsque vous souhaitez une sortie contenant uniquement le corps. Cette combinaison, lxml pour la sélection et html-text pour le dump de texte, est l'une des méthodes les plus propres pour extraire du texte HTML à grande échelle en Python.

Quand (et seulement quand) utiliser les expressions régulières pour le nettoyage

Tous les quelques mois, quelqu'un poste « pourquoi je ne peux pas simplement re.sub('<[^>]+>', '', html)? » et, tous les quelques mois, la réponse est la même : parce que le HTML est imbriqué, mal formé et regorge de cas limites que les expressions régulières ne peuvent pas modéliser. Les contre-exemples classiques sont les balises non fermées, les commentaires contenant > à l'intérieur, les blocs CDATA et les attributs contenant des crochets angulaires entre guillemets. Il existe également une célèbre réponse sur Stack Overflow à ce sujet qui vaut le détour.

La bonne méthode est la suivante : analysez le code avec un véritable parseur, puis laissez les expressions régulières peaufiner le texte brut obtenu. Une fois que BeautifulSoup ou html-text vous a fourni une chaîne de caractères, les expressions régulières conviennent parfaitement pour des tâches telles que :

import re
import unicodedata

text = unicodedata.normalize("NFKC", text)
text = text.replace("\u00a0", " ")          # NBSP -> space
text = re.sub(r"[\u2018\u2019]", "'", text) # smart single quotes
text = re.sub(r"[\u201c\u201d]", '"', text) # smart double quotes
text = re.sub(r"[ \t]+", " ", text)         # collapse runs of spaces
text = re.sub(r"\n{3,}", "\n\n", text)      # collapse blank-line runs

À éviter : supprimer des balises avec des expressions régulières, extraire des valeurs d’attributs du HTML brut avec des expressions régulières, et scinder sur < et > pour « extraire le texte ». Ces méthodes fonctionnent sur une démo écrite à la main, mais échouent en production. Si jamais vous êtes tenté, écrivez d'abord la version basée sur un analyseur syntaxique et n'utilisez les expressions régulières que sur la chaîne déjà aplatie qu'il produit.

Nettoyage de HTML réel : navigation, pieds de page, publicités, bannières de cookies, blocs cachés

Le résultat obtenu à partir du tutoriel BeautifulSoup contenait toujours la barre de navigation, un bloc publicitaire, un paragraphe d'affiliation masqué, une aria-hidden bannière de cookies et le pied de page. Rien de tout cela n’est utile pour l’indexation ou l’analyse. Nettoyer tout cela avant l’extraction est le gain de qualité le plus important que vous puissiez obtenir lorsque vous extrayez du texte HTML en Python.

La méthode est la suivante : analyser, supprimer les scripts et les styles, supprimer les éléments de mise en page, supprimer le contenu masqué, puis appeler get_text().

from bs4 import BeautifulSoup

NOISE_TAGS = ["script", "style", "noscript", "template", "svg"]
CHROME_SELECTOR = (
    "header, footer, nav, aside, "
    ".cookie-banner, .cookie, .consent, .gdpr, "
    ".ad, .ads, .advert, .promo, .newsletter, "
    ".social-share, .related, .breadcrumbs"
)
HIDDEN_SELECTOR = (
    ".hidden, .visually-hidden, .sr-only, "
    "[aria-hidden='true'], [hidden], "
    "[style*='display:none'], [style*='visibility:hidden']"
)

def clean(soup):
    for tag in soup(NOISE_TAGS):
        tag.decompose()
    for tag in soup.select(CHROME_SELECTOR):
        tag.decompose()
    for tag in soup.select(HIDDEN_SELECTOR):
        tag.decompose()
    return soup

L'ordre des opérations est important. Supprimez d'abord les scripts et les styles, car ils se trouvent souvent à l'intérieur des éléments que vous vous apprêtez à interroger, et les supprimer en premier garantit la fiabilité de vos sélecteurs. Supprimez ensuite les éléments de mise en page par nom de balise. Les sélecteurs par nom de classe viennent ensuite, car ils constituent la partie la plus fragile : chaque site nomme les éléments différemment, et vous devrez ajuster cette liste en fonction de la source.

Pourquoi decompose() et non extract()? decompose() supprime le nœud et tous ses enfants de l'arborescence et libère leurs références. extract() supprime le nœud mais le renvoie, ce qui est utile lorsque vous souhaitez déplacer un nœud ailleurs, et non lorsque vous supprimez du bruit. Pour le nettoyage, utilisez toujours decompose().

Après avoir exécuté clean(soup) sur notre exemple, puis en appelant soup.get_text(separator="\n", strip=True), vous obtenez quelque chose de proche de ce que voit réellement un lecteur :

How to brew filter coffee
Start with fresh beans ground medium-coarse.
Use a 1:16 ratio.
Bloom for 30 seconds.

C'est l'objectif : les titres et les paragraphes qui intéressent l'utilisateur, avec tout le contenu standard supprimé. Considérez les sélecteurs chrome et hidden ci-dessus comme un kit de départ, et non comme une liste exhaustive ; chaque domaine que vous explorez ajoutera une ou deux nouvelles classes que vous devrez supprimer.

Isoler le contenu principal à l'aide de sélecteurs et d'heuristiques de lisibilité

La suppression des éléments de présentation fonctionne, mais lorsque le balisage est bien structuré, l'approche la plus propre consiste à extraire directement le contenu principal. Le HTML moderne vous offre trois bons points d'ancrage :

main = (
    soup.select_one("main")
    or soup.select_one("article")
    or soup.select_one("[role='main']")
)
if main is None:
    main = soup.body or soup
text = main.get_text(separator="\n", strip=True)

Cette échelle de repli, <main>, <article>, role="main", puis <body>, couvre la plupart des sites de contenu. Si vous nettoyez également la sous-arborescence résultante à l'aide des sélecteurs chrome et hidden de la section précédente, vous obtenez généralement du texte contenant uniquement le corps sans avoir à écrire de règles personnalisées pour chaque site.

Lorsque le balisage est médiocre (pensez aux anciens modèles de CMS sans balises sémantiques), utilisez readability-lxml ou trafilatura. Les deux appliquent des heuristiques de densité de texte : ils évaluent chaque bloc en fonction du rapport entre le texte et le balisage ainsi que de la densité des liens, et renvoient la région ayant obtenu le score le plus élevé comme article principal. Aucun n’est parfait ; ils extraient parfois une section de commentaires ou manquent une note de bas de page dans la barre latérale. Considérez-les comme une solution de secours lorsque les sélecteurs structurels échouent, et non comme la méthode par défaut.

Normalisation du texte : espaces, NBSP, sauts de ligne et Unicode

La sortie brute de get_text() est rarement « propre ». Vous verrez des espaces insécables (\u00a0) là où vous vous attendiez à de vrais espaces, des \r\n des fins de ligne sur les pages créées sous Windows, des séries de trois ou quatre lignes vides provenant de modèles CMS généreux, et parfois des caractères katakana ou des ligatures de demi-largeur, gracieusement offerts par Unicode. Un petit normalisateur dédié corrige tout cela en une fois et vous fait gagner du temps lors du débogage ultérieur.

import re
import unicodedata

def normalize_text(text: str) -> str:
    # 1. Unicode-canonical form
    text = unicodedata.normalize("NFKC", text)
    # 2. NBSP and other exotic spaces -> regular space
    text = text.replace("\u00a0", " ").replace("\u200b", "")
    # 3. Normalize line endings
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    # 4. Strip per-line whitespace
    lines = [line.strip() for line in text.split("\n")]
    # 5. Collapse internal runs of spaces and tabs
    lines = [re.sub(r"[ \t]+", " ", line) for line in lines]
    # 6. Collapse runs of blank lines down to one blank line
    out, blank_run = [], 0
    for line in lines:
        if line == "":
            blank_run += 1
            if blank_run <= 1:
                out.append(line)
        else:
            blank_run = 0
            out.append(line)
    return "\n".join(out).strip()

Quelques remarques sur ce que chaque étape vous apporte. unicodedata.normalize("NFKC", ...) regroupe les caractères de compatibilité en leurs équivalents canoniques, de sorte que le caractère pleine largeur devient un A et les ligatures telles que deviennent fi. La documentation Python sur le module unicodedata explique en détail ce que fait chaque forme.

Il est important de supprimer les NBSP dès le début, car re.sub(r"\s+", ...) correspond bien \u00a0 dans le Python moderne, mais ce n'est souvent pas le cas des analyseurs syntaxiques et des indexeurs de recherche en aval. La normalisation des fins de ligne empêche un simple \r de casser les fichiers JSONL. La compression des séquences de blancs permet de conserver les sauts de paragraphe sans générer des pages de lignes vides.

Exécutez cet utilitaire une seule fois à la fin de votre pipeline, jamais à l'intérieur de la boucle par balise, et vous obtiendrez un texte que les outils en aval pourront réellement exploiter.

Extraction tenant compte de la structure : paragraphes, titres et listes sous forme de blocs

Une simple chaîne de caractères plate convient pour la recherche et l'analyse sommaire, mais elle est inadaptée au découpage pour la recherche et la récupération (RAG), à la synthèse et à tout ce qui tient compte de la hiérarchie. Si votre consommateur en aval a intérêt à distinguer les titres du corps du texte, générez des blocs typés plutôt qu'une seule grande chaîne.

BLOCK_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "blockquote", "td", "pre"}

def extract_blocks(soup):
    blocks = []
    for el in soup.find_all(list(BLOCK_TAGS)):
        text = el.get_text(separator=" ", strip=True)
        if not text:
            continue
        kind = "heading" if el.name.startswith("h") else "body"
        blocks.append({
            "kind": kind,
            "tag": el.name,
            "text": text,
        })
    return blocks

Sur notre article d'exemple, cela donne quelque chose comme :

[
  {"kind": "heading", "tag": "h1", "text": "How to brew filter coffee"},
  {"kind": "body",    "tag": "p",  "text": "Start with fresh beans ground medium-coarse."},
  {"kind": "body",    "tag": "li", "text": "Use a 1:16 ratio."},
  {"kind": "body",    "tag": "li", "text": "Bloom for 30 seconds."},
]

Pourquoi s'embêter ? Pour trois raisons. Premièrement, un outil de découpage LLM peut conserver les titres avec les paragraphes qui les suivent au lieu de les séparer. Deuxièmement, les requêtes d'analyse peuvent compter les titres séparément du corps du texte, ce qui est important pour les audits de contenu. Troisièmement, vous pouvez regrouper les titres dans un plan (# How to brew filter coffee) et conserver le corps du texte en dessous, ce qui vous offre gratuitement un résultat de type Markdown.

Si vous devez préserver l'ordre et l'imbrication (un titre et ses paragraphes descendants formant une section), itérez en utilisant soup.descendants et regroupez les blocs chaque fois que vous rencontrez une balise de titre. La structure est peu coûteuse à conserver mais coûteuse à reconstruire par la suite, il est donc préférable de la capturer une seule fois au moment de l'extraction.

Mini-projet de bout en bout : exploration, extraction, normalisation et enregistrement

Il est temps de tout assembler. Le script ci-dessous explore une section paginée d'un site, extrait le texte brut par page, le normalise et écrit un enregistrement JSONL par page ainsi qu'un fichier .txt . Il utilise un seul requests.Session, suit le Next lien de pagination et s'arrête à un max_pages.

import json
import re
import time
import unicodedata
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": "text-extractor/1.0 (+contact@example.com)",
    "Accept": "text/html,application/xhtml+xml",
}
NOISE_TAGS = ["script", "style", "noscript", "template", "svg"]
CHROME = "header, footer, nav, aside, .cookie-banner, .ad, .related, .newsletter"
HIDDEN = ".hidden, [aria-hidden='true'], [hidden]"

def fetch_soup(session, url):
    resp = session.get(url, headers=HEADERS, timeout=20.0)
    resp.raise_for_status()
    if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
        resp.encoding = resp.apparent_encoding
    return BeautifulSoup(resp.text, "lxml")

def clean(soup):
    for tag in soup(NOISE_TAGS):
        tag.decompose()
    for tag in soup.select(CHROME):
        tag.decompose()
    for tag in soup.select(HIDDEN):
        tag.decompose()
    return soup

def main_subtree(soup):
    return (
        soup.select_one("main")
        or soup.select_one("article")
        or soup.select_one("[role='main']")
        or soup.body
        or soup
    )

def normalize_text(text: str) -> str:
    text = unicodedata.normalize("NFKC", text)
    text = text.replace("\u00a0", " ").replace("\u200b", "")
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = "\n".join(line.strip() for line in text.split("\n"))
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()

def extract(soup):
    cleaned = clean(soup)
    body = main_subtree(cleaned)
    title = soup.title.get_text(strip=True) if soup.title else ""
    raw = body.get_text(separator="\n", strip=True)
    return title, normalize_text(raw)

def crawl(start_url: str, out_dir: Path, max_pages: int = 25):
    out_dir.mkdir(parents=True, exist_ok=True)
    jsonl_path = out_dir / "pages.jsonl"
    session = requests.Session()
    url, count = start_url, 0
    with jsonl_path.open("w", encoding="utf-8") as out:
        while url and count < max_pages:
            try:
                soup = fetch_soup(session, url)
            except requests.RequestException as exc:
                print(f"[skip] {url}: {exc}")
                break
            title, text = extract(soup)
            record = {"url": url, "title": title, "text": text}
            out.write(json.dumps(record, ensure_ascii=False) + "\n")
            (out_dir / f"page-{count:03d}.txt").write_text(text, encoding="utf-8")
            next_link = soup.select_one("ul.pager li.next a")
            url = urljoin(url, next_link["href"]) if next_link else None
            count += 1
            time.sleep(1.0)  # be polite
    return count

if __name__ == "__main__":
    pages = crawl(
        start_url="https://example.com/blog/",
        out_dir=Path("out"),
        max_pages=10,
    )
    print(f"Saved {pages} pages")

Les éléments sont délibérément petits. Remplacez fetch_soup par un récupérateur Playwright lorsque vous tombez sur des pages rendues en JavaScript. Remplacez le sélecteur de pagination par celui utilisé par votre site cible. Remplacez l’enregistreur JSONL par une insertion SQLite si vous souhaitez un stockage interrogeable. Le schéma (parser, nettoyer, extraire, normaliser, enregistrer) reste identique.

Deux petits détails à retenir. L' fetch_soup() helper applique un délai d'expiration de 20 secondes pour les requêtes et se rabat sur apparent_encoding lorsque le serveur renvoie la valeur par défaut iso-8859-1. Ces deux éléments sont peu coûteux à ajouter maintenant, mais difficiles à intégrer a posteriori. Le time.sleep(1.0) entre les pages est le minimum de courtoisie ; pour un crawling sérieux, consultez la section sur la mise à l'échelle ci-dessous.

Formats de sortie : JSONL vs CSV vs texte brut vs base de données

Adaptez le format de stockage à l'utilisateur final, et non à ce que vous avez saisi en premier.

  • JSONL (un objet JSON par ligne) est le format par défaut pour les pipelines de scraping. Il est compatible avec les flux, en ajout seul, facile à inspecter head -n 1 pages.jsonl | jq .et tolère l’évolution de la structure des enregistrements. Utilisez-le lorsque les enregistrements comportent plusieurs champs ou une structure imbriquée.
  • Le CSV est le bon choix lorsque les consommateurs en aval sont des feuilles de calcul, des pandas ou des outils de BI. Tenez-vous-en à un schéma plat avec des colonnes prévisibles et écrivez avec csv.DictWriter afin de ne pas avoir à mettre quoi que ce soit entre guillemets manuellement.
  • Le texte brut (.txt) par page est idéal pour le NLP, l’indexation de recherche et l’ingestion de LLM. Un fichier par document facilite la gestion avec Git et vous permet de traiter les pages en parallèle sans avoir à structurer les enregistrements.
  • SQLite ou DuckDB est le bon choix si vous souhaitez effectuer des requêtes ad hoc (« combien de pages mentionnent l’espresso ? ») ou des jointures avec d’autres tables. Les deux sont fournis sous forme de base de données à fichier unique ne nécessitant aucune configuration.

En pratique, le pipeline ci-dessus écrit simultanément en JSONL et par page .txt simultanément. Le JSONL constitue votre index de métadonnées ; les .txt fichiers sont ce que vous transmettez à l'étape suivante.

Pièges liés à l'encodage, au jeu de caractères et au balisage corrompu

Les bogues d’encodage sont la deuxième cause la plus fréquente pour laquelle un pipeline Python d’extraction de texte à partir de HTML renvoie des données erronées. Les symptômes classiques sont é l'apparition de caractères inattendus é, des caractères de remplacement () au milieu des paragraphes, ou le redoutable UnicodeDecodeError sur resp.text.

La cause principale est presque toujours que requests par défaut iso-8859-1 car la réponse ne comportait pas de jeu de caractères dans son Content-Type en-tête. La requests documentation le signale : lorsqu'aucun encodage n'est spécifié, iso-8859-1 est supposé. Remplacez-le :

resp = session.get(url, timeout=20.0)
if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
    resp.encoding = resp.apparent_encoding  # chardet-style sniff
html = resp.text

Pour les octets bruts, décodez explicitement et passez errors="replace" pour maintenir le pipeline en fonctionnement en cas d'entrée incorrecte :

html = resp.content.decode("utf-8", errors="replace")

Il y a ensuite le balisage incorrect lui-même. lxml est strict ; il ignorera ou rééquilibrera silencieusement les parties d'une entrée gravement mal formée. BeautifulSoup avec le paramètre par défaut html.parser est plus indulgent mais plus lent. Si vos données sont un mélange de HTML propre et de HTML mal formé, essayez BeautifulSoup(html, "html5lib"), qui est le backend le plus indulgent et suit le même algorithme d'analyse que les navigateurs. Le compromis est la vitesse : html5lib est nettement plus lent que lxml sur les documents volumineux, alors réservez-le à la minorité de données mal formées.

Gestion des pages rendues par JavaScript

Tôt ou tard, vous récupérerez une page, la viderez resp.textet vous trouverez un <div id="root"> là où le contenu devrait se trouver. Le site affiche son contenu côté client avec React, Vue ou un framework similaire, et requests n'exécute pas JavaScript. Aucune extraction, aussi ingénieuse soit-elle, ne permettra de résoudre ce problème.

Trois options réalistes :

  1. Recherchez un point de terminaison pré-rendu ou via une API. De nombreuses applications SPA s’alimentent à partir d’une API JSON que le navigateur appelle au chargement. Ouvrez DevTools, observez l’onglet Réseau, et vous trouverez souvent un point de terminaison structuré qui renvoie exactement ce dont vous avez besoin, sans aucune analyse HTML.
  2. Exécutez un navigateur sans interface graphique. Playwright, Pyppeteer, et Selenium tous lancent de véritables moteurs de navigateur (Chromium, Firefox, WebKit) qui exécutent du JavaScript. Le compromis porte sur la complexité et l'utilisation des ressources : chaque page vous coûte un onglet dans un navigateur réel, ce qui est bien plus coûteux qu'un requests appel.
  3. Utilisez une API de scraping qui renvoie du HTML rendu. Les services qui gèrent le rendu sans interface graphique à votre place acceptent une URL et renvoient le DOM final sous forme de chaîne de caractères, qui s'intègre directement dans le pipeline BeautifulSoup ci-dessus. Vous renoncez à une partie du contrôle sur les paramètres du navigateur ; vous gagnez en simplicité d'infrastructure et en débit constant.

Un récupérateur Playwright minimal ressemble à ceci :

from playwright.sync_api import sync_playwright

def fetch_rendered(url: str) -> str:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="networkidle", timeout=30_000)
        html = page.content()
        browser.close()
    return html

Intégrez-le à l’ fetch_soup étape du mini-projet (analysez le contenu renvoyé html avec BeautifulSoup) et le reste du pipeline reste inchangé. La boucle « analyser, nettoyer, extraire, normaliser » ne se soucie pas de la provenance du code HTML.

Évolutivité, anti-bot et fiabilité : quand la récupération devient le véritable goulot d'étranglement

Une fois que votre extraction fonctionne sur quelques pages, le goulot d'étranglement passe de l'analyse à la récupération. Les sites vous limitent en débit, les adresses IP des centres de données sont bloquées, des CAPTCHA apparaissent, et le même sélecteur qui fonctionnait hier ne renvoie rien aujourd'hui parce que la page identifie votre client.

Une liste de contrôle pratique pour la fiabilité de la couche de récupération :

  • Respectez robots.txt et les conditions d'utilisation du site. urllib.robotparser les lit pour vous.
  • Définissez des délais d’expiration réalistes (15 à 30 secondes pour la connexion et la lecture) afin qu’une connexion bloquée ne puisse pas interrompre l’ensemble du processus.
  • Réessayez avec un recul exponentiel sur les codes 429, 502, 503 et 504. tenacity ou urllib3.util.Retry gérez cela avec quelques lignes de configuration.
  • Utilisez des en-têtes réalistes. Un User-Agent qui identifie votre bot ainsi qu’un Accept et Accept-Language en-tête permet d’éviter les règles de détection les plus rudimentaires.
  • Limitez le débit par hôte. Un seul requests.Session avec un time.sleep entre les requêtes est le minimum requis ; l'exploration simultanée nécessite un « token bucket » par hôte.
  • Faites tourner les adresses IP lorsque vous traitez des volumes importants. Les proxys résidentiels ressemblent au trafic utilisateur ordinaire ; les adresses IP de centres de données sont signalées par défaut sur de nombreux grands sites.

Si vous ne souhaitez pas consacrer du temps à la gestion interne de tout cela, une API de récupération hébergée peut prendre en charge la rotation des proxys, la résolution des CAPTCHA et la logique de réessai derrière un point de terminaison unique, tandis que vous conservez le code d’analyse BeautifulSoup ou lxml code d'analyse inchangés. C'est le modèle sur lequel repose WebScrapingAPI : vous envoyez l'URL, vous récupérez le HTML rendu (ou le JSON structuré), et votre pipeline d'extraction reste en Python.

Quelle que soit la voie que vous choisissez, séparez clairement les préoccupations. Gardez le récupérateur dans un module et l'extracteur dans un autre. Vous pourrez alors remplacer requests Playwright par une API hébergée sans toucher au code d'analyse.

Référence interlangages : l'extraction en Ruby, JavaScript et C# en un seul endroit

Les langages changent, les bibliothèques changent, mais la logique d'extraction reste la même. La même boucle « analyser, nettoyer, extraire, normaliser » s'applique à toutes les piles. Voici l'équivalent du guide pratique de BeautifulSoup dans trois autres écosystèmes, utile si vous travaillez au sein d'une équipe polyglotte ou si vous êtes en train de choisir le langage sur lequel vous standardiser.

Ruby avec Nokogiri. Nokogiri est l'analyseur HTML standard dans le monde Ruby et joue le même rôle que BeautifulSoup ou lxml en Python.

require "nokogiri"
require "open-uri"

doc = Nokogiri::HTML(URI.open("https://example.com/coffee"))
doc.search("script, style, header, footer, nav, aside").each(&:remove)
text = doc.text.gsub(/\s+/, " ").strip
puts text

JavaScript avec cheerio. Cheerio implémente une API de type jQuery par-dessus un analyseur HTML rapide. jsdom est l'alternative la plus lourde lorsque vous avez également besoin d'API DOM et d'un rendu tenant compte du CSS.

import * as cheerio from "cheerio";

const html = await (await fetch("https://example.com/coffee")).text();
const $ = cheerio.load(html);
$("script, style, header, footer, nav, aside").remove();
const text = $("main, article, body").first().text().replace(/\s+/g, " ").trim();
console.log(text);

C# avec HtmlAgilityPack. Le schéma est le même ; l'API est plus verbeuse.

using HtmlAgilityPack;

var web = new HtmlWeb();
var doc = web.Load("https://example.com/coffee");
var junk = doc.DocumentNode.SelectNodes("//script|//style|//header|//footer|//nav|//aside");
if (junk != null) foreach (var n in junk) n.Remove();
var text = System.Text.RegularExpressions.Regex.Replace(
    doc.DocumentNode.InnerText, @"\s+", " ").Trim();
Console.WriteLine(text);

Chacun de ces extraits de code suit les quatre mêmes étapes que la version Python : analyser, supprimer le bruit évident (scripts, styles, chrome), extraire le texte de l'arborescence restante et réduire les espaces. Si vous intégrez la boucle, changer de langage devient un exercice de syntaxe, et non une remise en question.

Liste de contrôle pour le dépannage d'un résultat d'extraction brouillon

Lorsque vous extrayez du texte HTML avec Python en conditions réelles, le résultat est rarement parfait dès la première exécution. Ce tableau met en correspondance les symptômes que vous observez avec les solutions qui fonctionnent réellement.

Symptôme dans le résultat

Cause probable

Solution

Source JavaScript ou CSS dans le texte

<script> et <style> non supprimée avant l'extraction

for t in soup(['script','style','noscript']): t.decompose()

Mots collés les uns aux autres (freshbeans)

Manquant separator dans get_text()

soup.get_text(separator=' ', strip=True)

Espaces ou  artefacts

NBSP et incompatibilité d'encodage

unicodedata.normalize('NFKC', ...) et text.replace('\u00a0', ' ')

La page semble vide, pas de corps de texte

SPA rendu par JavaScript

Utilisez Playwright, un point de terminaison pré-rendu ou une API de scraping

La navigation, le pied de page ou les publicités s'affichent dans le résultat

Les éléments de mise en forme du site ne sont pas supprimés

soup.select('header, footer, nav, aside, .ad').decompose()

Page entière sous forme de texte, pas d'isolation des articles

Extraction à partir de <body> au lieu de <main>

soup.select_one('main, article, [role=main]').get_text()

Mojibake (é pour é)

requests par défaut ISO-8859-1

resp.encoding = resp.apparent_encoding auparavant resp.text

Trois lignes vides entre les paragraphes

Modèles CMS, non normalisés

`re.sub(r' {3,}', '

', text)`

UnicodeDecodeError sur resp.text

Codec incorrect ou flux tronqué

resp.content.decode('utf-8', errors='replace')

Procédez de haut en bas : supprimez d'abord les scripts et les styles, puis Chrome, puis l'encodage. La grande majorité des bugs de type « mon extraction ne fonctionne pas » se trouvent dans l'une des quatre premières lignes.

Points clés

  • La méthode fiable pour extraire du texte HTML en Python consiste en une boucle en quatre étapes : analyser avec un véritable analyseur syntaxique, nettoyer le bruit évident et les éléments de mise en page, extraire le texte de ce qui reste, puis normaliser les espaces et l'Unicode.
  • Commencez par BeautifulSoup pour presque tout. Passez à lxml.html en plus html-text lorsque vous avez besoin de rapidité ou d’une gestion par défaut plus propre des espaces. Utilisez Parsel pour les champs structurés, pas pour le nettoyage de texte brut.
  • N'exécutez jamais d'expressions régulières sur du code HTML complet. Analysez d'abord, puis utilisez des expressions régulières pour peaufiner la chaîne de texte brut obtenue (NBSP, guillemets typographiques, espaces compressés).
  • Isolez l'article principal avec <main>, <article>, ou [role="main"] avant l'extraction. N'utilisez les heuristiques de type « readability » que lorsque le balisage ne comporte aucun repère sémantique.
  • requests ne peut pas exécuter de JavaScript. Pour les pages rendues côté client, remplacez le récupérateur par un navigateur sans interface graphique ou une API de rendu ; le code d'analyse reste le même.
  • Enregistrez les métadonnées au format JSONL et le corps de chaque page au format .txt. Cette combinaison vous offre un index streamable ainsi qu’un texte prêt pour le pipeline sans vous engager trop tôt dans une base de données.

Ressources WebScrapingAPI associées

FAQ

Quelle est la différence entre BeautifulSoup, lxml, html-text et Parsel pour l'extraction de texte ?

BeautifulSoup est tolérant et adapté aux débutants ; lxml.html est rapide et rigoureux, avec une prise en charge complète de XPath ; html-text s'appuie sur lxml pour produire un texte propre et lisible avec des espaces bien placés ; Parsel est axé sur les sélecteurs pour extraire des champs structurés comme les prix ou les auteurs. Différentes facettes d'un même problème : choisissez BeautifulSoup à moins que l'un des autres outils ne dispose d'une fonctionnalité dont vous avez spécifiquement besoin.

Comment extraire uniquement le corps de l'article et ignorer la navigation, les publicités et les pieds de page ?

Sélectionnez d'abord la sous-arborescence principale : essayez soup.select_one("main"), puis "article", puis "[role='main']", et revenez à soup.body. À l'intérieur de cette sous-arborescence, supprimez les publicités, les blocs d'articles connexes, les widgets de partage et tous les éléments masqués à l'aide d'un sélecteur CSS. Lorsque le balisage ne comporte aucun repère sémantique, des bibliothèques telles que readability-lxml ou trafilatura classent les blocs en fonction de la densité du texte et renvoient le meilleur candidat.

Pourquoi mon texte extrait contient-il du code JavaScript ou CSS, et comment puis-je l'éviter ?

Cela signifie que vous avez appelé get_text() avant de supprimer <script> et <style> . L'analyseur traite leur contenu comme des nœuds de texte ordinaires. Parcourez ces balises et appelez .decompose() sur chacune d'elles avant l'extraction. Ajoutez <noscript> et <template> à la même liste pendant que vous y êtes ; les deux peuvent introduire du balisage ou du texte de secours dans votre sortie.

Comment extraire du texte d’une page rendue par JavaScript lorsque requests renvoie un corps HTML vide ?

Soit récupérez l'API sous-jacente utilisée par la page (consultez l'onglet Réseau des DevTools), soit affichez la page avec un navigateur sans interface graphique comme Playwright, Selenium ou Pyppeteer. Une fois que vous disposez de la chaîne HTML affichée, le reste de votre pipeline d'extraction est identique. Une API d'affichage hébergée fonctionne de la même manière si vous ne souhaitez pas exécuter vous-même des navigateurs.

Dois-je utiliser des expressions régulières pour extraire du texte du HTML en Python ?

Pas en tant qu'analyseur syntaxique. Les expressions régulières ne peuvent pas gérer de manière fiable les balises imbriquées, les éléments non fermés, les commentaires entre crochets angulaires ou les sections CDATA. Utilisez d'abord un véritable analyseur HTML pour aplatir le document, puis appliquez des expressions régulières à la chaîne de caractères résultante pour des tâches simples telles que la suppression des espaces, la normalisation des guillemets ou le remplacement des espaces insécables.

Conclusion et prochaines étapes

La raison pour laquelle l'extraction de texte à partir de HTML en Python semble plus difficile qu'elle ne devrait l'être est que la plupart des tutoriels s'arrêtent à soup.get_text(). Le véritable flux de travail comporte quatre étapes : analyser, nettoyer, extraire, normaliser, et une cinquième étape (enregistrer) une fois que vous l'avez intégré dans un pipeline. Intégrez cette boucle et le choix de la bibliothèque devient accessoire : BeautifulSoup pour la plupart des tâches, lxml.html plus html-text lorsque vous avez besoin de rapidité et de paramètres par défaut plus propres, Parsel lorsque vous voulez des champs structurés, un navigateur sans interface graphique lorsque JavaScript est un obstacle.

À partir de là, les étapes suivantes s’imposent naturellement : l’exploration à grande échelle (pagination, limitation de débit respectueuse, déduplication), la maîtrise des sélecteurs et de l’XPath, et le choix du moment où intégrer des analyseurs syntaxiques sensibles à la structure comme Parsel ou des heuristiques de lisibilité. Chacune de ces voies est un véritable labyrinthe, mais elles s’appuient toutes sur la même boucle d’extraction.

Si c'est la couche de récupération qui vous ralentit (blocages, CAPTCHA, rendu JS), cela vaut la peine d'essayer WebScrapingAPI comme outil de récupération prêt à l'emploi : envoyez une URL, récupérez le code HTML rendu, et laissez votre code d'extraction Python faire le reste. Commencez simplement avec BeautifulSoup, profilez-le lorsqu'il cesse de s'adapter, et n'utilisez les outils plus lourds qu'ensuite.

À propos de l'auteur
Mihai Maxim, Développeur Full Stack @ WebScrapingAPI
Mihai MaximDéveloppeur Full Stack

Mihai Maxim est développeur Full Stack chez WebScrapingAPI ; il participe à l'ensemble du produit et contribue à la création d'outils et de fonctionnalités fiables pour la plateforme.

Commencez à créer

Prêt à faire évoluer votre système de collecte de données ?

Rejoignez plus de 2 000 entreprises qui utilisent WebScrapingAPI pour extraire des données Web à l'échelle de l'entreprise, sans aucun coût d'infrastructure.