Κάθε λίγους μήνες ξεπροβάλλει κι ένας πιο γρήγορος HTML parser, τα benchmark κάνουν τον γύρο του ίντερνετ και κάποιος κηρύσσει πως το παλιό καθεστώς τελείωσε. Μετά όμως πας να διαλέξεις κάθε παράγραφο που περιέχει μια συγκεκριμένη λέξη ή να πάρεις το parent ενός matching node, και τότε θυμάσαι γιατί το lxml παραμένει ανοιχτό σε άλλο tab.
Το lxml είναι ένα 20ετές binding πάνω στο libxml2. Δεν είναι 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 και validation για 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 |
| Δημιουργήθηκε | 2011-02-11 |
| Τελευταίο push | 2026-07-02 |
| Σταθερό PyPI | 6.1.1 (2026-05-18) |
| Ενσωματωμένη μηχανή | libxml2 2.14.6 + libxslt 1.1.43 |
Πριν προλάβει κάποιος να με κατηγορήσει για υπερβολή: σε αυτή την αξιολόγηση δεν υπάρχουν «μυστικά». Το lxml είναι αρκετά παλιό ώστε κάθε συμπεριφορά που αναφέρω εδώ να είναι τεκμηριωμένη κάπου στα docs του lxml, σε changelog του libxml2 ή σε κάποιο launchpad thread. Δεν βρήκα κανένα κρυφό, ατεκμηρίωτο τρικ και δεν θα το επινοήσω. Η αξία όσων ακολουθούν είναι ότι είναι οργανωμένα, ποσοτικοποιημένα και στημένα γύρω από το lxml ως κεντρικό θέμα — όχι ότι είναι καινούρια.
Το στήσιμο των δοκιμών (και γιατί οι χρόνοι είναι δανεικοί)
Δύο κατηγορίες δεδομένων μπαίνουν σε αυτή την αξιολόγηση και προέρχονται από δύο διαφορετικές πηγές, οπότε θα είμαι ξεκάθαρος για το ποια είναι ποια.
Τα capability tests — συμπεριφορά XPath, τα δύο parser APIs, namespaces, encoding, κύκλος ζωής nodes — τα έτρεξα από την αρχή σε ένα μηχάνημα: 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 επιστρέψει string attribute.
Οι αριθμοί χρόνου και footprint μνήμης δεν προέρχονται από αυτό το pack. Έχουν επαναχρησιμοποιηθεί αυτούσιοι από το προηγούμενο benchmark pack του selectolax — ίδιο μηχάνημα, ίδιο virtual environment, ίδιο build του lxml και του libxml2, benchmarks ως 2026-07-13 — και δεν τους έτρεξα ξανά εδώ. Είναι σκόπιμο. Αν ξανατρέξεις timing benchmarks μαζί με ένα batch από capability scripts, θα δημιουργήσεις CPU contention που θα μολύνει τα επαναχρησιμοποιημένα νούμερα, και θα ήταν διπλή δουλειά: το lxml είχε ήδη μετρηθεί πλήρως ως control library σε εκείνο το pack. Η επαναχρησιμοποίηση του ίδιου bench κρατάει τα πάντα συγκρίσιμα αντί να βάζει μέσα μια δεύτερη, λίγο διαφορετική μέτρηση. Άρα όταν βλέπεις πιο κάτω έναν αριθμό σε milliseconds, διάβασέ τον ως "ίδιο test rig, ως 2026-07-13", όχι ως "το ξαναμέτρησα σήμερα".
Τα ευρήματα φέρουν ετικέτα βεβαιότητας: single-observation για τα ντετερμινιστικά capability tests, triple-run για τις επαναχρησιμοποιημένες κατανομές χρόνου, hypothesis όταν προτείνω έναν μηχανισμό που δεν απομόνωσα.
XPath: το ένα πράγμα που το selectolax και το BeautifulSoup απλώς δεν έχουν
Αυτό είναι το βασικό σημείο, οπότε ξεκινάω από εδώ.

