كل بضعة أشهر يظهر محلّل HTML أسرع، وتنتشر المقارنات، ثم يعلن أحدهم أن الحرس القديم صار من الماضي. ثم تحاول تحديد كل فقرة تحتوي على كلمة معيّنة، أو جلب العنصر الأب لعقدة مطابقة، فتتذكّر لماذا لا يزال lxml مفتوحًا في التبويب الآخر لديك.
lxml هو ربط Python عمره 20 عامًا مع libxml2. ليس مثيرًا. وليس جديدًا. لكنه في مهمة واحدة محددة — أي شيء يحتاج XPath حقيقيًا — لا يوجد في Python السائد ما ينافسه فعليًا. هذه مراجعة عملية لما يفعله، وأين يفوز بهدوء، وأين قد تفاجئك إعداداته الافتراضية إن لم تكن تعرف بوجودها.
lxml في سطر واحد: ما هو فعليًا؟
lxml هو ربط Python بمكتبات C libxml2 وlibxslt. هو محلّل ومُنسّق للبيانات، وليس أداة scraping ولا متصفحًا — يحوّل الترميز إلى شجرة يمكنك استعلامها وتعديلها، ثم يعيد الشجرة إلى بايتات. يقدّم واجهة متوافقة مع ElementTree، ومحرك XPath 1.0 كاملًا، وXSLT 1.0، والتحقق من المخططات، مع صيانة Stefan Behnel تحت شعار "أكثر مكتبة غنية بالميزات وسهلة الاستخدام لمعالجة XML وHTML في لغة Python".
إليك صورته، وفق لقطة من GitHub وPyPI بتاريخ 2026-07-14:
| الحقل | القيمة |
|---|---|
| المستودع | lxml/lxml |
| النجوم | 3,043 |
| النسخ المتفرعة | 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، واجهتا المحلّل، الأسماء المكانية، الترميز، دورة حياة العقد — أجريتها من جديد على جهاز واحد: macOS arm64، Python 3.14.2، lxml 6.1.1، libxml2 2.14.6. كل رقم في ملفات artifacts/raw/*.json حُسب من تنفيذ سكربت، لا كُتب يدويًا. اختبارات القدرات حتمية وذات قيم منطقية/عددية ثابتة، لذا فإن تشغيلًا واحدًا يكفي للاستقرار — حمولة الجهاز لا تغيّر ما إذا كان //a/@href سيعيد سلسلة سمة أم لا.
أما أرقام الزمن والبصمة الذاكرية فليست من هذه الحزمة. فهي معاد استخدامها حرفيًا من حزمة benchmark السابقة الخاصة بـ selectolax — نفس الجهاز، نفس البيئة الافتراضية، نفس إصدار lxml وlibxml2، مع قياسات بتاريخ 2026-07-13 — ولم أعد تشغيلها هنا. ذلك مقصود. إعادة قياس الزمن بالتزامن مع مجموعة من اختبارات القدرات قد تخلق تنافسًا على CPU يلوّث الأرقام المعاد استخدامها، كما أنه عمل مكرر: lxml كان أصلًا مكتبة تحكم measured بالكامل في تلك الحزمة. إعادة استخدام القياسات نفسها تجعل المقارنة متكافئة فعلًا بدل إدخال قياس ثانٍ مختلف قليلًا. لذلك حين ترى رقمًا بالميلي ثانية أدناه، فاقرأه على أنه "على نفس منصة الاختبار، بتاريخ 2026-07-13"، لا "أعدت قياسه اليوم".
النتائج تحمل وسم ثقة: single-observation لاختبارات القدرات الحتمية، وtriple-run لتوزيعات الزمن المعاد استخدامها، وhypothesis عندما أطرح آلية لم أفصلها تجريبيًا.
XPath: الشيء الوحيد الذي لا يملكه selectolax وBeautifulSoup
هذه هي العنوان الأبرز، لذا سأبدأ به.

أخضعت دالة xpath() في lxml لمصفوفة من 37 عنصرًا جرى تسجيلها مسبقًا — كان الناتج المتوقع لكل حالة مكتوبًا في المصدر قبل تشغيل الاختبار، بحيث لا أستطيع أن أمنح نفسي درجات متساهلة بالخطأ. عشرة محاور، تسعة أنماط من المرشحات، عشرة دوال مدمجة، ثلاثة أنواع إرجاع عددية، وخمس حالات فخ متعمّدة تستخدم صياغة XPath 2.0 التي ينبغي لمحرك lxml 1.0 رفضها.
| الفئة | التغطية | النتيجة |
|---|---|---|
| المحاور | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | نجاح 10/10 |
| المرشحات | [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() / sequences / if-then-else / except / خطأ في الصياغة | رُفضت جميعها 5/5 بشكل صحيح |
النتيجة 37/37، وعمود الفخاخ هو الأهم. matches() وعبارات sequence وif/then/else وexcept كلها من XPath 2.0، ومحرك libxml2 1.0 لا يدعمها دعمًا جزئيًا ثمينًا، بل يرفع XPathEvalError ويرفضها بدلًا من إرجاع مجموعة عقد خاطئة بصمت. إذن هذه نتيجة مثالية بعد محاولة كسرها، لا نتيجة مكوّنة من حالات سهلة. كل سلوك هنا مطابق تمامًا لما يصفه توثيق XPath في lxml، وهذا هو المقصود.
وأعترف بشيء واحد أخطأ فيه النظام التجريبي، لأن هذا هو النوع من "37/37" الذي يمكن الوثوق به فعلًا. مجموعتي المتوقعة الأولى لـ //div[.//a[@href]] كانت تتوقع نتيجتين؛ أما التنفيذ فأعاد نتيجة واحدة. ظننت أن lxml مخطئ لمدة ثلاثين ثانية تقريبًا، ثم راجعت العيّنة واكتشفت أن العنصر الثاني كان <footer> لا <div> — توقعاتي كانت خاطئة، لا المحرك. صححت المجموعة المتوقعة وأبقيت الملاحظة في تعليق داخل المصدر. هذا هو الترتيب الصحيح للوم: اشك في اختبارك أولًا قبل أن تتهم مكتبة C عمرها 20 عامًا.
XPath مقابل CSS: ما الذي لا يمكنك التعبير عنه في CSS إطلاقًا
الادعاء المجرد بأن "XPath أقوى" يستحق رقمًا ملموسًا، لذلك قست الفجوة. يمنحك lxml كلًا من .xpath() و.cssselect() (والأخيرة تحوّل CSS إلى XPath داخليًا). أخذت عشر وجهات اختيار وتحققت من أي منها يمكن لـ CSS التعبير عنه فعلًا.

| الهدف | XPath | CSS (cssselect) |
|---|---|---|
التصفية بحسب النص (contains(text(),"bargain")) | نعم | لا يوجد مرشح نصي |
اختيار الأب انطلاقًا من الابن (//b/parent::p) | نعم | لا يوجد محدد للأب |
إرجاع قيمة سمة (//a/@href) | نعم | يقتصر على العناصر |
إرجاع عقدة نصية (//p/text()) | نعم | لا توجد عقد نصية |
محور السلف (//td/ancestor::div) | نعم | لا توجد حركة إلى الأعلى |
تصفية الأب بعدد الأبناء (//ul[count(li)=4]) | نعم | لا يوجد مرشح عدّ |
التصفية بطول النص (string-length(text())>5) | نعم | لا يوجد مرشح طول |
nth-child / last-child / sibling مجاور | نعم | نعم (3 أساسية) |
سبعة من أصل عشرة أهداف لا يملك CSS مقابلًا لها إطلاقًا. التصفية بحسب محتوى النص، والتنقل الصاعد إلى الأب أو السلف، وسحب قيمة سمة أو عقدة نصية صريحة كنتيجة، والمرشحات المبنية على العد — CSS لا يستطيع التعبير عن أي منها. فقط ثلاثة (nth-child، وlast-child، وsibling المجاور) تعمل في الاثنين. هذه هي الإجابة المقيسة على سؤال: "ماذا أكسب فعليًا عندما ألجأ إلى lxml؟" selectolax يعمل عبر CSS فقط ولا يملك دالة xpath() أصلًا، لذا تصبح هذه الأنواع السبعة من الاستعلامات هناك إما حلقات Python متعددة المراحل أو لا تحدث إطلاقًا. إذا كان منطق scraping لديك يعتمد على أي منها، فهذه هي إجابتك.
(ونعم، التقطني النظام التجريبي هنا مرة ثانية: توقعت مجموعة فارغة لـ string-length(text())>5، لكن نصّين بطول ستة أحرف تطابقا. صححت التوقع، لا الأداة.)
ثلاث درجات للصرامة: etree مقابل recover مقابل lxml.html
XPath هو سبب اختيار lxml. أما التحكم الصارم بثلاث درجات فهو سبب الاستمرار في استخدامه.

معظم المحللات تمنحك سلوكًا واحدًا عند الإدخال المعطوب. lxml يمنحك ثلاثة، وهي متوقعة بما يكفي لدرجة أنني مررت ست فئات من HTML المعطوب عبر كل منها وسجّلت مسبقًا كيف ينبغي أن تتصرف كل حالة.
| الإدخال المعطوب | 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.error_log كل خطأ ابتلعه. أما lxml.html فيقبل كل شيء من دون اعتراض.
المصنّف الذي يقرر "رفع الخطأ مقابل الاستعادة مقابل القبول" يعتمد نفسه على طول error_log وقت التنفيذ، لا على قيمة ثابتة، ولهذا فإن مستندًا سليمًا يُحلَّل مع recover=True يُوسَم بشكل صحيح على أنه "يقبل" (سجل فارغ) بدلًا من "يستعيد". كانت نسختي الأولى من هذا المصنّف تعتبر أي نتيجة مع recover=True حالة "استعادة"، فأخطأت في وسم الإدخال السليم؛ قراءة error_log الفعلية أصلحت ذلك.
ما الذي يفيدك به هذا عمليًا: إذا كنت تريد تحققًا صارمًا بحيث يفشل feed المعطوب بصوت عالٍ، فاستخدم lxml.etree. إذا كانت HTML متسخة من العالم الحقيقي وتريد فقط تجاوزها، فاستخدم lxml.html. أما الحالة الوسطى التي لا تستطيع معظم الأدوات فعلها — "كن متساهلًا، لكن أخبرني بالضبط ما الذي كان معطوبًا كي أسجله" — فاستخدم recover=True واقرأ سجل الأخطاء. selectolax يملك درجة التساهل فقط، ولا يملك الوضع الصارم ولا سجل الأخطاء.
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 | يحتفظ بالمراجع؛ ثقيل مثل التحميل الكامل |
etree.parse (تحميل كامل، نقطة مرجعية) | ~386 MB | ثقيل كما هو متوقع؛ يثبت أن العداد يقرأ الحجم فعلًا |
الوضع المحدود يحافظ على أعلى RSS ضمن نحو 1-2 MB مقابل ~386 MB في التحميل الكامل — فرق بحجم 0.3-0.4% — كما أن حدث أول record يظهر قبل اكتمال قراءة الملف أصلًا، لذا فالبث هنا حقيقي، وليس بثًا وهميًا. والخط التعليمي الواضح هو السطر الأوسط. شغّل نفس حلقة iterparse لكن تجاهل clear()، فتعود الذاكرة وترتفع إلى ~386 MB، لأنك تحتفظ بمراجع لكل شيء. المكسب الحقيقي يكمن في clear()، لا في iterparse وحده. كما أن نقطة التحميل الكامل الأعلى بكثير من الوضع المحدود تؤكد أيضًا أن عداد RSS قادر فعلًا على رؤية الفجوة في الحجم بدل أن يقرأ أعمى. (اختبار الذاكرة هذا أجريته ضمن هذه الحزمة — إنه قياس بصمة، مختلف عن أرقام الزمن المستعارة.)
والنسخة الواقعية من ذلك: تصدير XML بحجم عدة جيجابايت لا يمكن أن يتسع في RAM لا يملك مسارًا في selectolax أصلًا. إما أن تستخدم محلّل lxml المتدفّق أو لغة أخرى.
الأسماء المكانية: RSS وSVG وفخ namespace الافتراضي
اثنتا عشرة حالة أسماء مكانية، تشمل RSS عبر ثلاثة namespaces، وSVG مع namespace افتراضي إضافة إلى xlink، وXML يعتمد 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، ويعرض البنية عبر nsmap. هذا سلوك موثّق ومدعوم، وهو بُعد كامل لا يلامسه selectolax أصلًا، لأن selectolax يعمل على HTML5 فقط ولا يعالج XML namespaces عامة.
لكن هناك فخ واحد موثّق يستحق أن تحفظه. XPath لا يملك مفهومًا لـ namespace افتراضي. إذا وجّهت //book إلى مستند يعرّف xmlns="urn:..." فستحصل على صفر نتائج — البادئة الفارغة غير معرّفة في XPath، كما يشرح توثيق lxml. يجب أن تربط بادئة اصطناعية (//c:book مع namespaces={"c": "urn:..."}، وقد وجدت جميع العناصر الثلاثة) أو تلجأ إلى //*[local-name()='book'] (أيضًا ثلاثة). ليس خطأً — إنها مواصفة XPath كما هي. لكنها تفاجئ الجميع مرة واحدة فقط.
صفحات حقيقية متّسخة: الدقة عبر 11 صفحة فعلية
الاختبارات الاصطناعية نظيفة؛ أما الويب فليس كذلك. أعدت استخدام 11 صفحة حقيقية ملتقطة من مجموعة selectolax (بتاريخ 2026-07-10، قراءة فقط) ومررت lxml.html عليها بصفته موضوع الاختبار.
| العيّنة | الحجم | الروابط | الأخطاء التي استعادها libxml2 | 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, وتطابقت أعداد الروابط والعناوين والصور مع أعداد lxml المعاد استخدامها من حزمة selectolax في جميع الحالات الإحدى عشرة — تحقق متبادل true. هذا التطابق هو ما يثبت أن إعادة الاستخدام هنا عادلة فعلًا وليست قياسَين مختلفين يحملان الملصق نفسه.
والنتيجة الجانبية: محلّل XML الصارم رفع خطأ في عشر صفحات من أصل إحدى عشرة. صفحات الويب الحقيقية غالبًا ليست XML سليمة البنية، وهذا بالضبط سبب وجود وضع الاسترداد في libxml2 لـ HTML. الاستثناء الوحيد كان BBC News، المبنية عبر Next.js وكانت سليمة بما يكفي لتنجو من التحليل الصارم. ليس كل ما يُسمّى "HTML" يحتاج إلى وضع الاسترداد.
ملاحظة عدّ سهلة الوقوع فيها. في docs_python.html، //a[@href] (وجود السمة) أحصى 343، بينما حزمة selectolax باستخدام if n.get("href") (قيمة truthy) أحصت 341. الرابطان الإضافيان هما href="" فارغان. هذا اختلاف في منهج العد — السمة موجودة مقابل السمة غير فارغة — وليس اختلافًا في سلوك lxml، وتستقيم الأعداد بمجرد توحيد المرشح. من المفيد معرفته عند scraping: هل الروابط الفارغة تُحسب أم لا؟ هذا قرار مرشحك، لا قرار المحلل.
حد العمق الذي يبدو كأنه خطأ (لكنه ليس كذلك)
كانت حزمة selectolax قد سجّلت أن lxml يفقد المحتوى الأعمق عند ترميز متداخل من 1,000 و5,000 مستوى <div>, وصاغت ذلك على أنه "lxml يفقد المحتوى الأعمق بصمت". أردت الآلية، لذلك شغّلت المحلل الافتراضي مقابل huge_tree=True.

| العمق المطلوب | ما يصل إليه المحلل الافتراضي | ما يصل إليه huge_tree=True |
|---|---|---|
| 300 | 253 (يسقط الباقي) | 299 (تم الاسترداد) |
| 1000 | 253 (يسقط الباقي) | 999 (تم الاسترداد) |
| 5000 | 253 (يسقط الباقي) | 2045 (ما يزال يسقط) |
المحلل الافتراضي يقطع عند نحو 253 مستوى ويسقط أي شيء أعمق بصمت. هذا ليس خطأً — إنه دفاع libxml2 ضد DoS، وهو حد تعشيق يقارب 256 مستوى يوقف مستندًا خبيثًا من تفجير المكدس، وهو موثّق في نقاش Launchpad الخاص بـ lxml حول XML_PARSE_HUGE. عند ضبط huge_tree=True تعود أعماق 300 و1,000 بالكامل. أما عمق 5,000 فلا يصل معه إلا إلى 2,045 حتى مع تفعيل huge_tree — يوجد حد استدعاء recursion ثانٍ أشد فوق الحد القابل للضبط، وhuge_tree لا يزيله.
إذًا فالإجراء العملي واضح: عندما تحلل ترميزًا عميقًا من مصدر تثق به، استخدم 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 يُبقي العناصر الفارغة دون إغلاق ذاتي كما ينبغي)، وpretty_print، وcanonicalization عبر C14N (method="c14n"، وهي ميزة أخرى حصرية لـ lxml)، وإعادة دورة نظيفة من البداية إلى النهاية.
أما الترميز فهو المجال الذي يميّز lxml بهدوء. إذا مرّرت له بايتات ليست UTF-8 — مثل "<p>café éè</p>".encode("latin-1") عبر lxml.html.fromstring — فإنه يستعيد café éè كما هي، بلا أحرف استبدال U+FFFD ولا بايتات مفقودة. هذا يعيد مباشرة دوره كـ "مرجع نظيف" في حزمة selectolax، حيث فسدت نفس المدخلة بصمت لدى المحركين الآخرين (Lexbor أخرج أحرف استبدال، وModest أسقط البايتات تمامًا). اكتشاف charset في libxml2 ببساطة أكثر ثباتًا هنا.
والجهة المقابلة هي الصرامة بشأن كيفية إعلان الترميز. فـ encoding="latin-1" داخل تصريح XML يرفع XMLSyntaxError: Unsupported encoding: latin-1، بينما encoding="ISO-8859-1" القياسي وفق IANA يُحلّل بنجاح ويعيد café. libxml2 يقبل فقط الأسماء القياسية للترميز، لا الأسماء البديلة — وهي تفصيلة موثقة منذ launchpad #613302. مزعجة إن لم تكن تعرفها، وبسيطة جدًا بعد معرفتها.
وأخيرًا، دورة حياة العقد. أجريت ثلاث حالات لعناصر قديمة المرجع داخل عمليات subprocess معزولة (لأن أي crash شديد سيظهر كخروج غير صفري): الاحتفاظ بعقدة بعد جمع الشجرة بواسطة garbage collector، وقراءة مقبض بعد drop_tree()، واستخدام عقدة بعد remove(). لم يحدث أي segfault في أي منها — 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 عند الأحجام الصغيرة |
| throughput لعُقد CSS بعدد 100k | 3,002,646 عقدة/ثانية | أعلى فئة بين محركات C الثلاثة |
| فرق RSS عند 10 MB | 128.9 MB | الأقل استهلاكًا بين ستة محللات، وأخف بنحو 1.7x من BeautifulSoup |
| زمن بدء الاستيراد البارد | 14.1 ms | أسرع بنحو 2.3x من استيرادات نمط parsel |
أرقام التحليل الخام والـ throughput قوية، وlxml هو الأقل استهلاكًا للذاكرة بين المحللات الستة المقاسة. لكن صورة التعددية تحتاج ملاحظة. البيانات المعاد استخدامها تُظهر تسريعًا على 4 خيوط بمقدار 1.21x فقط، وموسومًا على أنه غير حاسم — لكن ذلك في مسار parser مشترك افتراضي. يذكر FAQ الخاص بـ lxml بوضوح أن GIL يُحرَّر أثناء التحليل فقط عندما يستخدم كل خيط parser خاصًا به (أو نسخة من parser الافتراضي)، أما parser مشترك فيجعل الوصول متسلسلًا. تحققت بنيويًا من وجود واجهة API لفعل ذلك بشكل صحيح (XMLParser.copy() موجود، وget/set_default_parser موجودان، وXPathEvaluator يحمل قفلًا داخليًا)، لكنني لم أقس تسريع كل خيط ببرنامج parser مستقل — فذلك سيكون قياسًا زمنيًا جديدًا، وهذه الحزمة لا تنتج مثل هذه القياسات. لذا اقرأ "1.21x" على أنها "ضمن المسار المشترك الساذج"، لا كحد lxml النهائي في التعددية.
وهناك نجمة صغيرة على كل ذلك: هذه أرقام خاصة بمنصة واحدة، macOS arm64. والادعاء بأن تحليل lxml الخام يتفوق على Lexbor يذهب عكس الإجماع المعتاد الذي يرى أن محلّل Lexbor هو الأسرع، لذا فهو يحتاج فعلًا إلى إعادة اختبار على Linux x86_64 قبل أن يتعامل معه أحد على أنه أمر محسوم.
الترخيص: الفوز الممل
lxml يُوزَّع تحت BSD-3-Clause، ومكتبات C المضمّنة معه — libxml2 وlibxslt — كلاهما MIT. هذه سلسلة تراخيص permissive بالكامل بلا أي copyleft فيها، وهذا مهم فور إعادة التوزيع. وللمقارنة، حزمة selectolax تشمل Modest برخصة LGPL-2.1 وLexbor برخصة Apache-2.0، لذا فـ lxml هو القصة الأنظف عند الشحن داخل منتج مغلق المصدر.
هناك أيضًا ميزة عملية عند التثبيت: lxml ينشر wheels جاهزة ترتبط statically مع libxml2 وlibxslt، لذا فإن pip install lxml غالبًا لا يحتاج إلى libxml2 النظامي ولا إلى مترجم على جهازك — تجربة مختلفة عن البناء من المصدر.
أين يقع lxml — وأين تتولى طبقة الاستخراج بالذكاء الاصطناعي المهمة
حان الوقت لتحديد الحد الفاصل بوضوح، لأن الخلط هنا سهل. lxml مكتبة تحليل. تعطيك شجرة ومحرك استعلام ممتازًا، أما كل ما حول الشجرة فما يزال مسؤوليتك: جلب الصفحة، تنفيذ JavaScript، تجاوز دفاعات anti-bot، كتابة XPath وصيانته، ثم هيكلة النتيجة. هذه طبقة مختلفة عن خدمة extraction مستضافة، وليسا متنافسين بقدر ما هما طبقتان متجاورتان.
وللمطور الذي لا يريد أن يمتلك سلسلة fetch-render-select-maintain كاملة، فهذه الطبقة الأعلى هي المكان الذي يعيش فيه شيء مثل Thunderbit — ولجمهورنا هنا المقصود هو API وMCP server وCLI، لا إضافة المتصفح. Thunderbit Open API يوفّر POST /distill لتحويل الصفحة إلى Markdown نظيف، وPOST /extract لسحب بيانات منظمة وفق JSON Schema، مع مفتاح renderMode ووظائف batch للحجوم الكبيرة. كما يتوفر المحرك نفسه كخادم MCP (thunderbit_suggest_fields، وthunderbit_distill، وthunderbit_extract) للوكلاء ومساعدي البرمجة، وكـ CLI يمكنك تشغيله مباشرة من الطرفية عبر npx @thunderbit/thunderbit-cli. يتعامل مع عرض JavaScript وanti-bot وCAPTCHAs تلقائيًا، ويعيد JSON مطابقًا للمخطط — وهي الطبقة فوق التحليل، لا بديلًا عنه.
جرّب Thunderbit لاستخراج بيانات الويب
والتصور بسيط: استخدم lxml عندما تكون أنت مالك خط الأنابيب وتريد تحكم XPath جراحيًا في شجرة تفهمها. واستخدم API للاستخراج بالذكاء الاصطناعي عندما لا تريد أصلًا صيانة المحددات أو العرض. كثير من الأنظمة الحقيقية تستخدم الاثنين معًا — lxml للمصادر المنظمة التي تملكها، وخدمة extraction للصفحات المبعثرة ذات الذيل الطويل التي لا تملكها.
ما الذي لم تختبره هذه المراجعة
هذه مراجعة أولية، لا بطاقة تقييم نهائية، لذا إليك ما لا تغطيه.
جميع أرقام الزمن والذاكرة معاد استخدامها، ومنصة واحدة (macOS arm64، Python 3.14)، وتحمل معها تحذيرات الحزمة السابقة — نتيجة "lxml أسرع في التحليل الخام" تخالف الإجماع وتحتاج إعادة فحص على Linux x86_64. لم أختبر تسريع التعددية مع parser مستقل لكل خيط (لأنه يتطلب قياسًا جديدًا). قست iterparse على 300 ألف سجل لكن ليس على XML حقيقي بحجم جيجابايت، ولا iterparse على HTML مقابل XML، ولا اختبار تشغيل طويل لساعات. كذلك لم أختبر XSLT 1.0 في lxml، ولا RelaxNG / XMLSchema / DTD validation، ولا إضافات EXSLT — وهي مساحة قدرات كبيرة لكنها خارج قلب التحليل والاختيار. رصدت سقف العمق الثاني عند 2,045 لكنني لم أحدد ثابت recursion الدقيق في libxml2. كما اختبرت الإصدار المستقر 6.1.1 فقط، لا alpha 7.0.0. ولم أختبر Windows أو البناء من المصدر أو build 3.14t free-threaded. وداخل XPath نفسه، غطيت الدوال المدمجة لكن ليس متغيرات XPath أو دوال Python المخصصة أو إعادة استخدام كائن etree.XPath المسبق التحضير.
الحكم النهائي
lxml ليس الشيء الجديد السريع، وهذا بالضبط سبب التوصية به. إنه ربط libxml2 عمره عقدان مع محرك XPath 1.0 كامل لا يضاهيه أي بديل سائد في Python، وثلاث درجات متوقعة من صرامة التحليل مع سجل أخطاء في الوسط، ومحلّل streaming حقيقي للمستندات التي لا تتسع في الذاكرة، وتعامل صحيح مع تعدد namespaces والترميز، وترخيص permissive بالكامل. أما الحافتان الحادتان — حد العمق بنحو 253 مستوى، ورقم parser المشترك في التعددية — فموثقتان وقابلتان للضبط، والآن أصبحتا مفهومتين أيضًا.
إذا كنت تملك خط scraping الخاص بك وتعتمد على XPath، فما يزال lxml هو المحلل الذي تصل إليه. وإذا كنت تفضّل ألا تصون المحددات والعرض بنفسك، فهذه وظيفة طبقة استخراج بالذكاء الاصطناعي مثل Thunderbit API وMCP وCLI — تقسيم واضح للعمل، لا منافسة. وفي الحالتين، تعامل مع هذه الأرقام على أنها مؤقتة وأعد اختبار الزمن على منصتك قبل أن تقتبسها في وثيقة تصميم.
جرّب Thunderbit لاستخراج بيانات الويب Get Started Free
الأسئلة الشائعة
هل lxml أداة web scraper؟
لا. lxml هو محلّل ومُنسّق — ربط Python مع libxml2/libxslt يحوّل الترميز إلى شجرة قابلة للتعديل والاستعلام. لا يجلب الصفحات، ولا يعرض JavaScript، ولا يتعامل مع دفاعات anti-bot؛ أنت توفّر طبقة الطلب (عبر requests أو httpx أو متصفح headless أو خدمة scraping) ثم تسلّم البايتات إلى lxml.
متى أستخدم lxml بدل BeautifulSoup أو selectolax؟ اختر lxml عندما تحتاج إلى XPath. يمكن لـ BeautifulSoup أن يستخدم lxml كمحلّل خلفي، لكنه لا يوفّر XPath أصليًا، وselectolax يعمل عبر CSS فقط وهو أسرع في نطاقه الضيق. إذا كان منطق الاختيار لديك يحتاج إلى التصفية بالنص، أو التنقل إلى الأب/السلف، أو استخراج سمات/عقد نصية، أو المرشحات المبنية على العد، فمحرك XPath في lxml هو الخيار السائد الوحيد في Python الذي يعبّر عنها مباشرة.
لماذا يسقط lxml المحتوى العميق بصمت؟
لأن محلله الافتراضي يضع حدًا للتعشيق عند نحو 253 مستوى — وهو دفاع libxml2 ضد مستندات خبيثة، لا خطأ. اضبط huge_tree=True (مثلًا lxml.html.HTMLParser(huge_tree=True)) وستعود أعماق 300 و1,000 بالكامل. مع ملاحظة وجود سقف recursion ثانٍ أكثر صرامة قرب 2,045 مستوى لا يزيله huge_tree.
هل يحرّر lxml GIL أثناء التحليل متعدد الخيوط؟ فقط في الظروف الصحيحة. يذكر FAQ في lxml أن GIL يُحرَّر أثناء التحليل عندما يستخدم كل خيط parser خاصًا به أو نسخة من parser الافتراضي؛ أما parser مشترك فيجعل الوصول متسلسلًا. تسريع 1.21x المعاد استخدامه لرباعية الخيوط يعكس المسار الساذج ذو parser المشترك، وليس الحد الأعلى عند parser مستقل لكل خيط، ولم يتم قياس هذا هنا.
هل ما يزال lxml مُصانًا في 2026؟ نعم. الإصدار المستقر 6.1.1 صدر في 2026-05-18، وآخر دفع للمستودع كان في 2026-07-02، وهناك alpha 7.0.0 قيد التطوير. ومع نحو 3,000 نجمة على GitHub وصيانة نشطة لـ libxml2 تحته، فهو ما يزال مكتبة حديثة ومدعومة جيدًا، لا مجرد بقايا قديمة.


