Κάθε λίγους μήνες εμφανίζεται ένας πιο γρήγορος HTML parser, τα benchmarks κάνουν τον γύρο του διαδικτύου και κάποιος κηρύσσει το παλιό καθεστώς ξεπερασμένο. Μετά όμως χρειάζεται να διαλέξεις κάθε παράγραφο που περιέχει μια συγκεκριμένη λέξη ή να πάρεις τον γονέα ενός ταιριαστού κόμβου, και τότε θυμάσαι γιατί το lxml παραμένει ανοιχτό και σε άλλο tab.
Το lxml είναι ένα binding της libxml2 με ιστορία 20 ετών. Δεν είναι flashy. Δεν είναι καινούργιο. Και για μία συγκεκριμένη δουλειά — οτιδήποτε απαιτεί πραγματικό XPath — τίποτα άλλο στο mainstream της Python δεν το ανταγωνίζεται ουσιαστικά. Αυτή είναι μια πρακτική αποτίμηση για το τι κάνει, πού κερδίζει αθόρυβα και σε ποια-δυο σημεία οι προεπιλογές του μπορούν να σε τσιμπήσουν αν δεν τις ξέρεις.
Το lxml σε μία παράγραφο: Τι είναι πραγματικά
Το lxml είναι Python binding για τις C βιβλιοθήκες libxml2 και libxslt. Είναι parser και serializer, όχι scraper και όχι browser — μετατρέπει markup σε ένα δέντρο που μπορείς να κάνεις query και να επεξεργαστείς, και μετά ξαναμετατρέπει το δέντρο σε bytes. Σου δίνει API συμβατό με ElementTree, πλήρη μηχανή XPath 1.0, XSLT 1.0 και επικύρωση schemas, με συντήρηση από τον Stefan Behnel και σύνθημα «η πιο πλούσια σε δυνατότητες και εύχρηστη βιβλιοθήκη για επεξεργασία XML και HTML στη γλώσσα Python».
Να πού βρίσκεται, με βάση στιγμιότυπο GitHub και PyPI που λήφθηκε στις 2026-07-14:
| Πεδίο | Τιμή |
|---|---|
| Repo | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Open issues | 16 |
| License | BSD-3-Clause |
| Created | 2011-02-11 |
| Last push | 2026-07-02 |
| PyPI stable | 6.1.1 (2026-05-18) |
| Bundled engine | libxml2 2.14.6 + libxslt 1.1.43 |
Πριν με κατηγορήσει κανείς για hype, ένα πράγμα πρέπει να ξεκαθαριστεί: σε αυτή την αποτίμηση δεν υπάρχουν μυστικά. Το lxml είναι τόσο παλιό ώστε κάθε συμπεριφορά που αναφέρω εδώ να είναι τεκμηριωμένη κάπου στα docs του lxml, σε changelog της libxml2 ή σε thread του launchpad. Δεν βρήκα κάποια αποκλειστική, αδημοσίευτη τεχνική και δεν πρόκειται να επινοήσω μία. Η αξία όσων ακολουθούν είναι ότι είναι συστηματοποιημένα, ποσοτικοποιημένα και οργανωμένα γύρω από το lxml ως αντικείμενο — όχι ότι είναι «είδηση».
Το setup των δοκιμών (και γιατί οι χρονικές μετρήσεις είναι δανεικές)
Δύο κατηγορίες δεδομένων μπήκαν σε αυτή την αποτίμηση, και προέρχονται από δύο διαφορετικά σημεία, οπότε θα πω ανοιχτά ποιο είναι ποιο.
Τα tests δυνατοτήτων — συμπεριφορά XPath, τα δύο parser APIs, namespaces, κωδικοποίηση, κύκλος ζωής κόμβων — τα έτρεξα φρέσκα σε ένα μηχάνημα: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Κάθε αριθμός στα αρχεία artifacts/raw/*.json υπολογίστηκε από script κατά την εκτέλεση, όχι γραμμένος στο χέρι. Τα capability tests είναι ντετερμινιστικά booleans και enums, άρα ένα μόνο run αρκεί — το φορτίο του μηχανήματος δεν αλλάζει αν το //a/@href επιστρέψει attribute string.
Οι χρονικές μετρήσεις και τα νούμερα μνήμης δεν είναι από αυτό το pack. Έχουν επαναχρησιμοποιηθεί αυτούσια από το προηγούμενο selectolax benchmark pack — ίδιο μηχάνημα, ίδιο virtual environment, ίδια build του lxml και της libxml2, benchmarks έως 2026-07-13 — και δεν τα έτρεξα ξανά εδώ. Αυτό είναι επίτηδες. Αν έτρεχα timing benchmarks μαζί με ένα batch capability scripts, θα δημιουργούσα CPU contention που θα «λέρωναν» τα επαναχρησιμοποιημένα αποτελέσματα, και θα ήταν διπλή δουλειά: το lxml είχε ήδη μετρηθεί πλήρως ως control library σε εκείνο το pack. Η επαναχρησιμοποίηση του ίδιου bench κρατά τα πάντα συγκρίσιμα αντί να εισάγει ένα δεύτερο, λίγο διαφορετικό measurement. Άρα όταν βλέπεις παρακάτω ένα νούμερο σε milliseconds, διάβασέ το ως «ίδιο test rig, έως 2026-07-13», όχι ως «το ξαναμέτρησα σήμερα».
Τα ευρήματα φέρουν tag βεβαιότητας: single-observation για τα ντετερμινιστικά capability tests, triple-run για τις επαναχρησιμοποιημένες κατανομές timing, hypothesis όταν προτείνω έναν μηχανισμό που δεν απομόνωσα.
XPath: Το ένα πράγμα που το selectolax και το BeautifulSoup απλώς δεν έχουν
Αυτό είναι το βασικό συμπέρασμα, οπότε ξεκινάω από εδώ.

Πέρασα το xpath() του lxml από μια προ-καταχωρημένη μήτρα 37 στοιχείων — το αναμενόμενο αποτέλεσμα για κάθε περίπτωση είχε γραφτεί στον κώδικα πριν ξεκινήσει το test, ώστε να μην μπορώ κατά λάθος να βαθμολογήσω «με το ζόρι». Δέκα axes, εννέα στυλ predicates, δέκα ενσωματωμένες συναρτήσεις, τρεις scalar τύποι επιστροφής και πέντε σκόπιμες παγίδες με σύνταξη μόνο του XPath 2.0 που η μηχανή 1.0 του lxml θα έπρεπε να απορρίψει.
| Κατηγορία | Κάλυψη | Αποτέλεσμα |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predicates | [1] / last() / position()<n / equality σε attribute / ύπαρξη attribute / and / or / nested [.//a] / not() | 9/9 pass |
| Functions | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 pass |
| Return types | boolean / number scalars | 3/3 pass |
| Trap cases | matches() / sequences / if-then-else / except / syntax error | 5/5 correctly rejected |
Το σκορ είναι 37/37, και η στήλη των trap είναι το ουσιαστικό κομμάτι. Τα matches(), sequence expressions, if/then/else και except είναι σύνταξη XPath 2.0, και η μηχανή 1.0 της libxml2 δεν τα υποστηρίζει μισά-μισά — επιστρέφει XPathEvalError και αρνείται την εκτέλεση, αντί να δώσει αθόρυβα λάθος node set. Άρα έχουμε τέλειο σκορ μετά από προσπάθεια να σπάσει, όχι τέλειο σκορ από εύκολα τεστ. Ό,τι συνέβη εδώ είναι ακριβώς αυτό που περιγράφουν τα docs του XPath στο lxml, και αυτό είναι το ζητούμενο.
Θα παραδεχτώ ένα πράγμα που ο harness έκανε λάθος, επειδή είναι η εκδοχή του «37/37» που αξίζει πραγματικά να εμπιστευτείς. Το πρώτο μου expected set για το //div[.//a[@href]] προέβλεπε δύο hits· η εκτέλεση επέστρεψε ένα. Για περίπου τριάντα δευτερόλεπτα υπέθεσα ότι το lxml έκανε λάθος, μετά έλεγξα το fixture και είδα ότι το δεύτερο στοιχείο ήταν <footer> και όχι <div> — το δικό μου expectation ήταν λάθος, όχι η μηχανή. Διόρθωσα το expected set και άφησα το λάθος ως σχόλιο στον κώδικα. Αυτή είναι η σωστή σειρά απόδοσης ευθύνης: πρώτα υποψιάσου το test σου και μετά την C βιβλιοθήκη των 20 ετών.
XPath vs CSS: Τι κυριολεκτικά δεν μπορείς να εκφράσεις σε CSS
Ο αφηρημένος ισχυρισμός «το XPath είναι πιο ισχυρό» αξίζει ένα συγκεκριμένο νούμερο, οπότε ποσοτικοποίησα το κενό. Το lxml σου δίνει και .xpath() και .cssselect() (η δεύτερη μεταφράζει CSS σε XPath εσωτερικά). Πήρα δέκα στόχους επιλογής και έλεγξα ποιοι μπορούν όντως να εκφραστούν σε CSS.

| Στόχος | XPath | CSS (cssselect) |
|---|---|---|
Φιλτράρισμα με βάση το κείμενο (contains(text(),"bargain")) | Ναι | Δεν υπάρχει text predicate |
Επιλογή γονέα από παιδί (//b/parent::p) | Ναι | Δεν υπάρχει parent selector |
Επιστροφή τιμής attribute (//a/@href) | Ναι | Μόνο elements |
Επιστροφή text node (//p/text()) | Ναι | Δεν υπάρχουν text nodes |
Ancestor axis (//td/ancestor::div) | Ναι | Δεν υπάρχει προς τα πάνω πλοήγηση |
Φιλτράρισμα γονέα με βάση το πλήθος παιδιών (//ul[count(li)=4]) | Ναι | Δεν υπάρχει count predicate |
Φιλτράρισμα με βάση το μήκος κειμένου (string-length(text())>5) | Ναι | Δεν υπάρχει length predicate |
nth-child / last-child / adjacent sibling | Ναι | Ναι (3 βασικά) |
Επτά από τους δέκα στόχους δεν έχουν καθόλου CSS ισοδύναμο. Φιλτράρισμα βάσει περιεχομένου κειμένου, κίνηση προς τα πάνω σε γονείς και ancestors, επιστροφή τιμής attribute ή σκέτου text node ως αποτέλεσμα, predicates με βάση count — το CSS δεν μπορεί να τα εκφράσει. Μόνο τρία (nth-child, last-child, adjacent sibling) δουλεύουν και στα δύο. Αυτή είναι η μετρημένη απάντηση στο «τι κερδίζω πραγματικά όταν επιλέγω lxml». Το selectolax είναι μόνο CSS και δεν έχει καθόλου xpath() method, άρα αυτοί οι επτά τύποι query εκεί είτε γίνονται με πολλά Python loops είτε δεν γίνονται. Αν η λογική του scraping σου βασίζεται έστω και σε έναν από αυτούς, η απόφαση έχει ήδη ληφθεί.
(Και ναι, ο harness με έπιασε και δεύτερη φορά εδώ: προέβλεψα κενό σύνολο για το string-length(text())>5, αλλά δύο strings έξι χαρακτήρων ταίριαξαν. Διόρθωσα το expectation, όχι το εργαλείο.)
Τρεις βαθμίδες αυστηρότητας: etree, recover και lxml.html
Το XPath είναι ο λόγος να επιλέξεις lxml. Ο τρικάναλος έλεγχος αυστηρότητας είναι ο λόγος να το κρατήσεις.

Οι περισσότεροι parsers σου δίνουν μία συμπεριφορά για χαλασμένα input. Το lxml σου δίνει τρεις, και είναι αρκετά προβλέψιμες ώστε να περάσω έξι κατηγορίες malformed markup από καθεμία και να προ-καταχωρήσω πώς πρέπει να συμπεριφερθεί κάθε διαδρομή.
| Malformed input | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Unclosed tag <root><a>x</root> | raises | recovers | accepts |
Mis-nested <b><i></b></i> | raises | recovers | accepts |
Undefined entity | raises | recovers | accepts |
Bare & (Tom & Jerry) | raises | recovers | accepts |
Multiple roots <a>1</a><b>2</b> | raises | recovers | accepts |
| Well-formed XML | accepts | accepts (0 errors) | accepts |
Boolean attribute <input disabled> | raises | recovers | accepts |
Επτά στα επτά ταίριαξαν με το αναμενόμενο. Το lxml.etree σηκώνει XMLSyntaxError και στις έξι κατηγορίες malformed input. Πρόσθεσε recover=True στον ίδιο parser και καταπίνει τα σφάλματα και αναδομεί ένα χρησιμοποιήσιμο δέντρο — και αυτό είναι το υποτιμημένο σημείο — το parser.error_log στη συνέχεια απαριθμεί κάθε σφάλμα που κατάπιε. Το lxml.html δέχεται τα πάντα χωρίς διαμαρτυρία.
Ο classifier που αποφασίζει «σήκωσε σφάλμα vs ανακτήθηκε vs έγινε αποδεκτό» βασίζεται στο runtime μήκος του error_log και όχι σε hard-coded κανόνα, γι’ αυτό ένα well-formed έγγραφο που περνά από recover=True καταγράφεται σωστά ως «accepts» (κενό log) αντί για «recovers». Η πρώτη μου εκδοχή του classifier χαρακτήριζε κάθε αποτέλεσμα με recover=True ως «recovers» και λάθος κατέταξε το καθαρό input· η ανάγνωση του πραγματικού error_log το διόρθωσε.
Τι σημαίνει αυτό πρακτικά: για αυστηρή επικύρωση όπου ένα χαλασμένο feed πρέπει να αποτυγχάνει θορυβωδώς, χρησιμοποιείς lxml.etree. Για βρόμικο πραγματικό HTML που απλώς θέλεις να περάσεις, χρησιμοποιείς lxml.html. Και για τη μεσαία περίπτωση που τα περισσότερα εργαλεία δεν καλύπτουν — «να είναι επιεικές, αλλά να μου πει ακριβώς τι έσπασε για να το καταγράψω» — χρησιμοποιείς recover=True και διαβάζεις το error log. Το selectolax έχει την επιεική βαθμίδα και τίποτα άλλο, χωρίς strict mode και χωρίς error log.
iterparse: Το streaming gear που το selectolax δεν έχει καθόλου
Εδώ μιλάμε για δυνατότητα, όχι για ταχύτητα. Το selectolax καταναλώνει μόνο ολόκληρο string — δεν υπάρχει incremental interface. Το iterparse του lxml επιστρέφει elements καθώς κλείνουν, και σε συνδυασμό με το κλασικό fast_iter pattern (κάνε elem.clear() και διέγραψε τα προηγούμενα siblings καθώς προχωράς) κρατά τη μνήμη επίπεδη, όσο μεγάλο κι αν γίνει το έγγραφο.

Μέτρησα το χαρακτηριστικό μνήμης απευθείας — peak RSS μέσω ru_maxrss, κάθε subject σε δικό του φρέσκο process, σε 300.000 <record> στοιχεία συνολικού μεγέθους περίπου 26,7 MB (26.744.801 bytes).
| Λειτουργία | Peak RSS delta | Σημειώσεις |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | απελευθερώνει καθώς προχωρά· επίπεδο ανεξάρτητα από το πλήθος |
iterparse χωρίς clear | ~386 MB | κρατά αναφορές· τόσο βαρύ όσο και το full load |
etree.parse (full load, anchor) | ~386 MB | γνωστά βαρύ· δείχνει ότι το meter μετρά σωστά το μέγεθος |
Η bounded λειτουργία κρατά το peak RSS delta περίπου στα 1-2 MB έναντι περίπου 386 MB του full load — διαφορά μεγέθους 0,3-0,4% — και το πρώτο record event εμφανίζεται πριν καν τελειώσει το διάβασμα του αρχείου, άρα είναι πραγματικά incremental, όχι ψεύτικο streaming. Η πιο διδακτική γραμμή είναι η μεσαία. Τρέξε το ίδιο iterparse loop αλλά παράλειψε το clear(), και η μνήμη ξανανεβαίνει στα ~386 MB, επειδή κρατάς αναφορές σε όλα. Το κέρδος βρίσκεται στο clear(), όχι στο iterparse από μόνο του. Το anchor του full load, που διαβάζεται πολύ υψηλότερα από τη bounded λειτουργία, επιβεβαιώνει επίσης ότι το RSS meter όντως βλέπει το χάσμα μεγέθους και δεν είναι τυφλό. (Αυτό το memory test το έτρεξα σε αυτό το pack — είναι μέτρηση footprint, διαφορετική από τα δανεικά timing numbers.)
Η πραγματική εκδοχή αυτού: ένα XML export πολλών gigabytes που δεν χωρά στη RAM δεν έχει διαδρομή στο selectolax. Είναι parser streaming του lxml ή άλλη γλώσσα.
Namespaces: RSS, SVG και η παγίδα του default namespace
Δώδεκα περιπτώσεις namespaces, που καλύπτουν RSS σε τρία namespaces, SVG με default namespace και xlink, και XML με default namespace. Και οι δώδεκα πέρασαν.
Το lxml βγάζει το //dc:creator/text() από ένα RSS feed ακριβώς ως ["Alice", "Bob"], επιλύει τα //atom:link/@href και //content:encoded μέσα από τρία ξεχωριστά namespaces στο ίδιο έγγραφο, χειρίζεται //s:rect και //s:use/@xlink:href στο δεύτερο namespace του SVG, διασπά ονόματα Clark-notation {uri}local με QName και κάνει introspection μέσω nsmap. Αυτή είναι η τεκμηριωμένη και συντηρούμενη συμπεριφορά, και είναι μια ολόκληρη διάσταση που το selectolax δεν αγγίζει, γιατί το selectolax είναι μόνο για HTML5 και δεν επεξεργάζεται αυθαίρετα XML namespaces.
Υπάρχει μία τεκμηριωμένη παγίδα που αξίζει να θυμάσαι. Το XPath δεν έχει έννοια του default namespace. Αν βάλεις //book σε έγγραφο που δηλώνει xmlns="urn:...", θα πάρεις μηδέν αποτελέσματα — το κενό prefix δεν ορίζεται για XPath, όπως εξηγούν και τα docs του lxml. Πρέπει είτε να δέσεις ένα τεχνητό prefix (//c:book με namespaces={"c": "urn:..."}, που βρήκε και τα τρία) είτε να γυρίσεις σε //*[local-name()='book'] (επίσης τρία). Δεν είναι bug — είναι το XPath spec, υλοποιημένο πιστά. Απλώς ξαφνιάζει τους πάντες ακριβώς μία φορά.
Πραγματικά βρόμικες σελίδες: fidelity σε 11 πραγματικά scrapes
Τα synthetic tests είναι καθαρά· ο ιστός όχι. Επαναχρησιμοποίησα έντεκα πραγματικές αποθηκευμένες σελίδες από το fixture set του selectolax pack (έως 2026-07-10, μόνο για ανάγνωση) και πέρασα το lxml.html από αυτές ως subject.
| Fixture | Μέγεθος | Links | libxml2 recovered errors | Strict XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
Και τα έντεκα έγιναν parse με το lxml.html, ενώ οι μετρήσεις links, headings και εικόνων ταίριαξαν με τις επαναχρησιμοποιημένες μετρήσεις του lxml από το selectolax pack και στα έντεκα — cross-check true. Αυτή η συμφωνία μου λέει ότι η επαναχρησιμοποίηση είναι όντως apples-to-apples και όχι δύο διαφορετικές μετρήσεις με την ίδια ετικέτα.
Το side finding: ο strict XML parser σήκωσε σφάλμα στις δέκα από τις έντεκα σελίδες. Οι πραγματικές ιστοσελίδες είναι κατά πλειονότητα όχι well-formed XML, και ακριβώς γι’ αυτό υπάρχει το recovery mode της libxml2 για HTML. Η μοναδική εξαίρεση ήταν το BBC News, που αποδόθηκε με Next.js και ήταν αρκετά well-formed ώστε να επιβιώσει από strict XML parsing. Δεν χρειάζονται όλες οι σελίδες που λέγονται «HTML» το recovery path.
Μια σημείωση για το counting που είναι εύκολο να σε μπερδέψει. Στο docs_python.html, το //a[@href] (ύπαρξη attribute) μέτρησε 343, ενώ το if n.get("href") του selectolax pack (truthy τιμή) μέτρησε 341. Τα δύο επιπλέον είναι κενά links href="". Αυτή είναι διαφορά στον τρόπο μέτρησης — attribute exists versus attribute is non-empty — όχι διαφορά στη συμπεριφορά του lxml, και οι αριθμοί συμφωνούν μόλις ευθυγραμμίσεις το predicate. Αξίζει να το θυμάσαι όταν κάνεις scraping: το αν μετράνε τα άδεια href είναι επιλογή του φίλτρου σου, όχι του parser.
Το όριο βάθους που μοιάζει bug (αλλά δεν είναι)
Το selectolax pack είχε καταγράψει ότι το lxml χάνει το βαθύτερο περιεχόμενο σε markup με nesting 1.000 και 5.000 επιπέδων από <div> και το παρουσίαζε ως «το lxml χάνει αθόρυβα το βαθύτερο περιεχόμενο». Ήθελα τον μηχανισμό, οπότε έτρεξα τον default parser έναντι του huge_tree=True.

| Ζητούμενο βάθος | Default parser φτάνει | huge_tree=True φτάνει |
|---|---|---|
| 300 | 253 (κόβει τα υπόλοιπα) | 299 (ανακτήθηκε) |
| 1000 | 253 (κόβει τα υπόλοιπα) | 999 (ανακτήθηκε) |
| 5000 | 253 (κόβει τα υπόλοιπα) | 2045 (κόβει ακόμα) |
Ο default parser κόβει περίπου στα 253 επίπεδα και απορρίπτει αθόρυβα ό,τι είναι βαθύτερο. Αυτό δεν είναι bug — είναι άμυνα της libxml2 απέναντι σε DoS, ένα όριο nesting περίπου 256 επιπέδων που εμποδίζει ένα εχθρικό έγγραφο να τινάξει τη στοίβα στον αέρα, και είναι τεκμηριωμένο στο launchpad thread του lxml σχετικά με το XML_PARSE_HUGE. Βάλε huge_tree=True και τα βάθη 300 και 1.000 επανέρχονται πλήρως. Στα 5.000 βάθη, όμως, φτάνει μόνο μέχρι το 2.045 ακόμη και με ενεργό το huge_tree — υπάρχει δεύτερο, πιο αυστηρό recursion ceiling στην libxml2, πάνω από το ρυθμιζόμενο, και το huge_tree δεν το αίρει.
Άρα το πρακτικό action item είναι σαφές: όταν αναλύεις βαθύ markup από πηγή που εμπιστεύεσαι, χρησιμοποίησε lxml.html.HTMLParser(huge_tree=True). Αυτό που προσθέτει αυτό το pack πάνω από την επαναχρησιμοποιημένη παρατήρηση είναι ο μηχανισμός (όριο ασφαλείας, όχι διαφθορά δεδομένων), η διόρθωση (huge_tree) και το γεγονός ότι υπάρχει δεύτερο όριο που η διόρθωση δεν φτάνει.
Read/Write DOM, serialization, encoding
Το lxml είναι πλήρες δέντρο ανάγνωσης/εγγραφής, όχι μόνο extractor, και επαλήθευσα το πεδίο επεξεργασίας περίπτωση-περίπτωση. Και οι οκτώ DOM λειτουργίες πέρασαν: SubElement, insert, remove, replace, strip_tags (αφαίρεση tags και διατήρηση του κειμένου τους), strip_elements (αφαίρεση tags και του κειμένου τους), drop_tree (αποκλειστικό του lxml.html) και το μοντέλο δύο θέσεων text/tail που μπερδεύει τους νεοεισερχόμενους — στο <p>head<b>bold</b>tail</p>, το p.text είναι "head", το b.text είναι "bold" και το b.tail είναι "tail".
Η serialization πέρασε 5 στα 5: tostring σε XML και HTML mode (το HTML αφήνει σωστά τα void elements χωρίς self-closing), pretty_print, canonicalization C14N (method="c14n", ακόμη ένα lxml exclusive) και καθαρό round-trip.
Η κωδικοποίηση είναι το σημείο όπου το lxml ξεχωρίζει ήσυχα. Δώσε non-UTF-8 bytes — "<p>café éè</p>".encode("latin-1") μέσω lxml.html.fromstring — και ανακτά το café éè ανέπαφο, χωρίς χαρακτήρες αντικατάστασης U+FFFD και χωρίς χαμένα bytes. Αυτό αναπαράγει άμεσα τον ρόλο του ως «clean reference» στο selectolax pack, όπου το ίδιο input αλλοιωνόταν αθόρυβα στις άλλες δύο μηχανές (ο Lexbor παρήγαγε replacement characters, ο Modest έκοβε bytes εντελώς). Η ανίχνευση charset από τη libxml2 είναι απλώς πιο σταθερή εδώ.
Η άλλη πλευρά είναι η αυστηρότητα στο πώς δηλώνεις την κωδικοποίηση. Το encoding="latin-1" σε XML declaration σηκώνει XMLSyntaxError: Unsupported encoding: latin-1, ενώ το IANA-canonical encoding="ISO-8859-1" γίνεται parse κανονικά και επιστρέφει café. Η libxml2 δέχεται μόνο canonical ονόματα encoding, όχι aliases — λεπτομέρεια που είχε τεκμηριωθεί από παλιά στο launchpad #613302. Ενοχλητικό αν δεν το ξέρεις, απλό μόλις το μάθεις.
Τέλος, ο κύκλος ζωής των κόμβων. Έτρεξα τρία σενάρια stale-handle σε απομονωμένα subprocesses (ένα hard crash θα φαινόταν ως μη μηδενικό exit): κράτησα έναν κόμβο αφού το tree του έγινε garbage-collected, διάβασα ένα handle μετά το drop_tree() και χρησιμοποίησα έναν κόμβο μετά το remove(). Σε κανένα από τα τρία δεν υπήρξε segfault — το lxml κρατά τη αναφορά του κόμβου προς το tree του ζωντανή για να αποτρέψει use-after-free. Το ίδιο καθαρό αποτέλεσμα πήρε και το selectolax σε αυτό το test.
Ταχύτητα και μνήμη (δανεικά, και με ειλικρίνεια γι’ αυτό)
Ό,τι βρίσκεται σε αυτή την ενότητα έχει επαναχρησιμοποιηθεί από το selectolax pack, έως 2026-07-13. Αυτό το pack δεν παρήγαγε δικά του timing numbers, και προτιμώ να το πω δύο φορές παρά να νομίσεις ότι ξαναμέτρησα οτιδήποτε.
| Διάσταση | Τιμή lxml | Ανάγνωση |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | ~33-34% πιο γρήγορο από selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | περίπου ισοπαλία με Lexbor σε μικρά μεγέθη |
| 100k-node CSS throughput | 3,002,646 nodes/s | ταχύτερη κλίμακα από τις τρεις C engines |
| 10 MB RSS delta | 128.9 MB | το πιο ελαφρύ από τους έξι parsers, ~1.7x πιο οικονομικό από το BeautifulSoup |
| Import cold start | 14.1 ms | ~2.3x πιο γρήγορο από imports τύπου parsel |
Τα νούμερα του pure-parse και του throughput είναι ισχυρά, και το lxml είναι το πιο φειδωλό σε μνήμη από τους έξι parsers που μετρήθηκαν. Η εικόνα του threading χρειάζεται όμως μια επιφύλαξη. Τα επαναχρησιμοποιημένα δεδομένα δείχνουν 4-thread wall-clock speedup μόλις 1.21x, σημειωμένο ως inconclusive — αλλά αυτό είναι η διαδρομή shared-default-parser. Το FAQ του lxml λέει ξεκάθαρα ότι το GIL απελευθερώνεται κατά το parsing μόνο όταν κάθε thread χρησιμοποιεί δικό του parser (ή αντίγραφο του default)· shared parser σημαίνει σειριοποιημένη πρόσβαση. Επαλήθευσα δομικά το API surface για να γίνει σωστά (XMLParser.copy() υπάρχει, τα get/set_default_parser υπάρχουν, το XPathEvaluator κουβαλά εσωτερικό lock), αλλά δεν μέτρησα το speedup με per-thread parser — αυτό θα ήταν νέα μέτρηση timing, και αυτό το pack δεν παράγει τέτοιες. Άρα διάβασε το 1.21x ως «στην αφελή shared διαδρομή», όχι ως όριο του threading στο lxml.
Και μια αστερίσκο σε όλα αυτά: πρόκειται για νούμερα μιας πλατφόρμας, macOS arm64. Ο ισχυρισμός ότι το pure parse του lxml ξεπερνά το Lexbor πάει κόντρα στη συνήθη άποψη ότι ο parser βασισμένος στο Lexbor είναι ο πιο γρήγορος, άρα θέλει πραγματικά επανέλεγχο σε Linux x86_64 πριν το θεωρήσει κανείς οριστικό.
Άδεια χρήσης: η βαρετά καλή νίκη
Το lxml διανέμεται με BSD-3-Clause, και οι C βιβλιοθήκες που ενσωματώνει — libxml2 και libxslt — είναι και οι δύο MIT. Είναι μια απολύτως permissive αλυσίδα χωρίς copyleft πουθενά, κάτι που μετράει αμέσως μόλις πας να το αναδιανείμεις. Για σύγκριση, το wheel του selectolax περιλαμβάνει LGPL-2.1 Modest και Apache-2.0 Lexbor, οπότε το lxml είναι πιο καθαρή ιστορία για shipping σε κλειστό προϊόν.
Υπάρχει και πρακτικό πλεονέκτημα εγκατάστασης: το lxml δημοσιεύει προ-χτισμένα wheels που κάνουν static link τη libxml2 και τη libxslt, άρα το pip install lxml συνήθως δεν χρειάζεται system libxml2 ούτε compiler στο μηχάνημά σου — διαφορετική εμπειρία από το να το χτίζεις από source.
Πού ταιριάζει το lxml — και πού αναλαμβάνει ένα AI extraction layer
Ώρα να ξεκαθαρίσουμε το όριο, γιατί είναι εύκολο να γίνει λάθος κατηγορίας. Το lxml είναι βιβλιοθήκη parsing. Σου δίνει δέντρο και εξαιρετική μηχανή query, και όλα όσα γίνονται γύρω από αυτό το δέντρο παραμένουν δική σου δουλειά: να φέρεις τη σελίδα, να κάνεις render JavaScript, να περάσεις anti-bot άμυνες, να γράψεις και να συντηρήσεις τα XPath, και να δομήσεις το αποτέλεσμα. Αυτό είναι άλλο επίπεδο από μια hosted extraction service, και τα δύο δεν είναι τόσο ανταγωνιστές όσο γείτονες.
Για έναν developer που δεν θέλει να συντηρεί ο ίδιος το fetch-render-select-maintain stack, αυτό το ανώτερο επίπεδο είναι εκεί που ζει κάτι όπως το Thunderbit — και για αυτό το κοινό μιλάμε για API, MCP server και CLI, όχι για το browser extension. Το Thunderbit Open API εκθέτει το POST /distill για να μετατρέπει μια σελίδα σε καθαρό Markdown και το POST /extract για να τραβά δομημένα δεδομένα με βάση JSON Schema, με επιλογή renderMode και batch jobs για όγκο. Ο ίδιος μηχανισμός είναι διαθέσιμος ως MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) για agents και coding assistants, και ως CLI που τρέχεις κατευθείαν από τερματικό μέσω npx @thunderbit/thunderbit-cli. Χειρίζεται JS rendering, anti-bot και CAPTCHAs out of the box και επιστρέφει JSON που ταιριάζει στο schema — δηλαδή το επίπεδο πάνω από το parsing, όχι αντικατάστασή του.
Δοκίμασε το Thunderbit για εξαγωγή web δεδομένων
Το πλαίσιο είναι απλό. Διάλεξε lxml όταν εσύ ελέγχεις το pipeline και θέλεις χειρουργικό έλεγχο XPath πάνω σε ένα δέντρο που καταλαβαίνεις. Διάλεξε AI extraction API όταν δεν θέλεις καθόλου να συντηρείς selectors και rendering. Πολλά πραγματικά συστήματα χρησιμοποιούν και τα δύο — το lxml για τα δομημένα feeds που ελέγχουν και μια extraction service για τις βρόμικες, μακρές ουρές σελίδων που δεν ελέγχουν.
Τι δεν δοκίμασε αυτή η αποτίμηση
Αυτή είναι προκαταρκτική αποτίμηση, όχι οριστικό σκορ, οπότε να τι δεν καλύπτει.
Όλα τα timing και memory figures είναι επαναχρησιμοποιημένα, μιας πλατφόρμας (macOS arm64, Python 3.14) και κληρονομούν τις επιφυλάξεις εκείνου του pack — το αποτέλεσμα ότι «το lxml είναι πιο γρήγορο στο pure parsing» είναι αντίθετο με το γενικό consensus και θέλει επανέλεγχο σε Linux x86_64. Το per-thread-parser threading speedup δεν έχει δοκιμαστεί (θα απαιτούσε νέα μέτρηση timing). Μέτρησα iterparse memory σε 300k records αλλά όχι σε πραγματικό XML gigabytes, όχι iterparse σε HTML έναντι XML και όχι πολύωρο soak. Η XSLT 1.0 του lxml, η επικύρωση RelaxNG / XMLSchema / DTD και οι επεκτάσεις EXSLT δεν δοκιμάστηκαν καθόλου εδώ — μεγάλο capability surface, αλλά πέρα από τον πυρήνα parsing και selection. Παρατήρησα το δεύτερο όριο βάθους στο 2.045 αλλά δεν προσδιόρισα την ακριβή recursion constant της libxml2. Δοκιμάστηκε μόνο η σταθερή 6.1.1, όχι η alpha 7.0.0. Windows, builds από source και το free-threaded 3.14t build δεν δοκιμάστηκαν. Και μέσα στο ίδιο το XPath, κάλυψα τις ενσωματωμένες συναρτήσεις αλλά όχι XPath variables, custom Python extension functions ή επαναχρησιμοποίηση precompiled αντικειμένων etree.XPath.
Η ετυμηγορία
Το lxml δεν είναι το καινούργιο γρήγορο πράγμα, και αυτό ακριβώς είναι το πλεονέκτημα. Είναι ένα binding της libxml2 με ιστορία δύο δεκαετιών, με πλήρη μηχανή XPath 1.0 που καμία mainstream Python εναλλακτική δεν φτάνει, τρεις προβλέψιμες βαθμίδες αυστηρότητας parsing με error log στο μέσο, πραγματικό streaming parser για έγγραφα που δεν χωρούν στη μνήμη, σωστό χειρισμό πολλών namespaces και κωδικοποιήσεων, και πλήρως permissive άδεια χρήσης. Τα λίγα αιχμηρά σημεία — το όριο βάθους γύρω στα ~253 επίπεδα και ο αριθμός του shared-parser threading — είναι τεκμηριωμένα, ρυθμιζόμενα και τώρα εξηγημένα.
Αν εσύ ελέγχεις το scraping pipeline σου και βασίζεσαι στο XPath, το lxml παραμένει ο parser που θα διαλέξεις. Αν δεν θέλεις να συντηρείς selectors και rendering, γι’ αυτό υπάρχει ένα AI extraction layer όπως το Thunderbit API, MCP και CLI — καθαρή κατανομή εργασίας, όχι ανταγωνισμός. Όπως και να ’χει, αντιμετώπισε αυτά τα νούμερα ως προκαταρκτικά και ξαναμέτρησε το timing στη δική σου πλατφόρμα πριν τα βάλεις σε design doc.
Δοκίμασε το Thunderbit για εξαγωγή web δεδομένων Get Started Free
Συχνές ερωτήσεις
Είναι το lxml web scraper;
Όχι. Το lxml είναι parser και serializer — ένα Python binding της libxml2/libxslt που μετατρέπει markup σε επεξεργάσιμο, ερωτήσιμο δέντρο. Δεν φέρνει σελίδες, δεν κάνει render JavaScript και δεν χειρίζεται anti-bot άμυνες· εσύ παρέχεις το request layer (με requests, httpx, headless browser ή scraping service) και δίνεις τα bytes στο lxml.
Πότε να χρησιμοποιήσω lxml αντί για BeautifulSoup ή selectolax; Διάλεξε lxml όταν χρειάζεσαι XPath. Το BeautifulSoup μπορεί πράγματι να χρησιμοποιήσει το lxml ως backend parser, αλλά δεν εκθέτει native XPath, και το selectolax είναι μόνο CSS και πιο γρήγορο στη στενή του χρήση. Αν η λογική επιλογής σου χρειάζεται φιλτράρισμα βάσει κειμένου, πλοήγηση σε parent ή ancestor, εξαγωγή attribute ή text-node, ή predicates με count, η μηχανή XPath του lxml είναι η μόνη mainstream επιλογή στην Python που τα εκφράζει απευθείας.
Γιατί το lxml κόβει αθόρυβα πολύ βαθιά nested περιεχόμενα;
Ο default parser του περιορίζει το nesting περίπου στα 253 επίπεδα — άμυνα της libxml2 απέναντι σε εχθρικά έγγραφα, όχι bug. Βάλε huge_tree=True (π.χ. lxml.html.HTMLParser(huge_tree=True)) και ανακτά πλήρως βάθη 300 και 1.000. Υπάρχει όμως και δεύτερο, πιο σκληρό recursion ceiling γύρω στα 2.045 επίπεδα που το huge_tree δεν ξεπερνά.
Το lxml απελευθερώνει το GIL για multithreaded parsing; Μόνο υπό τις σωστές συνθήκες. Το FAQ του lxml λέει ότι το GIL απελευθερώνεται κατά το parsing όταν κάθε thread χρησιμοποιεί δικό του parser ή αντίγραφο του default parser· shared parser σημαίνει σειριοποιημένη πρόσβαση. Το επαναχρησιμοποιημένο speedup 4 threads του 1.21x αντανακλά τη naïve shared-parser διαδρομή, όχι το per-thread-parser ceiling, το οποίο δεν μετρήθηκε εδώ.
Συντηρείται ακόμα το lxml το 2026; Ναι. Η stable έκδοση 6.1.1 κυκλοφόρησε στις 2026-05-18, το repository έκανε last push στις 2026-07-02 και υπάρχει alpha 7.0.0 σε εξέλιξη. Με περίπου 3.000 GitHub stars και ενεργά συντηρούμενη libxml2 από κάτω, παραμένει τρέχουσα, καλά υποστηριζόμενη βιβλιοθήκη και όχι legacy.


