כל כמה חודשים צץ מנתח HTML מהיר יותר, הבנצ'מרקים יוצאים, ומישהו מכריז שהדור הישן כבר לא רלוונטי. ואז צריך לבחור כל פסקה שמכילה מילה מסוימת, או לשלוף את ההורה של צומת תואם, ונזכרים למה lxml עדיין פתוח בטאב השני.
lxml הוא binding ותיק בן 20 שנה ל-libxml2. הוא לא נוצץ. הוא לא חדש. ולמשימה אחת מאוד מסוימת — כל דבר שדורש XPath אמיתי — כמעט שום דבר אחר ב-Python המיינסטרימי לא באמת מתחרה בו. זו סקירה מעשית על מה הוא עושה, איפה הוא מנצח בשקט, ואילו שני מקומות בברירות המחדל שלו ינשכו אותך אם לא תדע שהם שם.
lxml בפסקה אחת: מה הוא בעצם
lxml הוא binding של Python לספריות C בשם libxml2 ו-libxslt. הוא מנוע ניתוח וסריאליזציה, לא סקרייפר ולא דפדפן — הוא הופך markup לעץ שאפשר לשאול ולערוך, ואז מחזיר את העץ לבייטים. הוא מספק API תואם ElementTree, מנוע מלא של XPath 1.0, XSLT 1.0, ואימות סכמות, ומתוחזק על ידי Stefan Behnel תחת הסיסמה "הספרייה העשירה ביותר בתכונות והקלה ביותר לשימוש לעיבוד XML ו-HTML בשפת Python".
כך הוא נראה, לפי snapshot מ-GitHub ומ-PyPI שנמשכו ב-2026-07-14:
| שדה | ערך |
|---|---|
| מאגר | lxml/lxml |
| כוכבים | 3,043 |
| Forks | 620 |
| בעיות פתוחות | 16 |
| רישיון | BSD-3-Clause |
| נוצר | 2011-02-11 |
| דחיפה אחרונה | 2026-07-02 |
| גרסה יציבה ב-PyPI | 6.1.1 (2026-05-18) |
| מנוע מובנה | libxml2 2.14.6 + libxslt 1.1.43 |
דבר אחד שכדאי לסגור כבר עכשיו לפני שמישהו יחשוד בי בהייפ: אין כאן סודות. lxml ותיק מספיק כדי שכל התנהגות שמוזכרת כאן תופיע איפשהו ב-תיעוד של lxml, ביומן השינויים של libxml2, או בשרשור launchpad. לא מצאתי טריק בלעדי ולא מתועד, וגם לא אמציא אחד. הערך של מה שמגיע כאן הוא בכך שהוא ממוסד, כמותי ומאורגן סביב lxml עצמו — לא בכך שהוא חדשותי.
סביבת הבדיקה (ולמה מספרי הזמנים מושאלים)
לסקירה הזו נכנסות שתי קטגוריות של נתונים, והן מגיעות משני מקומות שונים — אז חשוב להיות שקוף לגביהן.
בדיקות היכולות — התנהגות XPath, שני ממשקי ה-parser, namespaces, קידוד, מחזור חיים של צמתים — רצתי מחדש על מכונה אחת: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. כל מספר בקבצי artifacts/raw/*.json מחושב מהרצה של סקריפט, לא הוקלד ידנית. בדיקות יכולת הן בוליאנים ו-enums דטרמיניסטיים, ולכן הרצה אחת יציבה — עומס המכונה לא משנה אם //a/@href מחזיר מחרוזת של תכונה.
מספרי הזמן וטביעת הרגל בזיכרון אינם מהאוסף הזה. הם ממוחזרים מילה במילה מחבילת הבנצ'מרקים הקודמת של selectolax — אותה מכונה, אותה סביבה וירטואלית, אותו build של lxml ו-libxml2, בנצ'מרקים נכון ל-2026-07-13 — ולא הרצתי אותם מחדש כאן. זה מכוון. הרצת בנצ'מרקי זמן במקביל לאוסף סקריפטים של יכולת מזמינה תחרות על CPU, שעלולה לזהם את המספרים הממוחזרים, וזה גם היה כפילות עבודה: lxml כבר שימש שם כספריית בקרה שנמדדה במלואה. שימוש חוזר באותם בנצ'ים שומר על apples-to-apples במקום להכניס סט מדידה שני, מעט שונה. לכן כשאתם רואים למטה מספר במילישניות, קראו אותו כ-"אותה סביבת בדיקה, נכון ל-2026-07-13", לא כ-"מדדתי את זה מחדש היום".
לכל ממצא יש תג ביטחון: single-observation לבדיקות היכולת הדטרמיניסטיות, triple-run להתפלגויות הזמן הממוחזרות, hypothesis כשאני מציע מנגנון שלא בודדתי.
XPath: הדבר האחד שאין ל-selectolax ול-BeautifulSoup
זה הכותרת הראשית, אז נתחיל כאן.

הרצתי את xpath() של lxml דרך מטריצה של 37 פריטים שהוגדרה מראש — התוצאה הצפויה לכל מקרה נכתבה לקוד לפני שהבדיקה רצה, כך שלא יכולתי בטעות לצפות בציון מרוכך. עשרה axes, תשעה סגנונות predicate, עשר פונקציות מובנות, שלושה סוגי החזר סקלריים, וחמישה מקרי מלכודת מכוונים שהשתמשו בתחביר XPath 2.0 בלבד, שאמור להידחות על ידי מנוע 1.0 של lxml.
| קטגוריה | כיסוי | תוצאה |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 עבר |
| Predicates | [1] / last() / position()<n / שוויון תכונה / קיום תכונה / and / or / [.//a] מקונן / not() | 9/9 עבר |
| פונקציות | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 עבר |
| סוגי החזרה | בוליאני / סקלרים מספריים | 3/3 עבר |
| מקרי מלכודת | matches() / רצפים / if-then-else / except / שגיאת תחביר | 5/5 נדחו כראוי |
הציון הוא 37/37, וחלק ה-trap הוא החשוב. matches(), ביטויי רצף, if/then/else ו-except הם כולם תחביר XPath 2.0, ומנוע 1.0 של libxml2 לא תומך בהם רק חצי-חצי — הוא מרים XPathEvalError ומסרב, במקום להחזיר בשקט קבוצת צמתים שגויה. אז זה ציון מושלם אחרי שניסיתי לשבור אותו, לא ציון מושלם שהורכב משאלות רכות. כל ההתנהגות כאן תואמת בדיוק למה שמתאר התיעוד של XPath ב-lxml, וזו בדיוק הנקודה.
אודה על דבר אחד שה-harness טעה בו, כי זו הגרסה של "37/37" שאפשר באמת לסמוך עליה. הסט הצפוי הראשון שלי עבור //div[.//a[@href]] חזה שני התאמות; הריצה החזירה אחת. הנחתי ש-lxml טועה בערך שלושים שניות, ואז בדקתי את ה-fixture וגיליתי שהאלמנט השני היה <footer>, לא <div> — הציפייה שלי הייתה שגויה, לא המנוע. תיקנתי את הסט הצפוי והשארתי את הטעות כהערת מקור. זה סדר האשמה הנכון: קודם לחשוד בבדיקה שלך, ורק אחר כך בספריית C בת 20 שנה.
XPath מול CSS: מה אי אפשר לנסח בכלל ב-CSS
הטענה המופשטת ש-XPath חזק יותר ראויה למספר קונקרטי, אז כימותי את הפער. ל-lxml יש גם .xpath() וגם .cssselect() (האחרון מתרגם CSS ל-XPath מאחורי הקלעים). לקחתי עשר מטרות בחירה ובדקתי אילו מהן CSS באמת יודע לבטא.

| מטרה | XPath | CSS (cssselect) |
|---|---|---|
סינון לפי תוכן טקסט (contains(text(),"bargain")) | כן | אין predicate לטקסט |
בחירת הורה מתוך ילד (//b/parent::p) | כן | אין parent selector |
החזרת ערך של תכונה (//a/@href) | כן | רק elements |
החזרת צומת טקסט (//p/text()) | כן | אין text nodes |
ancestor axis (//td/ancestor::div) | כן | אין ניווט כלפי מעלה |
סינון הורה לפי מספר ילדים (//ul[count(li)=4]) | כן | אין predicate של count |
סינון לפי אורך טקסט (string-length(text())>5) | כן | אין predicate של length |
nth-child / last-child / sibling סמוך | כן | כן (3 בסיסיים) |
שבע מתוך עשר המטרות פשוט אין להן מקבילה ב-CSS. סינון לפי תוכן טקסט, ניווט כלפי מעלה להורים ולאבות, שליפת ערך תכונה או צומת טקסט גולמי כתוצאה, predicates שמבוססים על ספירה — CSS לא יודע לבטא אף אחד מהם. רק שלושה (nth-child, last-child, וסמוך-אח) עובדים בשניהם. זו התשובה הכמותית לשאלה "מה אני באמת מרוויח כשאני ניגש ל-lxml". ל-selectolax יש CSS בלבד ואין בכלל xpath(), אז שבעת סוגי השאילתות האלה הופכים שם או ללולאות Python בכמה שלבים, או פשוט לא קורים. אם הלוגיקה של הסקרייפינג שלכם נשענת על אחד מהם, זו כבר ההחלטה שלכם.
(וכן, ה-harness תפס אותי שוב כאן: חזיתי סט ריק עבור string-length(text())>5, אבל שתי מחרוזות באורך שש תווים התאימו. תיקנתי את הציפייה, לא את הכלי.)
שלושה הילוכים של קשיחות: etree מול recover מול lxml.html
XPath הוא הסיבה לבחור ב-lxml. בקרת הקשיחות בשלושה מצבים היא הסיבה להשאיר אותו.

רוב המנתחים נותנים התנהגות אחת לקלט שבור. lxml נותן שלוש, והן מספיק צפויות כדי שרצתי שישה סוגי markup שבור מול כל אחת מהן והגדרתי מראש איך כל נתיב אמור להתנהג.
| קלט שבור | lxml.etree (מחמיר) | etree + recover=True | lxml.html (סלחני) |
|---|---|---|---|
תג סגור-למחצה <root><a>x</root> | זורק | מתקן | מקבל |
קינון שגוי <b><i></b></i> | זורק | מתקן | מקבל |
ישות לא מוגדרת | זורק | מתקן | מקבל |
אמפרסנד בודד & (Tom & Jerry) | זורק | מתקן | מקבל |
כמה שורשים <a>1</a><b>2</b> | זורק | מתקן | מקבל |
| XML תקין | מקבל | מקבל (0 שגיאות) | מקבל |
תכונת בוליאנית <input disabled> | זורק | מתקן | מקבל |
שבעה מתוך שבעה תואמים לציפייה שהוגדרה מראש. lxml.etree מרים XMLSyntaxError בכל שש הקטגוריות השבורות. הוסיפו recover=True לאותו parser והוא בולע את השגיאות ובונה מחדש עץ שמיש — והחלק המוערך כאן הוא ש-parser.error_log אז מפרט כל שגיאה שנבלעה. lxml.html מקבל הכול בלי להתלונן.
המסווג שמחליט "זרק מול תוקן מול התקבל" מונע בעצמו על ידי אורך ה-error_log בזמן ריצה, לא על ידי ערך קשיח, ולכן מסמך תקין שרץ תחת recover=True מסומן נכון כ-"מקבל" (יומן ריק) ולא כ-"מתקן." הגרסה הראשונה שלי של המסווג סימנה כל תוצאה עם recover=True כ-"מתקן" וסימנה לא נכון את הקלט התקין; קריאת ה-error_log האמיתי תיקן את זה.
מה זה נותן בפועל: כשאתם חייבים ולידציה מחמירה על פיד שבור — השתמשו ב-lxml.etree. כשיש HTML מלוכלך מהעולם האמיתי ורק צריך לעבור דרכו — השתמשו ב-lxml.html. ובמקרה האמצעי שרוב הכלים לא יודעים לעשות — "להיות סלחני, אבל להגיד לי בדיוק מה נשבר כדי שאוכל לרשום לוג" — השתמשו ב-recover=True וקראו את ה-error log. ל-selectolax יש את ההילוך הסלחני, וזהו, בלי מצב מחמיר ובלי error log.
iterparse: הילוך הסטרימינג שאין בכלל ל-selectolax
זו יכולת, לא כפתור מהירות. selectolax בולע רק מחרוזת שלמה — אין לו ממשק הדרגתי. iterparse של lxml פולט אלמנטים כשהם נסגרים, ובשילוב עם דפוס fast_iter הקלאסי (קריאה ל-elem.clear() ומחיקת אחים קודמים תוך כדי) הוא שומר על זיכרון שטוח לא משנה כמה גדול המסמך.

מדדתי את מאפיין הזיכרון ישירות — RSS שיא דרך ru_maxrss, כל נושא בתהליך חדש ונקי משלו, על 300,000 אלמנטים מסוג <record> במשקל כולל של כ-15 MB.
| מצב | תוספת RSS בשיא | הערות |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | משתחרר תוך כדי; שטוח בלי קשר לכמות |
iterparse בלי clear | ~386 MB | שומר references; כבד כמו טעינה מלאה |
etree.parse (טעינה מלאה, עוגן) | ~386 MB | כבד ידוע; מוכיח שהמדד קורא סדר גודל |
המצב המוגבל שומר על תוספת RSS בשיא סביב 1-2 MB מול 386 MB בטעינה מלאה — פער סדר גודל של 0.3-0.4% — והאירוע של הרשומה הראשונה מגיע לפני שהקובץ אפילו סיים להיטען, כך שזה סטרימינג אמיתי, לא סטרימינג מזויף. הקו הממחיש הוא האמצעי. הריצו את אותו לולאת 386 MB, כי אתם מחזיקים references לכל דבר. הניצחון יושב ב-iterparse אבל דלגו על clear(), והזיכרון שוב מטפס לכclear(), לא ב-iterparse לבדו. נקודת העוגן של טעינה מלאה, שקוראת הרבה יותר גבוה מהמצב המוגבל, גם מאשרת שמד ה-RSS באמת רואה את הפער בגודל ולא קורא בחושך. (זו בדיקת זיכרון שרצתי בחבילה הזו — זו מדידת footprint, נפרדת ממספרי הזמן הממוחזרים.)
הגרסה מהעולם האמיתי: לייצוא XML רב-ג׳יגבייט שלא נכנס ל-RAM אין בכלל נתיב ב-selectolax. זה parser הסטרימינג של lxml או שפה אחרת.
Namespaces: RSS, SVG, ומלכודת ה-Default Namespace
שתים-עשרה תצורות namespace, כולל RSS על פני שלושה namespaces, SVG עם namespace ברירת מחדל בתוספת xlink, ו-XML עם default namespace. כל השתים-עשרה עברו.
lxml שולף מ-//dc:creator/text() בפיד RSS בדיוק את ["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 בלבד ולא מעבד namespaces שרירותיים של XML.
יש מלכודת אחת מתועדת שכדאי לזכור. ל-XPath אין מושג של default namespace. אם תצביעו //book על מסמך שמכריז xmlns="urn:...", תקבלו אפס התאמות — הקידומת הריקה אינה מוגדרת ב-XPath, כפי ש-תיעוד lxml מסביר. צריך לקשור קידומת מלאכותית (//c:book עם namespaces={"c": "urn:..."}, שמצאה את כל השלוש) או לעבור ל-//*[local-name()='book'] (גם שלוש). לא באג — זו спецификация של XPath, מיושמת נאמנה. פשוט דבר שמפתיע את כולם בדיוק פעם אחת.
דפים מלוכלכים באמת: נאמנות על 11 סריקות אמיתיות
בדיקות סינתטיות הן נקיות; הרשת לא. השתמשתי מחדש ב-11 דפים אמיתיים שנתפסו מחבילת ה-fixtures של selectolax (נכון ל-2026-07-10, לקריאה בלבד) והרצתי עליהם את lxml.html כש-lxml הוא נושא הבדיקה.
| Fixture | גודל | קישורים | שגיאות libxml2 שתוקנו | 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 |
כל אחד מה-11 נותח בהצלחה עם lxml.html, וספירות הקישורים, הכותרות והתמונות תואמות לספירות lxml הממוחזרות מחבילת selectolax בכל אחד מה-11 — בדיקת צולבת true. ההסכמה הזו היא מה שאומר לי שהשימוש החוזר באמת apples-to-apples ולא שני מדדים שונים עם אותה תווית.
הממצא המשני: המנתח המחמיר של XML זרק על עשרה מתוך אחד-עשר הדפים. דפי רשת אמיתיים הם ברובם המכריע לא XML תקין, וזו בדיוק הסיבה שמצב ה-recovery של libxml2 ל-HTML קיים כדי לבלוע אותם. החריג היחיד היה BBC News, שנרנדר ב-Next.js והיה תקין מספיק כדי לעבור ניתוח XML מחמיר. לא כל מה שמסומן "HTML" חייב את מסלול ההתאוששות.
עוד הערת ספירה שקל להיתקל בה. ב-docs_python.html, //a[@href] (קיום תכונה) ספר 343, בעוד שחבילת selectolax עם if n.get("href") (ערך truthy) ספרה 341. שתי התוספות הן קישורי href="" ריקים. זה הבדל ב-convention של ספירה — תכונה קיימת מול תכונה שאינה ריקה — לא הבדל בהתנהגות של lxml, והספירות מסתדרות כשמיישרים את ה-predicate. חשוב לדעת כשמסקרייפים: האם href ריק נחשב או לא — זו בחירה של הפילטר שלכם, לא של המנתח.
מגבלת העומק שנראית כמו באג (אבל לא)
חבילת selectolax תיעדה ש-lxml מפיל את התוכן העמוק ביותר ב-markup מקונן בעומק 1,000 ו-5,000, ותיארה זאת כ-"lxml silently loses the deepest content." רציתי להבין את המנגנון, אז הרצתי את המנתח ברירת המחדל מול huge_tree=True.

| עומק מבוקש | המנתח ברירת המחדל מגיע | huge_tree=True מגיע |
|---|---|---|
| 300 | 253 (היתר נופל) | 299 (משוחזר) |
| 1000 | 253 (היתר נופל) | 999 (משוחזר) |
| 5000 | 253 (היתר נופל) | 2045 (עדיין נופל) |
המנתח ברירת המחדל קוטם סביב 253 רמות ומפיל בשקט כל דבר עמוק יותר. זה לא באג — זו הגנת DoS של libxml2, מגבלת קינון של בערך 256 רמות שמונעת ממסמך עוין להפיל את ה-stack, והיא מתועדת ב-שרשור ה-launchpad של lxml על XML_PARSE_HUGE. כשתגדירו huge_tree=True, עומקים של 300 ו-1,000 חוזרים במלואם. אבל בעומק 5,000 עדיין מגיעים רק ל-2,045 גם עם huge_tree מופעל — יש סף recursion שני, קשיח יותר, מעל הסף הניתן להגדרה, ו-huge_tree לא עוקף אותו.
אז המשימה המעשית ברורה: כשאתם מנתחים markup עמוק ממקור שאתם סומכים עליו, השתמשו ב-lxml.html.HTMLParser(huge_tree=True). מה שחבילה זו מוסיפה מעל התצפית הממוחזרת הוא המנגנון (מגבלת בטיחות, לא השחתת נתונים), התיקון (huge_tree), והעובדה שיש סף שני שהפתרון לא מגיע אליו.
DOM לקריאה/כתיבה, סריאליזציה, וקידוד
lxml הוא עץ מלא לקריאה/כתיבה, לא מחלץ לקריאה בלבד, ואימתתי את משטח העריכה מקרה אחר מקרה. כל שמונה פעולות ה-DOM עברו: SubElement, insert, remove, replace, strip_tags (מסיר תגיות ומשאיר את הטקסט), strip_elements (מסיר תגיות וגם את הטקסט שלהן), drop_tree (ייחודי ל-lxml.html), ומודל הטקסט/זנב עם שני החריצים שמבלבל מצטרפים חדשים — ב-<p>head<b>bold</b>tail</p>, p.text הוא "head", b.text הוא "bold", ו-b.tail הוא "tail".
הסריאליזציה עברה חמש מתוך חמש: tostring במצבי XML ו-HTML (HTML משאיר כראוי אלמנטים ריקים בלי self-closing), pretty_print, C14N canonicalization (method="c14n", עוד ייחוד של lxml), ו-round-trip נקי.
קידוד הוא המקום שבו lxml נפרד בשקט מאחרים. תנו לו בייטים שאינם UTF-8 — למשל "<p>café éè</p>".encode("latin-1") דרך lxml.html.fromstring — והוא משחזר את café éè שלם, בלי תווי החלפה U+FFFD ובלי בייטים שנפלו. זה משחזר ישירות את תפקידו כ-"reference נקי" בחבילת selectolax, שבה אותו קלט הושחת בשקט תחת שני המנועים האחרים (Lexbor הפיק תווי החלפה, Modest פשוט הפיל בייטים). זיהוי התווים של lxml על בסיס libxml2 פשוט יציב יותר כאן.
הצד השני הוא קשיחות באופן שבו מכריזים על הקידוד. encoding="latin-1" בהצהרת XML מרים XMLSyntaxError: Unsupported encoding: latin-1, בעוד שהשם הקנוני לפי IANA encoding="ISO-8859-1" מנתח כראוי ומחזיר café. libxml2 מקבל רק שמות קידוד קנוניים, לא כינויים — פרט שתועד כבר ב-launchpad #613302. מעצבן אם לא יודעים, שולי כשכן יודעים.
ולבסוף, מחזור חיים של צמתים. הרצתי שלושה תרחישי handle מיושן בתהליכי subprocess מבודדים לחלוטין (קריסה קשה הייתה נראית כיציאה לא-אפסית): החזקת צומת אחרי שהעץ שלו נאסף ב-garbage collection, קריאת handle אחרי drop_tree(), ושימוש בצומת אחרי remove(). לא היו segfaults באף אחד מהם — lxml שומר על ההפניה של צומת לעץ שלו בחיים כדי למנוע use-after-free. אותו ציון עובר נקי גם selectolax קיבל בבדיקה הזו.
מהירות וזיכרון (ממוחזר, ובכנות מלאה לגבי זה)
כל מה שבסעיף הזה ממוחזר מחבילת selectolax, נכון ל-2026-07-13. החבילה הזו הפיקה אפס מספרי זמן משל עצמה, ואני מעדיף לומר את זה פעמיים מאשר שתחשבו שמדדתי משהו מחדש.
| ממד | ערך lxml | קריאה |
|---|---|---|
| p50 ניתוח גולמי (10 MB) | 77.9 ms | מהיר בכ-33-34% מ-selectolax-Lexbor |
| p50 ניתוח מלא + חילוץ (1 MB / 10 MB) | 14.18 ms / 172.9 ms | בערך שוויוני מול Lexbor בגדלים קטנים |
| תפוקת CSS ל-100k צמתים | 3,002,646 צמתים/שנייה | מהטווח המהיר ביותר מבין שלושת מנועי ה-C |
| שינוי RSS ב-10 MB | 128.9 MB | החסכוני ביותר מבין שישה מנתחים, כ-1.7x חסכוני יותר מ-BeautifulSoup |
| זמן עלייה קרה של import | 14.1 ms | מהיר פי ~2.3 מ-importים בסגנון parsel |
מספרי הניתוח הגולמי והתפוקה חזקים, ו-lxml הוא גם החסכוני ביותר בזיכרון מבין ששת המנתחים שנמדדו. תמונת ה-threading דורשת הסתייגות, עם זאת. הנתונים הממוחזרים מראים האצה של 1.21x בלבד ב-4 threads, שסומנה כלא חד-משמעית — אבל זו הדרך עם parser משותף כברירת מחדל. שאלות ותשובות של lxml ברורות לגבי כך שה-GIL משתחרר בזמן ניתוח רק כשכל thread משתמש ב-parser משלו (או ב-default שהועתק); parser משותף מסדר גישה בטור. אימתתי מבנית את משטח ה-API כדי לעשות זאת נכון (XMLParser.copy() קיים, get/set_default_parser קיימים, XPathEvaluator נושא lock פנימי), אבל לא מדדתי את ההאצה של parser נפרד לכל thread — זו הייתה דורשת מדידת זמן חדשה, והחבילה הזו לא מפיקה כאלה. לכן קראו את "1.21x" כ-"בנתיב המשותף והנאיבי", לא כתקרת ה-threading של lxml.
וכוכבית אחת על כל זה: אלו מספרים מפלטפורמה יחידה, macOS arm64. הטענה שהניתוח הגולמי של lxml מהיר מ-Lexbor עומדת מול הקונצנזוס הרגיל שלפיו המנתח שמבוסס Lexbor הוא המהיר ביותר, ולכן באמת צריך עליה אימות נוסף ב-Linux x86_64 לפני שמישהו מתייחס לזה כמסוכם.
רישוי: הניצחון המשעמם
lxml משוחרר תחת BSD-3-Clause, וספריות ה-C שהוא אורז איתו — libxml2 ו-libxslt — הן שתיהן MIT. זו שרשרת permissive לחלוטין, בלי copyleft בשום מקום, וזה חשוב ברגע שמפיצים מחדש. להשוואה, ה-wheel של selectolax אורז את Modest תחת LGPL-2.1 ואת Lexbor תחת Apache-2.0, כך של-lxml יש סיפור נקי יותר למוצר סגור.
יש גם יתרון פרקטי בהתקנה: lxml מפרסם wheels בנויים מראש שמקשרים סטטית את libxml2 ו-libxslt, כך ש-pip install lxml בדרך כלל לא צריך libxml2 של המערכת ולא קומפיילר במכונה שלכם — חוויה אחרת לגמרי מבנייה מהמקור.
איפה lxml משתלב — ואיפה שכבת חילוץ מבוססת AI לוקחת פיקוד
כדאי להיות חד לגבי הגבול, כי קל לטעות בקטגוריה כאן. lxml הוא ספריית ניתוח. הוא מוסר לכם עץ ומנוע שאילתות מעולה, וכל מה שסביב העץ הזה עדיין באחריותכם: למשוך את הדף, לרנדר JavaScript, לעבור דרך הגנות נגד בוטים, לכתוב ולתחזק את ה-XPath, ולבנות את התוצאה. זו שכבה אחרת משירות חילוץ מתארח, והשתיים פחות מתחרות ויותר שכנות.
למפתח שלא רוצה להחזיק בעצמו את כל ה-stack של fetch-render-select-maintain, השכבה העליונה הזו היא המקום שבו משהו כמו Thunderbit חי — ולסוג קהל כזה, זה ה-API, שרת MCP וה-CLI, לא תוסף הדפדפן. ה-Thunderbit Open API חושף את POST /distill כדי להפוך דף ל-Markdown נקי, ואת POST /extract כדי לשלוף נתונים מובנים לפי JSON Schema, עם מתג renderMode ומשימות batch לנפח גדול. אותו מנוע זמין גם כשרת MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) עבור agents ועוזרי קוד, וגם כ-CLI שאפשר להריץ ישירות מהטרמינל דרך npx @thunderbit/thunderbit-cli. הוא מטפל ברינדור JS, בהגנות נגד בוטים וב-CAPTCHAs כברירת מחדל, ומחזיר JSON שתואם לסכימה — כלומר השכבה שמעל הניתוח, לא תחליף לו.
נסו את Thunderbit לחילוץ נתוני רשת
המסגור הוא פיצול פשוט. בחרו ב-lxml כשאתם שולטים בצינור העבודה ורוצים שליטה כירורגית ב-XPath על עץ שאתם מבינים. בחרו ב-API של חילוץ מבוסס AI כשאתם מעדיפים לא לתחזק בכלל selectors ורינדור. לא מעט מערכות אמיתיות משתמשות בשניהם — lxml לפידים המובנים שהן שולטות בהם, ושירות חילוץ לדפים המלוכלכים והארוכים שהן לא שולטות בהם.
מה הסקירה הזו לא בדקה
זו סקירה זמנית, לא טבלת ציונים סופית, אז הנה מה שהיא לא מכסה.
כל מספרי הזמן והזיכרון ממוחזרים, מפלטפורמה יחידה (macOS arm64, Python 3.14), ומורישים את ההסתייגויות של החבילה ההיא — התוצאה של "lxml מהיר יותר בניתוח גולמי" סותרת את הקונצנזוס וצריכה אימות חוזר על Linux x86_64. ההאצה של threading עם parser נפרד לכל thread לא נבדקה (היא הייתה דורשת מדידת זמן חדשה). מדדתי זיכרון של iterparse על 300k רשומות אבל לא XML אמיתי בגודל ג׳יגה-בייט, לא iterparse על HTML מול XML, ולא הרצת soak של שעות. XSLT 1.0 של lxml, וכן אימותי RelaxNG / XMLSchema / DTD והרחבות EXSLT, לא נבדקו כאן בכלל — משטח יכולות גדול, אבל מעבר לליבה של ניתוח ובחירה. ראיתי את סף העומק השני ב-2,045 אבל לא מיפיתי את קבוע ה-recursion המדויק של libxml2. נבדקה רק גרסה יציבה 6.1.1, לא alpha של 7.0.0. Windows, בניות מקור, ו-build חסר-נעילה של 3.14t לא נבדקו. ובתוך XPath עצמו, כיסיתי פונקציות מובנות אבל לא משתני XPath, פונקציות הרחבה מותאמות Python, או שימוש חוזר באובייקטי etree.XPath מוכללים מראש.
פסק הדין
lxml הוא לא הדבר החדש והמהיר, וזו בדיוק הסיבה להמליץ עליו. זהו binding בן שני עשורים ל-libxml2 עם מנוע XPath 1.0 מלא שלא קיים לו מתחרה mainstream ב-Python, שלושה הילוכים צפויים של קשיחות בניתוח עם error log באמצע, parser סטרימינג אמיתי למסמכים שלא נכנסים לזיכרון, טיפול נכון בכמה namespaces ובקידוד, ורישיון permissive לחלוטין. זוג הקצוות החדים — סף העומק של ~253 רמות ומספרי ה-threading עם parser משותף — מתועדים, ניתנים להגדרה, וכעת גם מוסברים.
אם אתם שולטים בצינור הסקרייפינג שלכם ונשענים על XPath, lxml עדיין המנתח שאליו פונים. אם אתם מעדיפים לא לתחזק selectors ורינדור, לשם נועדה שכבת חילוץ מבוססת AI כמו ה-Thunderbit API, MCP ו-CLI — חלוקת עבודה נקייה, לא תחרות. בכל מקרה, התייחסו למספרים האלה כזמניים ובדקו שוב את מדידת הזמן בפלטפורמה שלכם לפני שאתם מצטטים אותם במסמך תכנון.
נסו את Thunderbit לחילוץ נתוני רשת Get Started Free
שאלות נפוצות
האם lxml הוא web scraper?
לא. lxml הוא parser ו-serializer — binding של Python ל-libxml2/libxslt שהופך markup לעץ שאפשר לערוך ולשאול עליו. הוא לא מביא דפים, לא מרנדר JavaScript ולא מטפל בהגנות נגד בוטים; אתם מספקים את שכבת הבקשות (באמצעות requests, httpx, דפדפן headless או שירות scraping) ומעבירים את הבייטים ל-lxml.
מתי להשתמש ב-lxml במקום BeautifulSoup או selectolax? פנו ל-lxml כשאתם צריכים XPath. BeautifulSoup יכול להשתמש ב-lxml כמנתח backend, אבל לא מציע XPath מקורי, ו-selectolax הוא CSS-only ומהיר יותר בנישה הצרה שלו. אם לוגיקת הבחירה שלכם צריכה סינון לפי תוכן טקסט, ניווט להורה או לאב קדמון, חילוץ תכונה/צומת טקסט, או predicates של count, מנוע ה-XPath של lxml הוא האפשרות המיינסטרימית היחידה ב-Python שמבטאת אותם ישירות.
למה lxml מפיל בשקט תוכן שמקונן לעומק רב?
מנתח ברירת המחדל שלו מגביל קינון לכ-253 רמות — הגנת DoS של libxml2 נגד מסמכים עוינים, לא באג. הגדירו huge_tree=True (למשל lxml.html.HTMLParser(huge_tree=True)) והוא משחזר לעומק של 300 ו-1,000 במלואם. שימו לב לסף recursion שני, קשיח יותר, סביב 2,045 רמות ש-huge_tree לא עוקף.
האם lxml משחרר את ה-GIL בניתוח מרובה threads? רק בתנאים הנכונים. FAQ של lxml קובע שה-GIL משתחרר בזמן ניתוח כשכל thread משתמש ב-parser משלו או ב-default parser שהועתק; parser משותף מסדר את הגישה בטור. ההאצה הממוחזרת של 1.21x ב-4 threads משקפת את הנתיב הנאיבי עם parser משותף, לא את התקרה עם parser נפרד לכל thread, שלא נמדדה כאן.
האם lxml עדיין מתוחזק ב-2026? כן. הגרסה היציבה 6.1.1 יצאה ב-2026-05-18, המאגר קיבל דחיפה אחרונה ב-2026-07-02, ויש alpha של 7.0.0 בתהליך. עם בערך 3,000 כוכבי GitHub ו-libxml2 שמתוחזקת באופן פעיל מתחתיו, זו עדיין ספרייה עדכנית ומגובה היטב — לא ירושה עתיקה.