Πέρασα το xpath() του lxml από έναν πίνακα 37 στοιχείων που είχε προ-καταχωρηθεί — το αναμενόμενο αποτέλεσμα για κάθε περίπτωση είχε γραφτεί στον κώδικα πριν ξεκινήσει το test, ώστε να μην μπορώ κατά λάθος να βάλω πιο χαμηλό πήχη. Δέκα axes, εννέα τύποι predicate, δέκα built-in functions, τρεις scalar τύποι επιστροφής και πέντε σκόπιμα trap cases με XPath 2.0 syntax που η 1.0 μηχανή του lxml θα έπρεπε να απορρίψει.
| Κατηγορία | Κάλυψη | Αποτέλεσμα |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predicates | [1] / last() / position()<n / ισότητα 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 |
| Τύποι επιστροφής | boolean / number scalars | 3/3 pass |
| Trap cases | matches() / sequences / if-then-else / except / syntax error | 5/5 απορρίφθηκαν σωστά |
Το σκορ είναι 37/37, και η στήλη με τα traps είναι το σημαντικό κομμάτι. Τα matches(), sequence expressions, if/then/else και except είναι syntax του XPath 2.0, και η 1.0 μηχανή του libxml2 δεν τα μισοϋποστηρίζει — ρίχνει XPathEvalError και αρνείται να προχωρήσει, αντί να επιστρέψει σιωπηλά λάθος node set. Άρα εδώ έχουμε τέλειο σκορ αφού προσπαθήσαμε να το σπάσουμε, όχι τέλειο σκορ από εύκολες ερωτήσεις. Κάθε συμπεριφορά εδώ είναι ακριβώς αυτή που περιγράφουν τα docs του XPath στο lxml, που είναι και το ζητούμενο.
Θα παραδεχτώ ένα πράγμα που το harness το έβγαλε λάθος, γιατί είναι το είδος του "37/37" που μπορείς όντως να εμπιστευτείς. Το πρώτο μου expected set για το //div[.//a[@href]] προέβλεπε δύο hits· το run έδωσε ένα. Νόμιζα για περίπου τριάντα δευτερόλεπτα ότι το lxml έκανε λάθος, μετά κοίταξα το fixture και είδα ότι το δεύτερο στοιχείο ήταν <footer> και όχι <div> — το λάθος ήταν δικό μου, όχι της μηχανής. Διόρθωσα το expected set και άφησα το λάθος σε σχόλιο στον κώδικα. Αυτή είναι η σωστή σειρά υποψιών: πρώτα το δικό σου test, μετά η 20χρονη C βιβλιοθήκη.
XPath vs CSS: τι κυριολεκτικά δεν μπορείς να εκφράσεις με CSS
Ο αφηρημένος ισχυρισμός ότι το XPath είναι πιο δυνατό αξίζει έναν συγκεκριμένο αριθμό, οπότε μέτρησα τη διαφορά. Το lxml σου δίνει και .xpath() και .cssselect() (το δεύτερο μεταφράζει CSS σε XPath από κάτω). Πήρα δέκα στόχους επιλογής και έλεγξα ποιοι μπορούν πραγματικά να εκφραστούν με CSS.

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

Οι περισσότεροι parsers σου δίνουν μία μόνο συμπεριφορά για σπασμένο input. Το lxml σου δίνει τρεις, και είναι τόσο προβλέψιμες ώστε πέρασα έξι κατηγορίες malformed markup από καθεμία και προ-καταχώρησα πώς θα έπρεπε να συμπεριφερθεί κάθε διαδρομή.
| Malformed input | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Ανοιχτό tag <root><a>x</root> | raises | recovers | accepts |
Λανθασμένη εσωτερική φωλιά <b><i></b></i> | raises | recovers | accepts |
Undefined entity | raises | recovers | accepts |
Γυμνό & (Tom & Jerry) | raises | recovers | accepts |
Πολλαπλά 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, καταπίνει τα errors και ξαναχτίζει ένα αξιοποιήσιμο δέντρο — και αυτό είναι το υποτιμημένο κομμάτι — ενώ το parser.error_log καταγράφει κάθε σφάλμα που κατάπιε. Το lxml.html δέχεται τα πάντα χωρίς παράπονο.
Ο classifier που αποφασίζει αν κάτι "έφαγε σφάλμα", "ανέκαμψε" ή "δέχτηκε" καθοδηγείται από το runtime μήκος του error_log και όχι από hard-code, γι’ αυτό ένα well-formed έγγραφο που τρέχει με recover=True χαρακτηρίζεται σωστά ως "accepts" (κενό log) και όχι ως "recovers." Η πρώτη μου έκδοση του classifier χαρακτήριζε κάθε αποτέλεσμα με recover=True ως "recovers" και έδινε λάθος ετικέτα στο καθαρό input· η ανάγνωση του πραγματικού error_log το διόρθωσε.
Τι κερδίζεις πρακτικά: για αυστηρή validation όπου ένα σπασμένο feed πρέπει να αποτύχει δυνατά, χρησιμοποίησε lxml.etree. Για βρόμικο, πραγματικό HTML που απλώς θέλεις να περάσει, χρησιμοποίησε lxml.html. Και για τη μέση περίπτωση που τα περισσότερα εργαλεία δεν μπορούν να κάνουν — "να είσαι επιεικής, αλλά να μου πεις ακριβώς τι ήταν χαλασμένο για να το καταγράψω" — χρησιμοποίησε recover=True και διάβασε το error log. Το selectolax έχει τη χαλαρή ταχύτητα και τίποτα άλλο, ούτε strict mode ούτε error log.
iterparse: το streaming εργαλείο που το selectolax δεν έχει καθόλου
Εδώ μιλάμε για δυνατότητα, όχι για ρυθμιστικό ταχύτητας. Το selectolax δέχεται μόνο ολόκληρο string — δεν υπάρχει incremental interface. Το iterparse του lxml βγάζει elements τη στιγμή που κλείνουν, και μαζί με το κλασικό fast_iter pattern (κάνε elem.clear() και σβήσε τα προηγούμενα siblings καθώς προχωράς) κρατάει τη μνήμη επίπεδη, όσο μεγάλο κι αν γίνει το έγγραφο.

