Tous les quelques mois, un parseur HTML plus rapide débarque, les benchmark s’enchaînent, et quelqu’un annonce que les anciens sont bons à jeter. Puis il faut choisir chaque paragraphe qui contient un mot précis, ou récupérer le parent d’un nœud trouvé, et on se rappelle pourquoi lxml reste ouvert dans l’autre onglet.
lxml est un pont Python vieux de 20 ans vers libxml2. Ce n’est ni tape-à-l’œil ni tout neuf. Et pour une tâche bien précise — tout ce qui demande un vrai XPath — aucun autre outil Python grand public ne joue vraiment dans la même catégorie. Voici un retour de terrain sur ce qu’il fait, là où il gagne sans faire de bruit, et les deux ou trois pièges de ses réglages par défaut si on ne les connaît pas.
lxml en un paragraphe : ce que c’est vraiment
lxml est une bibliothèque Python qui s’appuie sur les C libraries libxml2 et libxslt. C’est un parseur et un sérialiseur, pas un scraper et pas un navigateur — il transforme du balisage en arbre que vous pouvez interroger et modifier, puis reconvertit cet arbre en octets. Il fournit une API compatible avec ElementTree, un moteur XPath 1.0 complet, XSLT 1.0 et la validation de schémas, le tout maintenu par Stefan Behnel sous le slogan « la bibliothèque la plus riche en fonctionnalités et la plus simple d’utilisation pour traiter XML et HTML en Python ».
Voici où il en est, d’après un instantané GitHub et PyPI relevé le 2026-07-14 :
| Champ | Valeur |
|---|---|
| Dépôt | lxml/lxml |
| Étoiles | 3,043 |
| Forks | 620 |
| Tickets ouverts | 16 |
| Licence | BSD-3-Clause |
| Création | 2011-02-11 |
| Dernier push | 2026-07-02 |
| Version stable PyPI | 6.1.1 (2026-05-18) |
| Moteur embarqué | libxml2 2.14.6 + libxslt 1.1.43 |
Avant qu’on m’accuse de survendre la chose, je précise un point : cette analyse ne cache aucun secret. lxml est assez ancien pour que chaque comportement décrit ici apparaisse quelque part dans la documentation lxml, dans le changelog de libxml2 ou dans un fil Launchpad. Je n’ai trouvé aucune astuce obscure et non documentée, et je n’en inventerai pas. L’intérêt de ce qui suit, c’est que c’est structuré, chiffré et organisé autour de lxml comme sujet — pas que ce soit une révélation.
Protocole de test (et pourquoi les chiffres de performance sont empruntés)
Deux familles de données alimentent cette revue, et elles viennent de deux sources différentes ; autant être clair sur ce qui est quoi.
Les tests de capacité — comportement XPath, les deux API de parsing, les espaces de noms, l’encodage, le cycle de vie des nœuds — ont été exécutés à neuf sur une machine : macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Chaque chiffre des fichiers artifacts/raw/*.json est calculé par un script lancé à l’exécution, pas saisi à la main. Les tests de capacité étant déterministes (booléens et énumérations), un seul passage suffit à produire un résultat stable — la charge machine ne change pas le fait que //a/@href renvoie ou non une chaîne attributaire.
Les chiffres de temps et d’empreinte mémoire ne viennent pas de ce lot. Ils sont repris tels quels du pack de benchmarks selectolax précédent — même machine, même environnement virtuel, même build de lxml et libxml2, benchmarks arrêtés au 2026-07-13 — et je ne les ai pas relancés ici. C’est volontaire. Relancer des tests de timing en même temps qu’une série de scripts de capacité crée une concurrence CPU qui fausserait les chiffres réutilisés, et ce serait refaire le travail en double : dans ce pack, lxml était déjà une bibliothèque de référence entièrement mesurée. Réutiliser les mêmes benchmarks évite d’introduire une deuxième mesure légèrement différente. Donc, quand vous voyez un temps en millisecondes ci-dessous, lisez-le comme « même banc d’essai, au 2026-07-13 », pas comme « j’ai retimé cela aujourd’hui ».
Chaque résultat est accompagné d’un niveau de confiance : single-observation pour les tests de capacité déterministes, triple-run pour les distributions de timing réutilisées, hypothesis quand j’avance un mécanisme que je n’ai pas isolé.
XPath : la seule chose que selectolax et BeautifulSoup n’ont tout simplement pas
C’est l’argument principal, donc je commence par là.

J’ai soumis xpath() de lxml à une matrice préenregistrée de 37 cas — le résultat attendu de chaque test avait été écrit dans le code avant l’exécution, donc impossible de noter avec indulgence. Dix axes, neuf styles de prédicats, dix fonctions intégrées, trois types de retour scalaires, et cinq cas-pièges volontaires utilisant une syntaxe XPath 2.0 que le moteur 1.0 de lxml doit rejeter.
| Catégorie | Couverture | Résultat |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 OK |
| Prédicats | [1] / last() / position()<n / égalité d’attribut / existence d’attribut / and / or / [.//a] imbriqué / not() | 9/9 OK |
| Fonctions | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 OK |
| Types de retour | booléens / scalaires numériques | 3/3 OK |
| Cas-pièges | matches() / séquences / if-then-else / except / erreur de syntaxe | 5/5 rejetés correctement |
Le score est de 37/37, et la colonne des pièges est la plus importante. matches(), les expressions séquentielles, if/then/else et except relèvent tous de XPath 2.0, et le moteur 1.0 de libxml2 ne les prend pas en charge à moitié — il lève XPathEvalError et refuse l’expression, plutôt que de renvoyer silencieusement un mauvais ensemble de nœuds. C’est donc un score parfait après tentative de sabotage, pas un score obtenu avec des cas faciles. Chaque comportement ici correspond exactement à ce que décrit la documentation XPath de lxml, et c’est bien l’objectif.
J’admets un point sur lequel le harness s’est trompé, mais c’est justement la version du « 37/37 » à laquelle on peut faire confiance. Mon premier ensemble attendu pour //div[.//a[@href]] prévoyait deux hits ; l’exécution n’en a renvoyé qu’un. J’ai pensé que lxml avait tort pendant une trentaine de secondes, puis j’ai vérifié le fixture et découvert que le second élément était un <footer>, pas un <div> — c’était mon attente qui était fausse, pas le moteur. J’ai corrigé l’ensemble attendu et laissé l’erreur en commentaire dans la source. C’est le bon ordre des responsabilités : soupçonnez d’abord votre test, pas une bibliothèque C vieille de 20 ans.
XPath vs CSS : ce que CSS ne peut littéralement pas exprimer
Le slogan abstrait « XPath est plus puissant » mérite un chiffre concret, alors j’ai quantifié l’écart. lxml vous donne à la fois .xpath() et .cssselect() (ce dernier traduit du CSS vers XPath en interne). J’ai pris dix objectifs de sélection et vérifié lesquels CSS peut réellement exprimer.

| Cible | XPath | CSS (cssselect) |
|---|---|---|
Filtrer par contenu textuel (contains(text(),"bargain")) | Oui | Pas de prédicat texte |
Sélectionner le parent d’un enfant (//b/parent::p) | Oui | Pas de sélecteur parent |
Renvoyer la valeur d’un attribut (//a/@href) | Oui | Éléments uniquement |
Renvoyer un nœud texte (//p/text()) | Oui | Pas de nœuds texte |
Axe ancestor (//td/ancestor::div) | Oui | Pas de navigation vers le haut |
Filtrer un parent par nombre d’enfants (//ul[count(li)=4]) | Oui | Pas de prédicat de comptage |
Filtrer par longueur du texte (string-length(text())>5) | Oui | Pas de prédicat de longueur |
nth-child / last-child / sibling adjacent | Oui | Oui (3 cas de base) |
Sept des dix cibles n’ont tout simplement aucun équivalent CSS. Filtrage par texte, navigation ascendante vers parents et ancêtres, extraction d’une valeur d’attribut ou d’un nœud texte brut, prédicats basés sur le comptage — CSS ne sait rien exprimer de tout cela. Seuls trois cas (nth-child, last-child, sibling adjacent) fonctionnent dans les deux mondes. Voilà la réponse chiffrée à la question « qu’est-ce que je gagne vraiment avec lxml ? ». selectolax est limité au CSS et ne propose aucune méthode xpath(), donc ces sept types de requêtes doivent être bricolés en plusieurs étapes avec Python, ou ne peuvent pas être faits. Si votre logique de scraping repose sur l’un d’eux, la décision est prise.
(Et oui, le harness m’a piégé une deuxième fois ici : j’avais prévu un ensemble vide pour string-length(text())>5, mais deux chaînes de six caractères correspondaient. J’ai corrigé l’attente, pas l’outil.)
Trois niveaux de rigueur : etree, recover et lxml.html
XPath est la raison de choisir lxml. Les trois modes de strictness sont la raison de le garder.

La plupart des parseurs vous donnent un seul comportement face à une entrée cassée. lxml en propose trois, et ils sont assez prévisibles pour que j’aie fait passer six classes de balisage invalide dans chacun, avec les attentes préenregistrées pour chaque cas.
| Entrée invalide | lxml.etree (strict) | etree + recover=True | lxml.html (tolérant) |
|---|---|---|---|
Balise non fermée <root><a>x</root> | lève une erreur | récupère | accepte |
Imbrication incorrecte <b><i></b></i> | lève une erreur | récupère | accepte |
Entité non définie | lève une erreur | récupère | accepte |
Esperluette brute & (Tom & Jerry) | lève une erreur | récupère | accepte |
Racines multiples <a>1</a><b>2</b> | lève une erreur | récupère | accepte |
| XML bien formé | accepte | accepte (0 erreur) | accepte |
Attribut booléen <input disabled> | lève une erreur | récupère | accepte |
Sept cas sur sept ont concordé avec l’attente préenregistrée. lxml.etree lève XMLSyntaxError sur les six classes malformées. Ajoutez recover=True au même parseur, et il avale les erreurs tout en reconstruisant un arbre exploitable — et c’est la partie sous-estimée — parser.error_log liste alors chaque erreur absorbée. lxml.html, lui, accepte tout sans broncher.
Le classifieur qui décide entre « lève », « récupère » et « accepte » s’appuie lui-même sur la longueur réelle de error_log, pas sur une valeur codée en dur ; c’est ce qui permet à un document bien formé passé avec recover=True d’être correctement classé « accepte » (journal vide) plutôt que « récupère ». Ma première version de ce classifieur étiquetait tout résultat avec recover=True comme « récupère » et malclassait l’entrée propre ; lire le error_log réel a corrigé cela.
En pratique, cela vous donne : validation stricte quand un flux cassé doit échouer bruyamment, lxml.etree ; HTML réel sale qu’il faut simplement réussir à parser, lxml.html ; et le cas intermédiaire que la plupart des outils ne savent pas traiter — « soyez tolérant, mais dites-moi précisément ce qui était cassé pour que je le journalise » — recover=True avec lecture du journal d’erreurs. selectolax n’a que le mode tolérant, et rien d’autre : ni mode strict, ni journal d’erreurs.
iterparse : le mode streaming que selectolax n’a pas du tout
Ici, on parle de capacité, pas d’un simple réglage de vitesse. selectolax ingère uniquement une chaîne entière — il n’a pas d’interface incrémentale. iterparse de lxml renvoie les éléments au fur et à mesure qu’ils se ferment, et associé au pattern fast_iter classique (faire elem.clear() et supprimer les frères précédents au passage), il garde la mémoire stable quelle que soit la taille du document.

J’ai mesuré directement le comportement mémoire — RSS max via ru_maxrss, chaque sujet dans un processus vierge, sur 300 000 éléments <record> représentant environ 26,7 MB (26,744,801 octets).
| Mode | Variation RSS max | Notes |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | libéré au fil de l’eau ; stable quel que soit le volume |
iterparse sans clear | ~386 MB | garde les références ; aussi lourd qu’un chargement complet |
etree.parse (chargement complet, référence) | ~386 MB | lourd par nature ; prouve que le compteur voit bien l’ordre de grandeur |
Le mode borné maintient la hausse du RSS max autour de 1-2 MB face à ~386 MB en chargement complet — soit un écart de l’ordre de 0,3 à 0,4 % — et le premier événement de record arrive avant même la fin de lecture du fichier, donc il s’agit bien d’un flux incrémental réel, pas d’un faux streaming. La ligne la plus instructive est celle du milieu. Lancez la même boucle iterparse mais sans clear(), et la mémoire remonte à ~386 MB, parce que vous conservez des références à tout. Le gain vient de clear(), pas de iterparse tout seul. Le fait que la référence de chargement complet soit nettement plus haute que le mode borné confirme aussi que le mesureur RSS voit bien l’écart d’ampleur au lieu de lire dans le vide. (Ce test mémoire, je l’ai bien refait dans ce pack — c’est une mesure d’empreinte, distincte des timings réutilisés.)
La version terrain de cela : un export XML de plusieurs gigaoctets qui ne tient pas en RAM n’a aucune alternative avec selectolax. Il faut le parseur streaming de lxml, ou changer de langage.
Espaces de noms : RSS, SVG et le piège de l’espace de noms par défaut
Douze cas d’espaces de noms, couvrant RSS sur trois namespaces, SVG avec un namespace par défaut plus xlink, et du XML à namespace par défaut. Les douze ont réussi.
lxml extrait //dc:creator/text() d’un flux RSS exactement sous la forme ["Alice", "Bob"], résout //atom:link/@href et //content:encoded à travers trois namespaces distincts dans le même document, gère //s:rect et //s:use/@xlink:href dans le second namespace de SVG, découpe les noms au format Clark {uri}local avec QName, et expose les namespaces via nsmap. C’est le comportement maintenu et documenté, et c’est une dimension entière que selectolax ne couvre pas, parce qu’il est limité à HTML5 et ne traite pas les namespaces XML arbitraires.
Il existe un piège documenté qu’il vaut la peine de mémoriser. XPath n’a pas de notion d’espace de noms par défaut. Si vous appliquez //book à un document déclarant xmlns="urn:...", vous obtenez zéro résultat — le préfixe vide n’existe pas pour XPath, comme le rappelle la documentation lxml. Vous devez soit associer un préfixe artificiel (//c:book avec namespaces={"c": "urn:..."}, ce qui a trouvé les trois), soit retomber sur //*[local-name()='book'] (également trois). Ce n’est pas un bug — c’est la spécification XPath, appliquée fidèlement. Cela surprend juste tout le monde une fois.
Pages réelles malpropres : fidélité sur 11 scrapes réels
Les tests synthétiques sont propres ; le web ne l’est pas. J’ai réutilisé onze vraies pages capturées dans le jeu de fixtures du pack selectolax (au 2026-07-10, en lecture seule) et j’ai soumis lxml.html à ces pages en prenant lxml comme sujet.
| Fixture | Taille | Liens | erreurs récupérées par libxml2 | XML strict |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | erreur |
| wiki_scraping.html | 227 KB | 460 | 0 | erreur |
| gov_whitehouse.html | 289 KB | 154 | 0 | erreur |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | erreur |
| forum_reddit.html | 129 KB | 318 | 0 | erreur |
| docs_python.html | 80 KB | 341 | 2 | erreur |
| ecommerce_books.html | 51 KB | 94 | 0 | erreur |
| news_hackernews.html | 35 KB | 229 | 0 | erreur |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | erreur |
| spa_quotes_js.html | 6 KB | 5 | 0 | erreur |
Les onze ont été parsés avec lxml.html, et les comptes de liens, de titres et d’images coïncident avec les comptes lxml réutilisés du pack selectolax sur les onze cas — vérification croisée true. C’est cet accord qui me dit que la réutilisation est bien comparable et qu’il ne s’agit pas de deux mesures différentes portant la même étiquette.
Autre constat : le parseur XML strict a levé une erreur sur dix des onze pages. Les vraies pages web ne sont presque jamais du XML bien formé, ce qui explique précisément pourquoi le mode de récupération HTML de libxml2 existe. La seule exception était BBC News, rendue par Next.js et suffisamment propre pour passer le parsing XML strict. Tout ce qui s’appelle « HTML » n’a pas forcément besoin du mode récupération.
Un point de comptage est facile à rater. Sur docs_python.html, //a[@href] (existence d’attribut) a compté 343, alors que le if n.get("href") du pack selectolax (valeur vraie) comptait 341. Les deux liens supplémentaires ont un href="" vide. C’est une différence de convention de comptage — attribut présent versus attribut non vide — pas une différence de comportement de lxml, et les comptes s’alignent dès qu’on harmonise le prédicat. Bon à savoir quand vous scrapez : le fait de compter les href vides dépend de votre filtre, pas du parseur.
La limite de profondeur qui ressemble à un bug (mais n’en est pas un)
Le pack selectolax avait noté que lxml perdait le contenu le plus profond sur des balisages <div> imbriqués à 1 000 et 5 000 niveaux, en parlant de « lxml perd silencieusement le contenu le plus profond ». Je voulais comprendre le mécanisme, alors j’ai testé le parseur par défaut avec huge_tree=True.

| Profondeur demandée | Profondeur atteinte par le parseur par défaut | Profondeur atteinte avec huge_tree=True |
|---|---|---|
| 300 | 253 (le reste est perdu) | 299 (récupéré) |
| 1000 | 253 (le reste est perdu) | 999 (récupéré) |
| 5000 | 253 (le reste est perdu) | 2045 (reste perdu) |
Le parseur par défaut tronque vers 253 niveaux et supprime silencieusement tout ce qui est plus profond. Ce n’est pas un bug — c’est la défense anti-DoS de libxml2, une limite d’imbrication d’environ 256 niveaux destinée à empêcher un document hostile de faire exploser la pile, et c’est documenté dans le fil Launchpad de lxml sur XML_PARSE_HUGE. Activez huge_tree=True et les profondeurs 300 et 1 000 reviennent intégralement. En revanche, à 5 000 niveaux, on n’atteint encore que 2 045 même avec huge_tree — il existe un second plafond de récursion plus dur, au-dessus de la limite configurable, et huge_tree ne le supprime pas.
L’action concrète est donc claire : quand vous parsez un balisage très profond provenant d’une source de confiance, utilisez lxml.html.HTMLParser(huge_tree=True). Ce que ce pack ajoute à l’observation réutilisée, c’est le mécanisme (une limite de sécurité, pas une corruption de données), la correction (huge_tree), et le fait qu’il existe un second plafond que ce réglage n’atteint pas.
DOM en lecture/écriture, sérialisation, encodage
lxml est un arbre complet en lecture/écriture, pas un extracteur en lecture seule, et j’ai vérifié la surface d’édition cas par cas. Les huit opérations DOM ont réussi : SubElement, insert, remove, replace, strip_tags (supprime les balises tout en conservant leur texte), strip_elements (supprime les balises et leur texte), drop_tree (spécifique à lxml.html), ainsi que le modèle à deux emplacements text/tail qui piège les débutants — dans <p>head<b>bold</b>tail</p>, p.text vaut "head", b.text vaut "bold" et b.tail vaut "tail".
La sérialisation a réussi cinq sur cinq : tostring en mode XML et HTML (le HTML laisse correctement les éléments vides non autofermés), pretty_print, la canonisation C14N (method="c14n", autre exclusivité lxml) et un aller-retour propre.
L’encodage est le domaine où lxml se distingue discrètement. Donnez-lui des octets non UTF-8 — "<p>café éè</p>".encode("latin-1") via lxml.html.fromstring — et il restitue café éè intact, sans caractères de remplacement U+FFFD, sans octets perdus. Cela reproduit directement son rôle de référence propre dans le pack selectolax, où la même entrée était silencieusement corrompue par les deux autres moteurs (Lexbor produisait des caractères de remplacement, Modest supprimait carrément des octets). La détection d’encodage de libxml2 est tout simplement plus stable ici.
L’autre face de la médaille, c’est la rigueur sur la façon dont vous déclarez un encodage. encoding="latin-1" dans une déclaration XML provoque XMLSyntaxError: Unsupported encoding: latin-1, tandis que le nom canonique IANA encoding="ISO-8859-1" passe sans problème et renvoie café. libxml2 n’accepte que les noms canoniques, pas les alias — un détail documenté dès Launchpad #613302. Pénible si vous l’ignorez, trivial une fois connu.
Enfin, le cycle de vie des nœuds. J’ai testé trois scénarios de poignée obsolète dans des sous-processus isolés (un crash franc apparaîtrait comme une sortie non nulle) : conserver un nœud après la collecte de son arbre par le GC, relire une poignée après drop_tree(), et utiliser un nœud après remove(). Aucun segfault dans ces cas — lxml garde la référence du nœud vers son arbre pour éviter les use-after-free. Même constat rassurant que selectolax sur ce test.
Vitesse et mémoire (empruntées, et assumées comme telles)
Tout ce qui suit est repris du pack selectolax, arrêté au 2026-07-13. Ce pack ne produit aucun chiffre de timing propre, et je préfère le dire deux fois plutôt que vous laissiez croire que j’ai retimé quoi que ce soit.
| Dimension | Valeur lxml | Lecture |
|---|---|---|
| p50 parse pur (10 MB) | 77.9 ms | ~33-34 % plus rapide que selectolax-Lexbor |
| p50 parse complet + extraction (1 MB / 10 MB) | 14.18 ms / 172.9 ms | à peu près à égalité avec Lexbor sur les petites tailles |
| Débit CSS sur 100k nœuds | 3,002,646 nœuds/s | catégorie la plus rapide des trois moteurs C |
| Delta RSS sur 10 MB | 128.9 MB | le plus économe des six parseurs, ~1,7x plus léger que BeautifulSoup |
| Démarrage à froid de l’import | 14.1 ms | ~2,3x plus rapide que les imports de type parsel |
Les chiffres de parsing pur et de débit sont solides, et lxml est le plus frugal en mémoire des six parseurs mesurés. Il faut cependant nuancer l’histoire du multithreading. Les données réutilisées montrent un gain wall-clock de seulement 1,21x sur 4 threads, classé non concluant — mais cela correspond à un chemin avec parseur partagé par défaut. La FAQ lxml précise explicitement que le GIL n’est libéré pendant le parsing que si chaque thread utilise son propre parseur (ou une copie du parseur par défaut) ; un parseur partagé sérialise l’accès. J’ai vérifié structurellement que l’API nécessaire est bien là (XMLParser.copy() existe, get/set_default_parser existent, XPathEvaluator porte un verrou interne), mais je n’ai pas mesuré le gain avec un parseur par thread — cela exigerait un nouveau benchmark, et ce pack n’en produit pas. Donc lisez le 1.21x comme « sous le chemin naïf avec parseur partagé », pas comme le plafond de lxml en multithread.
Et une réserve sur l’ensemble : ce sont des chiffres mono-plateforme, macOS arm64. L’affirmation selon laquelle le parse pur de lxml dépasse Lexbor va à contre-courant du consensus habituel, qui considère le parseur basé sur Lexbor comme le plus rapide ; elle mérite donc réellement une vérification sur Linux x86_64 avant d’être considérée comme définitive.
Licence : la victoire sans bruit
lxml est distribué sous BSD-3-Clause, et les bibliothèques C qu’il embarque — libxml2 et libxslt — sont toutes deux sous MIT. C’est une chaîne entièrement permissive, sans copyleft nulle part, ce qui compte dès que vous redistribuez. À titre de comparaison, le wheel selectolax embarque LGPL-2.1 Modest et Apache-2.0 Lexbor, donc lxml est plus simple à intégrer dans un produit fermé.
Il y a aussi un avantage d’installation très concret : lxml publie des wheels précompilés qui lient statiquement libxml2 et libxslt, si bien que pip install lxml ne nécessite généralement ni libxml2 système ni compilateur sur votre machine — une expérience différente d’une compilation depuis les sources.
Où lxml s’insère — et où une couche d’extraction IA prend le relais
Il est important de bien fixer la frontière, car l’erreur de catégorie est facile. lxml est une bibliothèque de parsing. Il vous fournit un arbre et un excellent moteur de requêtes, et tout ce qui entoure cet arbre reste votre responsabilité : récupérer la page, rendre le JavaScript, contourner l’anti-bot, écrire et maintenir le XPath, puis structurer le résultat. C’est une autre couche qu’un service d’extraction hébergé, et les deux ne sont pas tant des rivaux que des voisins.
Pour un développeur qui ne veut pas porter toute la pile fetch-render-select-maintain, cette couche supérieure est l’endroit où vit quelque chose comme Thunderbit — et pour ce public, il s’agit de l’API, du serveur MCP et du CLI, pas de l’extension navigateur. Le Thunderbit Open API expose POST /distill pour transformer une page en Markdown propre et POST /extract pour extraire des données structurées selon un JSON Schema, avec un commutateur renderMode et des jobs batch pour le volume. Le même moteur existe aussi comme serveur MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) pour les agents et assistants de code, ainsi que comme CLI exécutable directement depuis un terminal via npx @thunderbit/thunderbit-cli. Il gère le rendu JS, l’anti-bot et les CAPTCHA dès le départ et renvoie du JSON conforme au schéma — c’est donc la couche au-dessus du parsing, pas un substitut.
Essayer Thunderbit pour l’extraction de données web
Le principe est simple : utilisez lxml quand vous maîtrisez le pipeline et voulez un contrôle XPath chirurgical sur un arbre que vous comprenez. Tournez-vous vers une API d’extraction IA quand vous ne voulez pas gérer les sélecteurs ni le rendu. Beaucoup de systèmes réels utilisent les deux : lxml pour les flux structurés qu’ils contrôlent, un service d’extraction pour les pages longues et sales qu’ils ne contrôlent pas.
Ce que cette revue n’a pas testé
Il s’agit d’une revue provisoire, pas d’un verdict final, donc voici ce qu’elle ne couvre pas.
Tous les chiffres de timing et de mémoire sont réutilisés, mono-plateforme (macOS arm64, Python 3.14) et héritent des réserves de ce pack — le résultat selon lequel « lxml est plus rapide en parsing pur » va à contre-courant du consensus et demande une nouvelle vérification sur Linux x86_64. Le gain de threading avec un parseur par thread n’a pas été testé (il faudrait un nouveau benchmark). J’ai mesuré la mémoire d’iterparse sur 300k enregistrements, mais pas sur du vrai XML à l’échelle du gigaoctet, ni iterparse sur HTML versus XML, ni une tenue sur plusieurs heures. XSLT 1.0 de lxml, la validation RelaxNG / XMLSchema / DTD et les extensions EXSLT n’ont pas été testées ici — un vaste périmètre de capacités, mais hors du cœur parsing-sélection. J’ai observé le second plafond de profondeur à 2 045 sans isoler la constante de récursion exacte de libxml2. Seule la version stable 6.1.1 a été testée, pas l’alpha 7.0.0. Windows, les builds depuis les sources et la version 3.14t free-threaded n’ont pas été testés. Et dans XPath lui-même, j’ai couvert les fonctions intégrées mais pas les variables XPath, les fonctions d’extension Python personnalisées, ni la réutilisation d’objets etree.XPath précompilés.
Verdict
lxml n’est pas la nouveauté rapide du moment, et c’est précisément pour ça qu’on le recommande. C’est un pont libxml2 vieux de deux décennies, avec un moteur XPath 1.0 complet qu’aucune alternative Python grand public n’égale, trois niveaux de rigueur de parsing prévisibles avec un journal d’erreurs au milieu, un vrai parseur streaming pour les documents qui ne tiennent pas en mémoire, une gestion correcte des espaces de noms multiples et de l’encodage, et une licence totalement permissive. Les quelques angles vifs — la limite de profondeur d’environ 253 niveaux et le chiffre de threading avec parseur partagé — sont documentés, configurables et maintenant expliqués.
Si vous maîtrisez votre pipeline de scraping et vous appuyez sur XPath, lxml reste le parseur de référence. Si vous préférez ne pas maintenir vous-même les sélecteurs et le rendu, c’est justement le rôle d’une couche d’extraction IA comme l’API Thunderbit, le MCP et le CLI — une répartition claire des tâches, pas une compétition. Dans tous les cas, considérez ces chiffres comme provisoires et revérifiez les temps sur votre propre plateforme avant de les citer dans une note de conception.
Essayer Thunderbit pour l’extraction de données web Get Started Free
FAQ
lxml est-il un web scraper ?
Non. lxml est un parseur et un sérialiseur — un pont Python vers libxml2/libxslt qui transforme le balisage en arbre éditable et interrogeable. Il ne récupère pas les pages, ne rend pas le JavaScript et ne gère pas l’anti-bot ; c’est vous qui fournissez la couche de requête (via requests, httpx, un navigateur headless ou un service de scraping) avant de passer les octets à lxml.
Quand faut-il utiliser lxml plutôt que BeautifulSoup ou selectolax ? Choisissez lxml quand vous avez besoin de XPath. BeautifulSoup peut utiliser lxml comme backend de parsing, mais n’expose aucun XPath natif, et selectolax est limité au CSS — plus rapide dans son créneau étroit. Si votre logique de sélection doit filtrer selon le texte, remonter vers un parent ou un ancêtre, extraire un attribut ou un nœud texte, ou utiliser des prédicats de comptage, le moteur XPath de lxml est la seule option Python grand public qui les exprime directement.
Pourquoi lxml supprime-t-il silencieusement le contenu profondément imbriqué ?
Son parseur par défaut limite l’imbrication à environ 253 niveaux — une défense libxml2 contre les documents hostiles, pas un bug. Activez huge_tree=True (par exemple lxml.html.HTMLParser(huge_tree=True)) et il récupère intégralement des profondeurs de 300 et 1 000. À noter qu’il existe un second plafond de récursion plus dur, autour de 2 045 niveaux, que huge_tree ne supprime pas.
lxml libère-t-il le GIL lors du parsing multithread ? Oui, mais seulement dans les bonnes conditions. La FAQ lxml indique que le GIL est libéré pendant le parsing lorsque chaque thread utilise son propre parseur ou une copie du parseur par défaut ; un parseur partagé sérialise l’accès. Le gain réutilisé de 1,21x sur 4 threads reflète le chemin naïf avec parseur partagé, pas le plafond avec un parseur par thread, qui n’a pas été mesuré ici.
lxml est-il encore maintenu en 2026 ? Oui. La version stable 6.1.1 est sortie le 2026-05-18, le dépôt a reçu son dernier push le 2026-07-02, et une alpha 7.0.0 est en cours. Avec environ 3 000 étoiles GitHub et une libxml2 activement maintenue en dessous, c’est encore une bibliothèque actuelle et bien supportée, pas un héritage abandonné.


