ทุก ๆ สองสามเดือนจะมีตัวแปลง HTML ที่เร็วกว่าโผล่ขึ้นมา เบนช์มาร์กก็ถูกหยิบมาคุยกันอีกรอบ แล้วก็มีคนประกาศว่าเครื่องมือรุ่นเก่าหมดเวลาแล้ว แต่พอถึงจังหวะที่คุณต้องเลือกทุกย่อหน้าที่ มี คำบางคำอยู่จริง ๆ หรือดึง parent ของโหนดที่ตรงเงื่อนไขกลับมา คุณก็จะนึกขึ้นได้ว่า lxml ยังเปิดค้างอยู่ในอีกแท็บของคุณนี่แหละ
lxml คือ binding ของ libxml2 ที่อยู่มานานกว่า 20 ปี มันไม่ได้หวือหวา และก็ไม่ใช่ของใหม่ แต่ถ้าเป็นงานเฉพาะทางแบบที่ต้องใช้ XPath จริง ๆ ไม่มีไลบรารี Python กระแสหลักตัวไหนสู้ได้ บทความนี้เป็นรีวิวแบบลงมือใช้จริง ว่ามันทำอะไรได้ดี แพ้ตรงไหนแบบเงียบ ๆ และมีจุดไหนที่ค่าตั้งต้นอาจทำให้คุณพลาด ถ้าไม่รู้ว่ามันมีอยู่
lxml แบบสรุปสั้น ๆ: มันคืออะไรกันแน่
lxml คือ Python binding สำหรับไลบรารี C อย่าง libxml2 และ libxslt มันเป็น parser และ serializer ไม่ใช่ web scraper และไม่ใช่ browser — มันแปลง markup ให้เป็นต้นไม้ข้อมูลที่คุณ query และแก้ไขได้ แล้วแปลงต้นไม้นั้นกลับเป็น bytes ได้อีกครั้ง มันมี API ที่ใช้ร่วมกับ ElementTree ได้, มีเครื่องมือ XPath 1.0 ครบถ้วน, รองรับ XSLT 1.0 และการตรวจสอบ schema โดย Stefan Behnel ดูแลภายใต้คำโปรยว่า "the most feature-rich and easy-to-use library for processing XML and HTML in the Python language"
นี่คือภาพรวมสถานะจาก GitHub และ PyPI ที่ดึงมาเมื่อ 2026-07-14:
| Field | Value |
|---|---|
| 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 |
ก่อนจะมีใครหาว่าผมเขียนเชียร์ ขอเคลียร์ตรงนี้ก่อน: ในรีวิวนี้ไม่มีของซ่อนอะไรทั้งนั้น lxml เก่าแก่พอที่พฤติกรรมทุกอย่างในบทความนี้จะมีบันทึกไว้ใน เอกสาร lxml, changelog ของ libxml2 หรือเธรดใน launchpad อยู่แล้ว ผมไม่ได้เจอทริกที่ไม่มีใครรู้ และจะไม่แต่งมันขึ้นมาเอง คุณค่าของสิ่งที่อ่านต่อจากนี้คือมันถูกจัดระบบ วัดผล และเรียบเรียงโดยยึด lxml เป็นตัวหลัก — ไม่ใช่เพราะมันเป็นข่าวใหม่
ชุดทดสอบนี้ตั้งอย่างไร (และตัวเลขเวลาเอามาจากไหน)
รีวิวนี้อิงข้อมูลอยู่สองประเภท และมาจากคนละที่กัน ผมขออธิบายให้ชัดก่อน
การทดสอบความสามารถ — พฤติกรรม XPath, API ของ parser สองแบบ, namespaces, encoding, วงจรชีวิตของโหนด — ผมรันใหม่ทั้งหมดบนเครื่องเดียว: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6 ตัวเลขทุกตัวในไฟล์ artifacts/raw/*.json คำนวณจากสคริปต์ที่รันจริง ไม่ได้พิมพ์ใส่เอง การทดสอบประเภทนี้เป็น boolean และ enum ที่ผลแน่นอน ดังนั้นรันครั้งเดียวก็เสถียร — ภาระเครื่องไม่ทำให้ //a/@href จู่ ๆ เปลี่ยนใจคืนค่าเป็นสตริงอื่น
แต่ ตัวเลขด้านเวลาและหน่วยความจำไม่ได้มาจากชุดนี้ ตัวเลขเหล่านั้นยกมาจาก benchmark pack ของ selectolax ก่อนหน้า — เครื่องเดียวกัน, virtual environment เดียวกัน, build ของ lxml และ libxml2 เดียวกัน, และเป็นเบนช์มาร์ก ณ 2026-07-13 — ผมไม่ได้รันใหม่ในที่นี้ และตั้งใจให้เป็นแบบนั้น เพราะถ้ารัน benchmark เวลาไปพร้อมกับสคริปต์ทดสอบความสามารถ มันจะมีการแย่ง CPU ทำให้ค่าที่นำมาใช้ซ้ำเพี้ยน แถมยังซ้ำซ้อนด้วย: ใน pack นั้น lxml ถูกวัดไว้ครบแล้ว การใช้ชุดเดิมทำให้เปรียบเทียบได้แบบ apples-to-apples โดยไม่สร้างการวัดชุดที่สองซึ่งต่างกันเล็กน้อย ดังนั้นถ้าคุณเห็นค่ามิลลิวินาทีด้านล่าง ให้ตีความว่าเป็น "อุปกรณ์ทดสอบชุดเดิม ณ 2026-07-13" ไม่ใช่ "ผมเพิ่งวัดใหม่วันนี้"
ผลลัพธ์ทุกข้อมีป้ายความมั่นใจ: single-observation สำหรับการทดสอบเชิงกำหนด, triple-run สำหรับการกระจายเวลาที่นำมาใช้ซ้ำ, และ hypothesis สำหรับกรณีที่ผมเสนอกลไกแต่ยังแยกพิสูจน์ไม่ได้
XPath: สิ่งเดียวที่ selectolax กับ BeautifulSoup ยังไม่มี
นี่คือประเด็นหลัก และผมจะเริ่มตรงนี้เลย

ผมเอา xpath() ของ lxml ไปทดสอบกับ matrix 37 รายการที่ล็อกผลไว้ล่วงหน้า — คำตอบที่คาดไว้ของแต่ละกรณีถูกเขียนลงในซอร์สก่อนเริ่มทดสอบ ดังนั้นจึงไม่มีทางเผลอให้คะแนนเข้าข้างตัวเองได้ มี axes 10 แบบ, predicate 9 สไตล์, ฟังก์ชันในตัว 10 ตัว, ชนิดผลลัพธ์แบบ scalar 3 แบบ และอีก 5 กรณีดักพลาดที่ใช้ไวยากรณ์เฉพาะของ XPath 2.0 ซึ่งเอนจิน 1.0 ของ lxml ควรปฏิเสธ
| Category | Coverage | Result |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predicates | [1] / last() / position()<n / attribute equality / attribute existence / 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 และคอลัมน์กับดักคือส่วนที่สำคัญ matches(), sequence expressions, if/then/else และ except เป็นไวยากรณ์ของ XPath 2.0 ทั้งหมด และเอนจิน 1.0 ของ libxml2 ไม่ได้รองรับแบบครึ่ง ๆ กลาง ๆ — มันโยน XPathEvalError แล้วปฏิเสธเลย แทนที่จะคืน node set ที่ผิดแบบเงียบ ๆ เพราะฉะนั้นนี่คือคะแนนเต็ม หลังจากพยายามทำให้มันพัง ไม่ใช่คะแนนที่ได้จากโจทย์ง่าย ๆ พฤติกรรมทุกอย่างในนี้ตรงกับที่ เอกสาร XPath ของ lxml อธิบายไว้ทุกประการ ซึ่งนั่นแหละคือประเด็น
ขอยอมรับอย่างหนึ่งว่าชุดทดสอบเคยพลาด ซึ่งเป็นเวอร์ชันของ "37/37" ที่เชื่อถือได้จริง ตอนแรกผมคาดว่าชุดผลลัพธ์ของ //div[.//a[@href]] จะได้สองรายการ แต่ผลที่รันได้มีหนึ่งรายการ ผมคิดว่า lxml ผิดอยู่ราวสามสิบวินาที ก่อนจะไปดู fixture แล้วพบว่าองค์ประกอบตัวที่สองเป็น <footer> ไม่ใช่ <div> — คนที่คาดผิดคือผม ไม่ใช่เอนจิน ผมแก้ expected set แล้วทิ้งคำอธิบายผิดพลาดไว้ในคอมเมนต์ซอร์ส นั่นคือการโทษที่ถูกลำดับ: ให้สงสัย test ของตัวเองก่อนจะโทษ C library อายุ 20 ปี
XPath กับ CSS: สิ่งที่คุณเขียนใน CSS ไม่ได้จริง ๆ
คำพูดกว้าง ๆ ว่า "XPath ทรงพลังกว่า" ควรมีตัวเลขรองรับ ผมเลยวัดช่องว่างนี้ให้ชัด lxml มีทั้ง .xpath() และ .cssselect() (ซึ่งแปลง CSS เป็น XPath ภายใน) ผมหยิบเป้าหมายการเลือก 10 แบบมาดูว่า CSS เขียนได้จริงกี่แบบ

| Target | XPath | CSS (cssselect) |
|---|---|---|
Filter by text content (contains(text(),"bargain")) | Yes | No text predicate |
Select parent from child (//b/parent::p) | Yes | No parent selector |
Return an attribute value (//a/@href) | Yes | Elements only |
Return a text node (//p/text()) | Yes | No text nodes |
Ancestor axis (//td/ancestor::div) | Yes | No upward navigation |
Filter parent by child count (//ul[count(li)=4]) | Yes | No count predicate |
Filter by text length (string-length(text())>5) | Yes | No length predicate |
nth-child / last-child / adjacent sibling | Yes | Yes (3 baseline) |
เป้าหมาย 7 ใน 10 แบบไม่มีทางแทนด้วย CSS เลย การกรองด้วยข้อความ, การไล่ขึ้นไปหา parent หรือ ancestor, การดึงค่าของ attribute หรือ text node แบบเปล่า ๆ, เงื่อนไขที่อิงจำนวน children — CSS พูดไม่ได้เลย มีแค่ 3 แบบ (nth-child, last-child, sibling ที่ติดกัน) ที่ทำได้ทั้งสองฝั่ง นี่คือคำตอบเชิงตัวเลขของคำถามว่า "ผมได้อะไรจริง ๆ จากการใช้ lxml" selectolax เป็น CSS-only และไม่มีเมธอด xpath() เลย ดังนั้น query ทั้ง 7 แบบนี้ในนั้นจะกลายเป็นลูป Python หลายขั้นตอน หรือไม่ก็ทำไม่ได้ ถ้า logic การ scrape ของคุณพึ่งพาพวกนี้ นั่นแปลว่าคุณตัดสินใจได้แล้ว
(และใช่ ชุดทดสอบจับผมได้อีกรอบตรงนี้: ตอนแรกผมทำนายว่ากรณี string-length(text())>5 จะได้ผลว่างเปล่า แต่จริง ๆ มี string ยาว 6 ตัวอักษรสองตัวที่ตรงเงื่อนไข แก้ expected ไม่ใช่แก้เครื่องมือ)
สามระดับความเข้ม: etree, recover และ lxml.html
XPath คือเหตุผลที่เลือก lxml แต่สามระดับของความเข้มในการ parse คือเหตุผลที่คุณจะอยากใช้มันต่อ

parser ส่วนใหญ่ให้พฤติกรรมเดียวเมื่อเจอ input เสีย lxml ให้มาสามแบบ และมันคาดเดาได้พอที่ผมจะป้อน markup เสีย 6 หมวดผ่านทั้งสามโหมด แล้วล็อกผลลัพธ์ที่ควรเกิดไว้ล่วงหน้า
| 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 |
ผลลัพธ์ตรงตามที่ตั้งไว้ 7 จาก 7 กรณี lxml.etree จะโยน XMLSyntaxError กับ markup เสียทั้ง 6 แบบ ถ้าเพิ่ม recover=True ให้ parser ตัวเดียวกัน มันจะกลืน error เหล่านั้นแล้วสร้างต้นไม้ที่ยังใช้งานได้ — และจุดที่มักถูกมองข้ามคือ parser.error_log จะบันทึกทุก error ที่มันกลืนเข้าไปด้วย ส่วน lxml.html จะรับทุกอย่างโดยไม่บ่น
ตัวจำแนกที่ตัดสินว่า "raised vs recovered vs accepted" ใช้ความยาวของ error_log ตอนรันจริง ไม่ได้ hard-code ไว้ ดังนั้นเอกสารที่ดีจริงเมื่อรันด้วย recover=True จะถูกติดป้ายว่า "accepts" (log ว่าง) ไม่ใช่ "recovers" รุ่นแรกของ classifier นี้ผมเคยตั้งให้ผลลัพธ์ recover=True ทุกแบบเป็น "recovers" จนไปติดป้ายผิดกับ input ที่สะอาด — อ่าน error_log จริง ๆ แล้วจึงแก้ได้
สิ่งที่ได้ในทางปฏิบัติ: ถ้าต้องการ validation แบบเข้มเพื่อให้ feed ที่เสียต้องล้มอย่างชัดเจน ให้ใช้ lxml.etree ถ้าเป็น HTML สกปรกจากโลกจริงที่แค่ต้องอ่านให้ผ่าน ให้ใช้ lxml.html และกรณีกลางที่เครื่องมือส่วนใหญ่มักทำไม่ได้ — "ยืดหยุ่นได้ แต่บอกฉันให้ชัดว่าพังตรงไหนเพื่อจะได้ log" — ให้ใช้ recover=True แล้วอ่าน error log selectolax มีแค่โหมดผ่อนปรน ไม่มี strict mode และไม่มี error log
iterparse: เกียร์สตรีมมิงที่ selectolax ไม่มีเลย
นี่คือความสามารถ ไม่ใช่ปุ่มเร่งความเร็ว selectolax รับได้แค่ string ทั้งก้อน — ไม่มีอินเทอร์เฟซแบบ incremental ส่วน iterparse ของ lxml จะส่ง element ออกมาตอนที่มันปิดตัวลง และถ้าใช้คู่กับแพตเทิร์น fast_iter แบบดั้งเดิม (เรียก elem.clear() แล้วลบ sibling ก่อนหน้าไปเรื่อย ๆ) หน่วยความจำก็จะคงที่ไม่ว่าขนาดเอกสารจะใหญ่แค่ไหน

ผมวัดลักษณะหน่วยความจำโดยตรง — peak RSS ผ่าน ru_maxrss, แต่ละ subject อยู่ใน process ใหม่ของตัวเอง, บนข้อมูล 300,000 <record> ประมาณ 15 MB
| Mode | Peak RSS delta | Notes |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | released as it goes; flat regardless of count |
iterparse without clear | ~386 MB | keeps references; as heavy as full load |
etree.parse (full load, anchor) | ~386 MB | known-heavy; proves the meter reads magnitude |
โหมดที่ควบคุมไว้คง peak RSS delta ได้ราว 1-2 MB เมื่อเทียบกับการโหลดเต็มที่ ~386 MB — ต่างกันระดับ 0.3-0.4% — และ event ของ record แรกเกิดขึ้นก่อนอ่านไฟล์จบด้วยซ้ำ ดังนั้นมันคือ incremental จริง ไม่ใช่ streaming ปลอม สิ่งที่สอนเราได้มากที่สุดคือบรรทัดกลาง รันลูป iterparse เดิม แต่ไม่เรียก clear() แล้วหน่วยความจำจะพุ่งกลับไป ~386 MB เพราะคุณกำลังถือ reference ไว้หมด ชัยชนะมาจาก clear() ไม่ใช่ iterparse เพียงอย่างเดียว ค่า full-load ที่สูงกว่าโหมดควบคุมมากยังยืนยันได้ด้วยว่า meter ของ RSS มองเห็นความต่างระดับนี้จริง ไม่ได้ตาบอด (การทดสอบ memory นี้ผมรันใน pack นี้เอง เป็นการวัด footprint ต่างจากตัวเลขเวลาเดิมที่ยกมาใช้)
ในโลกจริง นี่แปลว่า XML export หลายกิกะไบต์ที่ไม่อาจยัดลง RAM ไม่มีทางไปกับ selectolax ได้เลย ต้องใช้ parser แบบ streaming ของ lxml หรือไม่ก็เปลี่ยนภาษา
Namespaces: RSS, SVG และกับดัก default namespace
ทดสอบ namespace 12 กรณี ครอบคลุม RSS ผ่าน 3 namespace, SVG ที่มี default namespace ร่วมกับ xlink และ XML ที่ใช้ default namespace ทั้งหมด 12 กรณีผ่าน
lxml ดึง //dc:creator/text() จาก RSS feed ได้ตรง ๆ เป็น ["Alice", "Bob"], แกะ //atom:link/@href และ //content:encoded ข้ามสาม namespace ในเอกสารเดียวกันได้, จัดการ //s:rect และ //s:use/@xlink:href ใน namespace ที่สองของ SVG ได้, แยกชื่อแบบ Clark notation {uri}local ด้วย QName ได้ และตรวจสอบผ่าน nsmap พฤติกรรมทั้งหมดนี้เป็นของที่รองรับและมีเอกสารชัดเจน และเป็นอีกมิติหนึ่งที่ selectolax ไม่แตะเลย เพราะ selectolax เป็น HTML5-only และไม่ประมวลผล XML namespaces แบบทั่วไป
มีกับดักที่ควรจำขึ้นใจอยู่ข้อหนึ่ง XPath ไม่มีแนวคิดเรื่อง default namespace ถ้าคุณชี้ //book ไปยังเอกสารที่ประกาศ xmlns="urn:..." คุณจะได้ผลลัพธ์เป็นศูนย์ — prefix ว่างไม่มีความหมายใน XPath ตามที่ เอกสาร lxml ระบุไว้ คุณต้องผูก prefix สมมติ (//c:book พร้อม namespaces={"c": "urn:..."} ซึ่งเจอครบทั้งสามรายการ) หรือใช้ //*[local-name()='book'] แทน (ก็ได้สามรายการเช่นกัน) ไม่ใช่บั๊ก — นี่คือสเปก XPath ที่ถูก implement อย่างตรงไปตรงมา เพียงแต่ทุกคนจะงงครั้งแรกเสมอ
หน้าเว็บจริงที่สกปรก: ความแม่นยำบนหน้า real scrape 11 หน้า
การทดสอบสังเคราะห์มันสะอาด แต่เว็บจริงไม่สะอาด ผมเอา fixture หน้าเว็บจริง 11 หน้า จากชุดของ selectolax pack (ณ 2026-07-10, อ่านอย่างเดียว) มาใช้ซ้ำ แล้วให้ lxml.html ผ่านมันในฐานะ subject
| 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 |
ทั้ง 11 หน้า parse ได้ด้วย lxml.html และจำนวนลิงก์, heading และรูปภาพตรงกับตัวเลข lxml ที่ใช้ซ้ำจาก selectolax pack ครบทั้ง 11 หน้า — cross-check เป็น true ความสอดคล้องนี้เองที่บอกว่าการใช้ซ้ำเป็นแบบ apples-to-apples จริง ไม่ใช่เอาค่าต่างกันสองชุดมาใส่ชื่อเดียวกัน
ข้อค้นพบรองคือ strict XML parser โยน error กับ 10 จาก 11 หน้า เว็บจริงส่วนใหญ่ไม่ได้เป็น XML ที่สมบูรณ์อยู่แล้ว นี่แหละเหตุผลที่ libxml2 มีโหมด HTML recovery เอาไว้กินมัน หน้าเดียวที่รอดคือ BBC News ซึ่งเรนเดอร์ด้วย Next.js และสมบูรณ์พอจะผ่าน strict XML parsing ได้ ไม่ใช่ทุกอย่างที่ติดป้ายว่า "HTML" จะต้องใช้ recovery path
มีข้อสังเกตเรื่องการนับที่คนมักพลาดง่าย บน docs_python.html //a[@href] (ตรวจว่า attribute มีอยู่) นับได้ 343 ขณะที่ pack ของ selectolax ใช้ if n.get("href") (ค่าต้องเป็น truthy) นับได้ 341 สองตัวที่เกินมาคือ link ที่มี href="" ว่าง ๆ นั่นคือความต่างของ convention การนับ — มี attribute หรือค่าต้องไม่ว่าง — ไม่ใช่ความต่างของพฤติกรรม lxml และตัวเลขจะกลับมาตรงกันเมื่อคุณปรับ predicate ให้เหมือนกัน สิ่งที่ควรรู้เวลา scrape คือจะนับ href ว่างด้วยหรือไม่ เป็นการเลือกของ filter คุณ ไม่ใช่ของ parser
ขีดจำกัดความลึกที่ดูเหมือนบั๊ก แต่ไม่ใช่
pack ของ selectolax เคยบันทึกว่า lxml ตัดเนื้อหาส่วนลึกสุดทิ้งใน markup ที่ซ้อน <div> ลึก 1,000 และ 5,000 ชั้น และอธิบายว่า "lxml silently loses the deepest content" ผมอยากรู้กลไก จึงรัน 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) |
parser ปริยายจะตัดที่ราว 253 ชั้น แล้วทิ้งทุกอย่างที่ลึกกว่านั้นอย่างเงียบ ๆ นั่นไม่ใช่บั๊ก — มันคือการป้องกัน DoS ของ libxml2 ซึ่งเป็น hard cap การซ้อนราว 256 ชั้น เพื่อไม่ให้เอกสารอันตรายทำให้ stack พัง และมีการพูดถึงไว้ใน เธรด launchpad ของ lxml เกี่ยวกับ XML_PARSE_HUGE ตั้ง huge_tree=True แล้วความลึก 300 กับ 1,000 จะกลับมาครบ แต่ที่ความลึก 5,000 แม้เปิด huge_tree ก็ยังไปได้แค่ 2,045 — แปลว่ายังมี ceiling ของ recursion อีกชั้นใน libxml2 ที่แข็งกว่า และ huge_tree ไม่ได้ปลดมัน
ดังนั้นสิ่งที่ควรทำคือชัดเจน: ถ้าคุณ parse markup ลึกจากแหล่งที่เชื่อถือได้ ให้ใช้ lxml.html.HTMLParser(huge_tree=True) สิ่งที่ pack นี้เพิ่มจากการสังเกตเดิมคือกลไกจริง ๆ (เป็น safety cap ไม่ใช่ข้อมูลพัง), วิธีแก้ (huge_tree) และข้อเท็จจริงว่ามีเพดานอีกชั้นที่วิธีแก้นี้ไม่แตะ
DOM แบบอ่าน/เขียน, serialization, encoding
lxml เป็นต้นไม้แบบอ่าน-เขียนเต็มรูปแบบ ไม่ใช่ตัวดึงข้อมูลอย่างเดียว และผมตรวจพฤติกรรมการแก้ไขทีละกรณี ทั้ง 8 DOM operations ผ่านหมด: SubElement, insert, remove, replace, strip_tags (ลบแท็กแต่เก็บข้อความ), strip_elements (ลบทั้งแท็กและข้อความ), 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 (HTML จะไม่ปิด void elements เป็นตัวเองแบบถูกต้อง), pretty_print, การทำ canonicalization แบบ C14N (method="c14n", อีกฟีเจอร์เฉพาะของ lxml), และ round-trip ที่สะอาด
encoding คือจุดที่ lxml แยกตัวออกมาแบบเงียบ ๆ ป้อน bytes ที่ไม่ใช่ UTF-8 — "<p>café éè</p>".encode("latin-1") ผ่าน lxml.html.fromstring — แล้วมันกู้ café éè กลับมาได้ครบ ไม่มี U+FFFD replacement characters ไม่มี byte หาย นี่คือการทำงานเดียวกับที่มันถูกใช้เป็น "clean reference" ใน selectolax pack ซึ่งอินพุตเดียวกันนั้นถูกทำให้เสียแบบเงียบ ๆ ในอีกสองเอนจิน (Lexbor ออก replacement characters, Modest ตัด bytes ทิ้งไปเลย) libxml2 ที่อยู่เบื้องหลัง lxml จัดการ charset ได้มั่นคงกว่าจริง ๆ
ด้านกลับคือความเข้มงวดเรื่องการ ประกาศ encoding ถ้าคุณใส่ encoding="latin-1" ใน XML declaration จะโยน XMLSyntaxError: Unsupported encoding: latin-1 แต่ถ้าใช้ชื่อ canonical ตาม IANA อย่าง encoding="ISO-8859-1" จะ parse ได้และคืน café libxml2 ยอมรับเฉพาะชื่อ encoding แบบ canonical ไม่ใช่ alias — รายละเอียดนี้มีบันทึกไว้ตั้งแต่ launchpad #613302 น่าหงุดหงิดถ้าไม่รู้ แต่กลายเป็นเรื่องเล็กทันทีเมื่อรู้แล้ว
สุดท้ายคือวงจรชีวิตของโหนด ผมลองสามสถานการณ์ stale handle ใน subprocess แยกกัน (ถ้า crash จริงจะเห็นเป็น exit code ไม่เป็นศูนย์): ถือ node ไว้หลัง tree ถูก garbage-collect, อ่าน handle หลัง drop_tree(), และใช้ node หลัง remove() ไม่มี segfault ในกรณีใดเลย — lxml เก็บ reference ของ node ไปยัง tree ของมันไว้เพื่อกัน use-after-free selectolax ก็ผ่านการทดสอบนี้เช่นกัน
ความเร็วและหน่วยความจำ (ยืมมา และบอกตรง ๆ ว่ายืม)
ทุกอย่างในส่วนนี้นำมาจาก selectolax pack เดิม ณ 2026-07-13 pack นี้ไม่ได้สร้างตัวเลขเวลาใหม่เลย และผมยอมพูดซ้ำให้ชัด ดีกว่าให้คุณคิดว่าผมวัดอะไรใหม่
| Dimension | lxml value | Reading |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | ~33-34% faster than selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | roughly even with Lexbor at small sizes |
| 100k-node CSS throughput | 3,002,646 nodes/s | fastest tier of the three C engines |
| 10 MB RSS delta | 128.9 MB | leanest of six parsers, ~1.7x leaner than BeautifulSoup |
| Import cold start | 14.1 ms | ~2.3x faster than parsel-style imports |
ตัวเลข pure-parse และ throughput แข็งแรงมาก และ lxml เป็นตัวที่ใช้หน่วยความจำคุ้มที่สุดในบรรดา parser ทั้งหกที่วัดไว้ อย่างไรก็ตามภาพของ threading ต้องมีข้อแม้ ตัวเลขที่นำมาใช้ซ้ำชี้ว่า speedup แบบ 4-thread wall-clock อยู่แค่ 1.21x และถูกทำเครื่องหมายว่า inconclusive — แต่ค่านั้นมาจากเส้นทาง shared-default-parser FAQ ของ lxml ระบุชัดว่า GIL จะถูกปล่อยระหว่าง parse ก็ต่อเมื่อแต่ละเธรดใช้ parser ของตัวเอง (หรือ default ที่ถูก copy) ถ้าใช้ parser ร่วมกัน การเข้าถึงจะถูก serialize ผมตรวจ API ว่ามีทางใช้ให้ถูกต้อง (XMLParser.copy() มี, get/set_default_parser มี, XPathEvaluator มี internal lock) แต่ผมไม่ได้วัด speedup แบบ per-thread-parser — เพราะนั่นจะเป็นการวัดใหม่ และ pack นี้ไม่ได้ผลิตตัวเลขนั้น ดังนั้นให้ตีความ "1.21x" ว่าเป็น "ภายใต้ shared path แบบ naive" ไม่ใช่เพดาน threading ของ lxml
และมีข้อแม้อีกชั้นกับตัวเลขทั้งหมดนี้: มันเป็นตัวเลขจากแพลตฟอร์มเดียว คือ macOS arm64 คำกล่าวที่ว่า lxml pure parse เร็วกว่า Lexbor ขัดกับฉันทามติทั่วไปที่ parser บน Lexbor มักเร็วสุดอยู่แล้ว จึงสมควรมีการทดสอบซ้ำบน Linux x86_64 ก่อนที่ใครจะถือว่าเรื่องนี้จบแล้ว
ใบอนุญาต: ชัยชนะที่ไม่ค่อยมีใครพูดถึง
lxml มาใต้ใบอนุญาต BSD-3-Clause และไลบรารี C ที่มัน bundle มาด้วย — libxml2 กับ libxslt — ก็เป็น MIT ทั้งคู่ นี่คือสายอนุญาตแบบ permissive เต็มรูปแบบ ไม่มี copyleft อยู่เลย ซึ่งสำคัญมากทันทีที่คุณจะนำไปแจกจ่ายต่อ เทียบกัน selectolax wheel bundle ทั้ง Modest ใต้ LGPL-2.1 และ Lexbor ใต้ Apache-2.0 ดังนั้น lxml จึงเล่าเรื่องการนำไปใช้ในผลิตภัณฑ์ปิดได้สะอาดกว่า
ยังมีข้อดีเชิงติดตั้งอีกอย่าง: lxml แจก wheels ที่ build ไว้ล่วงหน้าและลิงก์ libxml2 กับ libxslt แบบ static ดังนั้น pip install lxml โดยทั่วไปไม่ต้องพึ่ง system libxml2 หรือคอมไพเลอร์ในเครื่องคุณ — ประสบการณ์คนละแบบกับการ build จากซอร์ส
lxml เหมาะตรงไหน — และชั้น AI Extraction เข้ามารับช่วงตรงไหน
ถึงเวลาพูดให้ชัดเรื่องขอบเขต เพราะจุดนี้สับสนกันง่าย lxml คือไลบรารี parsing มันส่งต้นไม้และ query engine ที่ยอดเยี่ยมให้คุณ ส่วนทุกอย่างรอบต้นไม้นั้นยังเป็นหน้าที่คุณอยู่ดี: การดึงหน้าเว็บ, การเรนเดอร์ JavaScript, การผ่านด่าน anti-bot, การเขียนและดูแล XPath และการจัดโครงสร้างผลลัพธ์ นั่นคืออีกชั้นหนึ่งจากบริการ extraction แบบ hosted และสองอย่างนี้ไม่ใช่คู่แข่งกันเท่าไร แต่เป็นเพื่อนบ้านกันมากกว่า
สำหรับนักพัฒนาที่ไม่อยากแบกภาระ fetch-render-select-maintain เอง ชั้นบนกว่านั้นคือสิ่งที่ 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) สำหรับ agent และ coding assistant และเป็น CLI ที่รันจากเทอร์มินัลได้ตรง ๆ ผ่าน npx @thunderbit/thunderbit-cli มันจัดการ JS rendering, anti-bot และ CAPTCHA ให้ตั้งแต่ต้น และส่งคืน JSON ที่ตรงตาม schema — ซึ่งเป็นชั้นที่อยู่เหนือ parsing ไม่ใช่ตัวแทนของมัน
ลอง Thunderbit เพื่อดึงข้อมูลเว็บ
กรอบคิดนั้นง่ายมาก: เลือก lxml เมื่อคุณเป็นเจ้าของ pipeline และอยากคุม XPath แบบเจาะจงบนต้นไม้ที่คุณเข้าใจ เลือก AI extraction API เมื่อคุณไม่อยากดูแล selector และ rendering เอง หลายระบบจริงใช้ทั้งสองอย่างร่วมกัน — ใช้ lxml กับ feed ที่เป็นโครงสร้างชัดเจนซึ่งคุณควบคุมได้ และใช้บริการ extraction กับหน้าเว็บเละ ๆ ที่ยาวเป็นหางว่าวซึ่งคุณไม่อยากดูแลเอง
สิ่งที่รีวิวนี้ไม่ได้ทดสอบ
นี่คือรีวิวเบื้องต้น ไม่ใช่ใบสรุปสุดท้าย ดังนั้นขอบเขตที่ไม่ครอบคลุมมีดังนี้
ตัวเลขเวลาและหน่วยความจำทั้งหมดเป็นค่าที่นำมาใช้ซ้ำ, มาจากแพลตฟอร์มเดียว (macOS arm64, Python 3.14) และรับข้อจำกัดของ pack เดิมมาด้วย — ผลที่ว่า "lxml เร็วกว่าในการ parse ล้วน" ขัดกับฉันทามติเดิม จึงควรมีการทดสอบซ้ำบน Linux x86_64 speedup ของ threading แบบ per-thread-parser ยังไม่ถูกวัด (ต้องใช้ timing ใหม่) ผมวัดหน่วยความจำของ iterparse บน 300k records แล้ว แต่ไม่ได้วัด XML ขนาดระดับ GB จริง, ไม่ได้วัด iterparse บน HTML เทียบกับ XML, และไม่ได้ทำ soak test หลายชั่วโมง lxml ฝั่ง XSLT 1.0, การตรวจสอบ RelaxNG / XMLSchema / DTD และ EXSLT extensions ยังไม่ได้ทดสอบทั้งหมด — เป็นพื้นที่ความสามารถขนาดใหญ่ แต่เกินแกน parsing-and-selection ไปแล้ว ผมพบ ceiling ความลึกชั้นที่สองที่ 2,045 แต่ยังไม่ได้ระบุค่าคงที่ recursion ของ libxml2 แบบเป๊ะ ๆ ทดสอบเฉพาะ stable 6.1.1 ไม่ใช่ alpha 7.0.0 Windows, build จากซอร์ส และ free-threaded 3.14t ยังไม่ถูกทดสอบทั้งหมด และภายใน XPath เอง ผมครอบคลุม built-in functions แต่ไม่ได้ทดสอบ XPath variables, custom Python extension functions หรือการ reuse ของ etree.XPath ที่ precompiled แล้ว
บทสรุป
lxml ไม่ใช่ของใหม่ที่เร็วสุด และนั่นแหละคือเหตุผลที่แนะนำมัน มันคือ binding ของ libxml2 อายุสองทศวรรษที่มีเอ็นจิน XPath 1.0 แบบเต็ม ซึ่งไม่มีทางเลือก Python กระแสหลักตัวไหนเทียบได้, มีสามระดับความเข้มของ parser ที่คาดเดาได้พร้อม error log อยู่ตรงกลาง, มี parser แบบ streaming จริงสำหรับเอกสารที่ใส่ RAM ไม่ได้, รองรับ multi-namespace และ encoding ได้ถูกต้อง, และมีใบอนุญาตแบบ permissive เต็มรูปแบบ จุดคมบางอย่าง — ขีดจำกัดความลึกราว 253 ชั้น และตัวเลข threading แบบ shared-parser — เป็นสิ่งที่เอกสารไว้ ปรับได้ และตอนนี้อธิบายได้แล้ว
ถ้าคุณเป็นเจ้าของ pipeline การ scrape เองและใช้งาน XPath หนัก ๆ lxml ยังเป็น parser ที่ควรหยิบก่อน ถ้าคุณไม่อยากดูแล selectors และ rendering เอง นั่นคือหน้าที่ของชั้น AI extraction อย่าง Thunderbit API, MCP และ CLI — การแบ่งงานที่ชัดเจน ไม่ใช่การแข่งขันกัน ไม่ว่าทางไหน ให้ถือว่าตัวเลขเหล่านี้ยังเป็นข้อมูลเบื้องต้น และตรวจ timing บนแพลตฟอร์มของคุณเองก่อนจะอ้างอิงใน design doc
ลอง Thunderbit เพื่อดึงข้อมูลเว็บ Get Started Free
คำถามที่พบบ่อย
lxml เป็นเว็บสแครปเปอร์ไหม?
ไม่ใช่ lxml คือ parser และ serializer — เป็น Python binding ของ libxml2/libxslt ที่แปลง markup ให้เป็นต้นไม้ที่แก้ไขและ query ได้ มันไม่ดึงหน้าเว็บ, ไม่เรนเดอร์ JavaScript และไม่จัดการ anti-bot defense; คุณต้องเตรียมชั้น request เอง (ผ่าน requests, httpx, headless browser หรือ scraping service) แล้วส่ง bytes ให้ lxml ประมวลผล
ควรใช้ lxml แทน BeautifulSoup หรือ selectolax ตอนไหน? เลือก lxml เมื่อคุณต้องใช้ XPath จริง ๆ BeautifulSoup ใช้ lxml เป็น backend parser ได้ แต่ไม่มี XPath แบบ native ส่วน selectolax เป็น CSS-only และเร็วในขอบเขตที่แคบของมันเอง ถ้า logic การเลือกของคุณต้องกรองด้วยข้อความ, ไล่ขึ้นไปหา parent หรือ ancestor, ดึง attribute/text node หรือใช้ count predicates เอนจิน XPath ของ lxml คือทางเลือกหลักใน Python ที่แสดงสิ่งเหล่านี้ได้โดยตรง
ทำไม lxml ถึงตัดเนื้อหาที่ซ้อนลึกมากทิ้งแบบเงียบ ๆ?
เพราะ parser ปริยายจำกัด nesting ไว้ราว 253 ชั้น — เป็นกลไกป้องกัน DoS ของ libxml2 ไม่ใช่บั๊ก ตั้ง huge_tree=True (เช่น lxml.html.HTMLParser(huge_tree=True)) แล้วมันจะกู้ความลึก 300 และ 1,000 ได้ครบ แต่ต้องจำไว้ว่ายังมีเพดาน recursion อีกชั้นราว 2,045 ที่ huge_tree ไม่ปลด
lxml ปล่อย GIL ตอน parse แบบหลายเธรดไหม? ปล่อยเฉพาะในเงื่อนไขที่ถูกต้อง FAQ ของ lxml ระบุว่า GIL จะถูกปล่อยระหว่าง parse เมื่อแต่ละเธรดใช้ parser ของตัวเอง หรือใช้ default parser ที่ถูก copy มาแล้ว; ถ้าใช้ parser ร่วมกัน การเข้าถึงจะถูก serialize แทน ความเร็วที่ใช้ซ้ำ 1.21x จาก 4 เธรดสะท้อนเส้นทาง shared-parser แบบ naive ไม่ใช่เพดานของ per-thread-parser ซึ่งไม่ได้วัดในที่นี้
lxml ยังได้รับการดูแลในปี 2026 อยู่ไหม? ใช่ รุ่น stable 6.1.1 ออกเมื่อ 2026-05-18 รีโพซิทอรีถูก push ล่าสุดเมื่อ 2026-07-02 และกำลังพัฒนา alpha 7.0.0 อยู่ ด้วย GitHub stars ราว 3,000 และ libxml2 ที่ดูแลอยู่เบื้องหลัง มันยังเป็นไลบรารีปัจจุบันที่ได้รับการซัพพอร์ต ไม่ใช่ของเก่าทิ้งไว้เฉย ๆ