Μέτρησα τη συμπεριφορά μνήμης άμεσα — peak RSS μέσω ru_maxrss, κάθε subject σε δική του φρέσκια διεργασία, πάνω σε 300,000 <record> elements συνολικού μεγέθους περίπου 15 MB.
| Mode | Peak RSS delta | Σημειώσεις |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | απελευθερώνεται στην πορεία· επίπεδο ανεξάρτητα από το πλήθος |
iterparse χωρίς clear | ~386 MB | κρατάει αναφορές· τόσο βαρύ όσο και το full load |
etree.parse (full load, anchor) | ~386 MB | γνωστά βαρύ· δείχνει ότι ο μετρητής πιάνει το μέγεθος |
Η περιορισμένη λειτουργία κρατά το peak RSS delta περίπου στα 1-2 MB απέναντι σε περίπου 386 MB του full load — διαφορά μεγέθους 0.3-0.4% — και το πρώτο event record εμφανίζεται πριν τελειώσει καν η ανάγνωση του αρχείου, άρα είναι πραγματικά incremental, όχι ψεύτικο streaming. Η πιο διδακτική γραμμή είναι η μεσαία. Τρέξε τον ίδιο iterparse βρόχο αλλά παράλειψε το clear(), και η μνήμη ανεβαίνει ξανά στα ~386 MB, επειδή κρατάς αναφορές σε όλα. Το κέρδος βρίσκεται στο clear(), όχι στο iterparse από μόνο του. Το anchor του full-load που διαβάζει πολύ υψηλότερα από το bounded mode επιβεβαιώνει επίσης ότι ο μετρητής RSS μπορεί όντως να δει τη διαφορά μεγέθους αντί να μετρά στα τυφλά. (Αυτό το memory test το έτρεξα σε αυτό το pack — είναι μέτρηση footprint, διαφορετική από τους δανεικούς χρόνους.)
Η πραγματική μετάφραση αυτού: ένα XML export πολλών gigabytes που δεν χωράει στη RAM δεν έχει διαδρομή στο selectolax. Ή θα πας στο streaming parser του lxml ή σε άλλη γλώσσα.
Namespaces: RSS, SVG και η παγίδα του default namespace
Δώδεκα περιπτώσεις namespace, που καλύπτουν 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, ενώ επιθεωρεί το nsmap. Αυτή είναι η διατηρημένη και τεκμηριωμένη συμπεριφορά, και είναι μια ολόκληρη διάσταση που το 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, και το lxml το εφαρμόζει πιστά. Απλώς μπερδεύει τους πάντες μία φορά.
Πραγματικά βρόμικες σελίδες: fidelity σε 11 πραγματικές scrapes
Τα synthetic tests είναι καθαρά· ο ιστός δεν είναι. Επαναχρησιμοποίησα έντεκα πραγματικές, αποθηκευμένες σελίδες από το fixture set του selectolax pack (ως 2026-07-10, μόνο για ανάγνωση) και πέρασα το lxml.html από αυτές, με το lxml ως κεντρικό αντικείμενο.
| Fixture | Size | 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 |
Και τα έντεκα αναλύθηκαν με lxml.html, ενώ οι μετρήσεις για link, heading και image ταίριαξαν με τις επαναχρησιμοποιημένες μετρήσεις του lxml από το pack του selectolax και στα έντεκα — cross-check true. Αυτή η συμφωνία είναι που μου λέει ότι η επαναχρησιμοποίηση είναι πραγματικά apples-to-apples και όχι δύο διαφορετικές μετρήσεις με ίδια ετικέτα.
Το παράπλευρο εύρημα: ο strict XML parser έβγαλε error στις δέκα από τις έντεκα σελίδες. Οι πραγματικές ιστοσελίδες είναι κατά κανόνα όχι well-formed XML, και ακριβώς γι’ αυτό υπάρχει το recovery mode του libxml2 για HTML. Η μοναδική εξαίρεση ήταν το BBC News, που αποδίδεται από Next.js και ήταν αρκετά καλά σχηματισμένο ώστε να περάσει strict XML parsing. Δεν χρειάζονται όλες οι σελίδες που λέγονται "HTML" το recovery path.
Μια σημείωση για counting που εύκολα σε παγιδεύει. Στο docs_python.html, το //a[@href] (attribute-existence) μέτρησε 343, ενώ το if n.get("href") του selectolax pack (truthy value) μέτρησε 341. Τα δύο επιπλέον είναι empty href="" links. Αυτό είναι διαφορά στη σύμβαση μέτρησης — attribute exists έναντι attribute is non-empty — όχι διαφορά στη συμπεριφορά του lxml, και οι μετρήσεις συμφωνούν μόλις ευθυγραμμίσεις το predicate. Χρήσιμο να το ξέρεις στο scraping: το αν τα empty hrefs μετράνε είναι επιλογή του φίλτρου σου, όχι του parser.
Το βάθος που μοιάζει με bug (αλλά δεν είναι)
Το pack του selectolax είχε καταγράψει ότι το lxml χάνει το πιο βαθύ περιεχόμενο σε nested <div> markup 1,000 και 5,000 επιπέδων, και το είχε διατυπώσει ως "το lxml χάνει σιωπηλά το βαθύτερο περιεχόμενο". Ήθελα να βρω τον μηχανισμό, οπότε έτρεξα τον default parser απέναντι στο huge_tree=True.

| Requested depth | Default parser reaches | huge_tree=True reaches |
|---|---|---|
| 300 | 253 (drops rest) | 299 (recovered) |
| 1000 | 253 (drops rest) | 999 (recovered) |
| 5000 | 253 (drops rest) | 2045 (still drops) |
Ο default parser κόβει γύρω στα 253 επίπεδα και πετάει σιωπηλά οτιδήποτε είναι βαθύτερο. Αυτό δεν είναι bug — είναι άμυνα DoS του libxml2, ένα όριο ένθεσης περίπου 256 επιπέδων που εμποδίζει ένα εχθρικό έγγραφο να τινάξει το stack, και τεκμηριώνεται στο launchpad thread του lxml για το XML_PARSE_HUGE. Βάλε huge_tree=True και τα depths 300 και 1,000 επανέρχονται πλήρως. Το depth 5,000 όμως φτάνει μόνο μέχρι το 2,045 ακόμη και με ενεργό huge_tree — υπάρχει ένα δεύτερο, πιο σκληρό recursion ceiling στο libxml2 πάνω από το ρυθμιζόμενο, και το huge_tree δεν το αφαιρεί.
Άρα η πρακτική οδηγία είναι σαφής: όταν κάνεις parse πολύ βαθύ markup από πηγή που εμπιστεύεσαι, χρησιμοποίησε lxml.html.HTMLParser(huge_tree=True). Αυτό που προσθέτει αυτό το pack πάνω από την επαναχρησιμοποιημένη παρατήρηση είναι ο μηχανισμός (όριο ασφαλείας, όχι αλλοίωση δεδομένων), η διόρθωση (huge_tree) και το γεγονός ότι υπάρχει ένα δεύτερο όριο που η διόρθωση δεν αγγίζει.
DOM για ανάγνωση και εγγραφή, serialization, encoding
Το lxml είναι πλήρες tree για ανάγνωση και εγγραφή, όχι μόνο για εξαγωγή, και επαλήθευσα το editing surface περίπτωση-περίπτωση. Και οι οκτώ 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 πέρασε πέντε στα πέντε: tostring σε XML και HTML mode (το HTML σωστά δεν self-closes τα void elements), pretty_print, C14N canonicalization (method="c14n", ακόμη ένα lxml exclusive) και καθαρό round-trip.
Το encoding είναι το σημείο όπου το lxml ξεχωρίζει αθόρυβα. Δώσε non-UTF-8 bytes — "<p>café éè</p>".encode("latin-1") μέσω lxml.html.fromstring — και ανακτά το café éè ανέπαφο, χωρίς χαρακτήρες αντικατάστασης U+FFFD, χωρίς χαμένα bytes. Αυτό αναπαράγει άμεσα τον ρόλο του ως «καθαρή αναφορά» στο pack του selectolax, όπου το ίδιο input αλλοιωνόταν αθόρυβα στις άλλες δύο μηχανές (ο Lexbor παρήγε replacement characters, ο Modest έκοβε bytes εντελώς). Η charset detection του libxml2 απλώς στέκεται πιο σταθερά εδώ.
Η αντίστροφη πλευρά είναι η αυστηρότητα στο πώς δηλώνεις ένα encoding. Το encoding="latin-1" σε XML declaration ρίχνει XMLSyntaxError: Unsupported encoding: latin-1, ενώ το IANA-canonical encoding="ISO-8859-1" αναλύεται κανονικά και επιστρέφει café. Το libxml2 δέχεται μόνο canonical ονόματα encoding, όχι aliases — λεπτομέρεια που έχει τεκμηριωθεί από παλιά στο launchpad #613302. Ενοχλητικό αν δεν το ξέρεις, απλό όταν το μάθεις.
Τέλος, ο κύκλος ζωής των nodes. Έτρεξα τρία stale-handle σενάρια σε απομονωμένα subprocesses (αν υπήρχε hard crash, θα φαινόταν ως non-zero exit): κράτημα node αφού το tree του έχει συλλεχθεί από τον garbage collector, ανάγνωση handle μετά από drop_tree() και χρήση node μετά από remove(). Σε κανένα δεν υπήρξε segfault — το lxml κρατά ζωντανή την αναφορά του node στο tree του για να αποτρέψει use-after-free. Το ίδιο καθαρό αποτέλεσμα πήρε και το selectolax σε αυτό το τεστ.
Ταχύτητα και μνήμη (δανεικά, και με ειλικρίνεια)
Όλα σε αυτή την ενότητα είναι επαναχρησιμοποιημένα από το pack του selectolax, ως 2026-07-13. Αυτό το pack δεν παρήγαγε δικούς του timing αριθμούς, και προτιμώ να το πω δύο φορές παρά να νομίσεις ότι ξαναμέτρησα οτιδήποτε.
| Διάσταση | Τιμή 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 μηχανές |
| 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 που μετρήθηκαν. Η εικόνα για τα threads όμως θέλει μια επιφύλαξη. Τα επαναχρησιμοποιημένα δεδομένα δείχνουν βελτίωση μόλις 1.21x σε 4 threads, σημειωμένη ως inconclusive — αλλά αυτό είναι το μονοπάτι με shared default parser. Το FAQ του lxml είναι ξεκάθαρο ότι το GIL απελευθερώνεται κατά το parsing μόνο όταν κάθε thread χρησιμοποιεί δικό του parser (ή αντίγραφο του default)· ένας shared parser σειριοποιεί την πρόσβαση. Επαλήθευσα δομικά το API surface για να γίνει σωστά αυτό (XMLParser.copy() υπάρχει, get/set_default_parser υπάρχουν, το XPathEvaluator φέρει εσωτερικό lock), αλλά δεν μέτρησα το speedup ανά-thread-parser — αυτό θα ήταν νέα μέτρηση χρόνου, και αυτό το pack δεν παράγει τέτοιες. Άρα διάβασε το "1.21x" ως "στο αφελές shared path", όχι ως ανώτατο όριο του lxml σε threads.
Και μια αστερίσκος σε όλα: αυτά είναι νούμερα μίας πλατφόρμας, 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 είναι η πιο καθαρή ιστορία όταν το στέλνεις σε κλειστό προϊόν.
Υπάρχει και πρακτικό πλεονέκτημα εγκατάστασης: το lxml δημοσιεύει prebuilt wheels που κάνουν στατικό linking το libxml2 και το libxslt, οπότε το pip install lxml συνήθως δεν χρειάζεται system libxml2 ούτε compiler στο μηχάνημά σου — άλλη εμπειρία από το να το χτίζεις από source.
Πού ταιριάζει το lxml — και πού αναλαμβάνει ένα AI extraction layer
Ώρα να είμαστε καθαροί για το όριο, γιατί εδώ γίνεται εύκολα κατηγοριακό λάθος. Το lxml είναι βιβλιοθήκη parsing. Σου δίνει ένα δέντρο και μια εξαιρετική μηχανή query, και όλα γύρω από αυτό το δέντρο εξακολουθούν να είναι δική σου δουλειά: να κατεβάσεις τη σελίδα, να αποδώσεις JavaScript, να περάσεις anti-bot άμυνες, να γράψεις και να συντηρείς το XPath και να δομήσεις το αποτέλεσμα. Αυτό είναι διαφορετικό επίπεδο από μια hosted extraction service, και τα δύο δεν είναι τόσο ανταγωνιστές όσο γείτονες.
Για έναν developer που δεν θέλει να συντηρεί ο ίδιος ολόκληρο το stack fetch-render-select-maintain, αυτό το πάνω επίπεδο είναι εκεί όπου ζει κάτι σαν το Thunderbit — και για αυτό το κοινό μιλάμε για API, MCP server και CLI, όχι για το browser extension. Το Thunderbit Open API εκθέτει τα POST /distill για να μετατρέπει μια σελίδα σε καθαρό Markdown και POST /extract για να τραβά δομημένα δεδομένα πάνω σε JSON Schema, με switch renderMode και batch jobs για όγκο. Η ίδια μηχανή είναι διαθέσιμη ως MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) για agents και coding assistants, και ως CLI που τρέχεις απευθείας από terminal μέσω npx @thunderbit/thunderbit-cli. Διαχειρίζεται από μόνο του JS rendering, anti-bot και CAPTCHAs και επιστρέφει JSON που ταιριάζει στο schema — δηλαδή το επίπεδο πάνω από το parsing, όχι αντικατάστασή του.
Δοκίμασε το Thunderbit για εξαγωγή web δεδομένων
Το πλαίσιο είναι απλό: διάλεξε lxml όταν εσύ ελέγχεις το pipeline και θέλεις χειρουργικό έλεγχο XPath πάνω σε ένα δέντρο που καταλαβαίνεις. Διάλεξε AI extraction API όταν δεν θέλεις καθόλου να συντηρείς selectors και rendering. Πολλά πραγματικά συστήματα χρησιμοποιούν και τα δύο — το lxml για τα δομημένα feeds που ελέγχουν, μια υπηρεσία extraction για τις ακατάστατες long-tail σελίδες που δεν ελέγχουν.
Τι δεν δοκίμασε αυτή η αξιολόγηση
Αυτή είναι μια προκαταρκτική αξιολόγηση, όχι τελικό report, οπότε να τι δεν καλύπτει.
Όλοι οι αριθμοί χρόνου και μνήμης είναι επαναχρησιμοποιημένοι, μίας πλατφόρμας (macOS arm64, Python 3.14), και κληρονομούν τα caveats εκείνου του pack — το αποτέλεσμα ότι το "lxml είναι ταχύτερο στο pure parsing" πάει κόντρα στην κοινή άποψη και θέλει ξανά έλεγχο σε Linux x86_64. Η επιτάχυνση parsing ανά thread δεν δοκιμάστηκε (θα απαιτούσε νέους timings). Μέτρησα iterparse μνήμη σε 300k records αλλά όχι σε πραγματικό XML gigabyte-κλίμακας, ούτε iterparse σε HTML έναντι XML, ούτε πολύωρο soak test. Το XSLT 1.0 του lxml, η validation μέσω RelaxNG / XMLSchema / DTD και οι EXSLT επεκτάσεις δεν δοκιμάστηκαν καθόλου εδώ — μεγάλη επιφάνεια δυνατοτήτων, αλλά πέρα από τον πυρήνα parsing και selection. Παρατήρησα το δεύτερο όριο βάθους στο 2,045, αλλά δεν εντόπισα την ακριβή σταθερά recursion του libxml2. Δοκιμάστηκε μόνο η σταθερή 6.1.1, όχι το 7.0.0 alpha. Windows, source builds και το free-threaded 3.14t build δεν δοκιμάστηκαν. Και μέσα στο ίδιο το XPath, κάλυψα built-in functions αλλά όχι XPath variables, custom Python extension functions ή επαναχρησιμοποίηση precompiled etree.XPath objects.
Η ετυμηγορία
Το lxml δεν είναι το νέο γρήγορο πράγμα, και αυτό ακριβώς είναι το δυνατό του σημείο. Είναι ένα binding του libxml2 δύο δεκαετιών με πλήρη μηχανή XPath 1.0 που δεν έχει ισοδύναμο στο mainstream Python, τρεις προβλέψιμες βαθμίδες αυστηρότητας parsing με error log στη μέση, πραγματικό streaming parser για έγγραφα που δεν χωράνε στη μνήμη, σωστό χειρισμό πολλών namespaces και encoding, και μια πλήρως permissive άδεια. Τα λίγα αιχμηρά σημεία — το όριο βάθους περίπου 253 επιπέδων και το νούμερο του shared-parser threading — είναι τεκμηριωμένα, ρυθμιζόμενα και τώρα εξηγημένα.
Αν ελέγχεις εσύ το scraping pipeline και βασίζεσαι στο XPath, το lxml παραμένει ο parser που θα πάρεις πρώτος. Αν δεν θέλεις να συντηρείς selectors και rendering, εκεί χρησιμεύει ένα AI extraction layer όπως το Thunderbit API, MCP και CLI — καθαρή κατανομή εργασίας, όχι ανταγωνισμός. Σε κάθε περίπτωση, αντιμετώπισε αυτά τα νούμερα ως προσωρινά και ξαναέλεγξε τα timings στη δική σου πλατφόρμα πριν τα χρησιμοποιήσεις σε design doc.
Δοκίμασε το Thunderbit για εξαγωγή web δεδομένων Get Started Free
FAQs
Είναι το lxml web scraper;
Όχι. Το lxml είναι parser και serializer — ένα Python binding πάνω στα libxml2/libxslt που μετατρέπει το markup σε ένα επεξεργάσιμο, queryable δέντρο. Δεν κατεβάζει σελίδες, δεν αποδίδει JavaScript και δεν χειρίζεται anti-bot άμυνες· εσύ δίνεις το request layer (μέσω requests, httpx, headless browser ή κάποιας scraping υπηρεσίας) και περνάς τα 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 κόβει σιωπηλά το πολύ βαθιά φωλιασμένο περιεχόμενο;
Ο default parser του βάζει όριο ένθεσης περίπου στα 253 επίπεδα — άμυνα DoS του libxml2 απέναντι σε εχθρικά έγγραφα, όχι bug. Βάλε huge_tree=True (για παράδειγμα lxml.html.HTMLParser(huge_tree=True)) και ανακτά πλήρως βάθη 300 και 1,000. Να σημειωθεί όμως και ένα δεύτερο, πιο σκληρό recursion ceiling γύρω στα 2,045 επίπεδα που το huge_tree δεν αφαιρεί.
Το lxml απελευθερώνει το GIL για parsing σε πολλαπλά threads;
Μόνο υπό τις σωστές συνθήκες. Το FAQ του lxml λέει ότι το GIL απελευθερώνεται κατά το parsing όταν κάθε thread χρησιμοποιεί δικό του parser ή αντίγραφο του default parser· ένας shared parser, αντίθετα, σειριοποιεί την πρόσβαση. Η επαναχρησιμοποιημένη βελτίωση 1.21x σε 4 threads αντικατοπτρίζει το αφελές shared-parser path, όχι το όριο με per-thread parser, το οποίο δεν μετρήθηκε εδώ.
Συντηρείται ακόμη το lxml το 2026;
Ναι. Η σταθερή έκδοση 6.1.1 κυκλοφόρησε στις 2026-05-18, το repository δέχτηκε το τελευταίο push στις 2026-07-02 και υπάρχει alpha 7.0.0 σε εξέλιξη. Με περίπου 3,000 GitHub stars και ενεργά συντηρούμενο libxml2 από κάτω, παραμένει τρέχουσα και καλά υποστηριζόμενη βιβλιοθήκη, όχι legacy.


