lxml รีวิว: เอ็นจิน XPath ที่ยังเหนือชั้นกว่า Python Parser เจ้าอื่น

อัปเดตล่าสุดเมื่อ August 12, 2026
lxml รีวิว: เอ็นจิน XPath ที่ยังเหนือชั้นกว่า Python Parser เจ้าอื่น
สรุปด้วย AI
บทความรีวิว lxml นี้มองไลบรารีนี้ในฐานะ Python binding ของ libxml2 และ libxslt ที่ใช้งานมาอย่างยาวนาน พร้อมจุดแข็งสำคัญที่ parser รุ่นใหม่หลายตัวก็ยังไม่เท่า นั่นคือ XPath engine แบบจริงจัง บทความทดสอบทั้งความครอบคลุมของ XPath, โหมด strictness ของ parser, พฤติกรรมด้าน memory ของการสตรีม, ความสามารถของ CSS เทียบกับ XPath และข้อจำกัดด้านความลึกของ libxml2 พร้อมชี้ให้เห็นว่า lxml เร็ว ประหยัดหน่วยความจำ และทรงพลังมากสำหรับงาน XML/HTML ที่ต้องใช้ axes, predicates, functions, streaming หรือโหมด recovery ที่ยืดหยุ่น บทความยังอธิบายค่าเริ่มต้นด้านความปลอดภัยเรื่องความลึกของ tree และขอบเขตที่ huge_tree ช่วยขยายได้

ทุก ๆ สองสามเดือนจะมี HTML parser ที่เร็วกว่าโผล่ขึ้นมา ทำคะแนน benchmark ได้สวย แล้วก็มีคนประกาศว่าเครื่องมือรุ่นเก่าหมดสมัยไปแล้ว แต่พอถึงเวลาต้องเลือกทุกย่อหน้าที่ มี คำบางคำอยู่ข้างใน หรือดึง parent ของ node ที่ตรงเงื่อนไขขึ้นมา คุณก็จะนึกได้ทันทีว่าทำไม lxml ถึงยังเปิดค้างอยู่ในแท็บอีกอันของคุณ

lxml คือ binding ของ libxml2 ที่อยู่มานานกว่า 20 ปี มันไม่ใช่ของใหม่ ไม่ได้หวือหวา และไม่ได้เกิดมาเพื่อเป็น scraper หรือ browser — แต่มันเด่นมากสำหรับงานเฉพาะทางหนึ่ง นั่นคือทุกอย่างที่ต้องใช้ XPath แบบจริงจัง ในโลก Python กระแสหลักแทบไม่มีอะไรสู้มันได้ บทความนี้คือรีวิวแบบลงมือใช้ ว่ามันทำอะไรได้ดีตรงไหน ชนะเงียบ ๆ ตรงไหน และจุดไหนที่ค่าเริ่มต้นของมันอาจทำให้คุณสะดุ้งถ้าไม่รู้ไว้ก่อน

lxml ในหนึ่งย่อหน้า: มันคืออะไรกันแน่

lxml คือ Python binding สำหรับไลบรารี C ชุด libxml2 และ libxslt มันเป็น parser และ serializer ไม่ใช่ scraper และไม่ใช่ browser — มันแปลง markup ให้กลายเป็น tree ที่คุณ query และแก้ไขได้ แล้วแปลง tree กลับเป็น 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"

สถานะล่าสุดจาก snapshot บน GitHub และ PyPI ที่ดึงเมื่อ 2026-07-14 เป็นดังนี้:

ฟิลด์ค่า
Repolxml/lxml
Stars3,043
Forks620
Open issues16
LicenseBSD-3-Clause
Created2011-02-11
Last push2026-07-02
PyPI stable6.1.1 (2026-05-18)
Bundled enginelibxml2 2.14.6 + libxslt 1.1.43

ก่อนจะมีใครหาว่าผมอวย ขอเคลียร์ไว้ก่อนว่ารีวิวนี้ไม่มี “สูตรลับ” อะไรทั้งนั้น lxml เก่าพอที่จะมีพฤติกรรมทุกอย่างที่พูดถึงอยู่ใน docs ของ lxml, changelog ของ libxml2 หรือเธรดใน launchpad อยู่แล้ว ผมไม่ได้เจอทริกที่ไม่มีเอกสารรองรับ และจะไม่แต่งขึ้นมาเอง จุดที่มีคุณค่าของบทความนี้คือการเอาทุกอย่างมาจัดระบบ วัดผล และเรียบเรียงรอบ ๆ lxml ในฐานะหัวข้อหลัก — ไม่ใช่เพราะมันเป็นข่าวใหม่

วิธีทดสอบ (และเหตุผลที่ตัวเลขเวลาเป็นตัวเลขยืม)

รีวิวนี้อิงข้อมูลสองประเภท ซึ่งมาจากสองแหล่งต่างกัน ผมขอแยกให้ชัดว่าตรงไหนเป็นอะไร

การทดสอบความสามารถ — พฤติกรรม XPath, parser API สองแบบ, namespaces, encoding, วงจรชีวิตของ node — ผมรันใหม่บนเครื่องเดียว: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6 ตัวเลขทุกตัวในไฟล์ artifacts/raw/*.json เหล่านั้นคำนวณจากสคริปต์ที่รันจริง ไม่ได้พิมพ์มือ ความสามารถพวกนี้เป็นผลแบบ deterministic ที่ตอบเป็น boolean หรือ enum ดังนั้นการรันครั้งเดียวก็เสถียร — โหลดเครื่องไม่ได้เปลี่ยนว่า //a/@href จะคืนค่า attribute string หรือไม่

ส่วนตัวเลขเวลาและ memory footprint ไม่ได้มาจากชุดนี้ แต่นำมาซ้ำตรง ๆ จาก benchmark pack ของ selectolax ก่อนหน้า — เครื่องเดียวกัน, virtual environment เดียวกัน, build ของ lxml และ libxml2 เดียวกัน, benchmarks ณ วันที่ 2026-07-13 — และผมไม่ได้รันซ้ำที่นี่ ตั้งใจทำแบบนั้น เพราะถ้ารัน timing benchmark ไปพร้อมกับชุดสคริปต์ความสามารถ จะเสี่ยงให้ CPU ชนกันจนตัวเลขที่ยืมมาคลาดเคลื่อน และยังเป็นงานซ้ำด้วย: ใน pack นั้น lxml ถูกวัดไว้ครบอยู่แล้ว การนำ benchmark ชุดเดิมมาใช้ช่วยให้เทียบกันได้แบบ apples-to-apples แทนที่จะสร้างการวัดรอบสองที่ต่างกันเล็กน้อย ดังนั้นเวลาเห็นตัวเลขมิลลิวินาทีด้านล่าง ให้เข้าใจว่าเป็น “เครื่องทดสอบเดียวกัน, ณ 2026-07-13” ไม่ใช่ “ผมจับเวลาใหม่วันนี้”

ผลลัพธ์แต่ละข้อมีป้ายความเชื่อมั่นกำกับ: single-observation สำหรับชุดทดสอบความสามารถที่เป็น deterministic, triple-run สำหรับการแจกแจงเวลาที่นำกลับมาใช้ใหม่ และ hypothesis สำหรับจุดที่ผมเสนอกลไกแต่ยังไม่ได้แยกตัวแปรออกมาพิสูจน์

XPath: สิ่งเดียวที่ selectolax กับ BeautifulSoup ไม่มีทางมี

นี่คือประเด็นหลัก จึงขอเริ่มตรงนี้

lxml XPath coverage moat with axes predicates and functions

ผมนำ xpath() ของ lxml ไปทดสอบกับ matrix 37 รายการที่ pre-registered ไว้แล้ว — นั่นคือคำตอบที่คาดไว้ของทุกกรณีถูกเขียนไว้ในซอร์สก่อนเริ่มทดสอบ จึงไม่มีโอกาสเผลอให้คะแนนเอียงไปตามใจเรา มี axes 10 แบบ, predicate 9 รูปแบบ, built-in functions 10 ตัว, scalar return types 3 แบบ และ trap case ตั้งใจหลอก 5 กรณีที่ใช้ syntax ของ XPath 2.0 ซึ่งเอ็นจิน XPath 1.0 ของ lxml ควรปฏิเสธ

หมวดความครอบคลุมผลลัพธ์
Axeschild / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / precedingผ่าน 10/10
Predicates[1] / last() / position()<n / attribute equality / attribute existence / and / or / nested [.//a] / not()ผ่าน 9/9
Functionstext() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string()ผ่าน 10/10
Return typesboolean / number scalarsผ่าน 3/3
Trap casesmatches() / sequences / if-then-else / except / syntax errorปฏิเสธถูกต้อง 5/5

คะแนนคือ 37/37 และคอลัมน์ trap คือส่วนสำคัญ matches(), sequence expressions, if/then/else และ except ล้วนเป็น syntax ของ XPath 2.0 และเอ็นจิน 1.0 ของ libxml2 ไม่ได้ครึ่ง ๆ กลาง ๆ กับมัน — มันโยน XPathEvalError แล้วปฏิเสธทันที แทนที่จะคืน node set ผิด ๆ แบบเงียบ ๆ ดังนั้นนี่คือคะแนนเต็ม หลังจากพยายามทำให้มันพัง ไม่ใช่คะแนนเต็มจากโจทย์ง่าย ๆ ทุกพฤติกรรมตรงกับที่ docs ของ XPath ใน lxml อธิบายไว้เป๊ะ ๆ ซึ่งนั่นแหละคือประเด็น

ขอยอมรับอย่างหนึ่งว่าฮาร์เนสทดสอบพลาดไปหนึ่งจุด แต่เป็นความหมายที่คุณเชื่อถือได้จริงของ “37/37” ครั้งแรกผมคาดว่า //div[.//a[@href]] จะได้สองผลลัพธ์ แต่เมื่อรันจริงได้หนึ่ง ผมนึกว่า lxml ผิดอยู่ราวสามสิบวินาที แล้วกลับไปเช็ค fixture จึงพบว่าอีกตัวเป็น <footer> ไม่ใช่ <div> — expectation ของผมผิด ไม่ใช่เอนจิน ผมแก้ expected set แล้วทิ้งความผิดพลาดไว้ในคอมเมนต์ซอร์ส เพราะลำดับการโทษที่ถูกต้องคือ สงสัยเทสต์ของตัวเองก่อนจะโทษไลบรารี C ที่อยู่มานาน 20 ปี

XPath vs CSS: สิ่งที่ CSS ไม่สามารถอธิบายได้จริง ๆ

คำพูดกว้าง ๆ ว่า “XPath ทรงพลังกว่า” ควรมีตัวเลขรองรับ ผมจึงวัดช่องว่างนี้ไว้ lxml ให้ทั้ง .xpath() และ .cssselect() (ตัวหลังแปลง CSS เป็น XPath ใต้ฝากระโปรง) ผมเลือกเป้าหมายการ select 10 แบบ แล้วตรวจว่า CSS เขียนถึงได้จริงกี่แบบ

XPath expresses seven of ten tasks CSS cannot express

เป้าหมายXPathCSS (cssselect)
กรองตามข้อความภายใน (contains(text(),"bargain"))ได้ไม่มี text predicate
เลือก parent จาก child (//b/parent::p)ได้ไม่มี parent selector
คืนค่า attribute value (//a/@href)ได้คืนได้เฉพาะ elements
คืนค่า text node (//p/text())ได้ไม่มี text nodes
ancestor axis (//td/ancestor::div)ได้ไม่มีการไล่ขึ้นด้านบน
กรอง parent ด้วยจำนวน child (//ul[count(li)=4])ได้ไม่มี count predicate
กรองตามความยาวข้อความ (string-length(text())>5)ได้ไม่มี length predicate
nth-child / last-child / adjacent siblingได้ได้ (3 พื้นฐาน)

เป้าหมาย 10 แบบ มี 7 แบบที่ CSS แทนไม่ได้เลย การกรองตามเนื้อหาข้อความ, การไล่ขึ้นไปหา parent หรือ ancestor, การดึง attribute value หรือ text node เดี่ยว ๆ ออกมาเป็นผลลัพธ์, predicate ที่อิงจำนวน child — CSS พูดถึงทั้งหมดนี้ไม่ได้ มีแค่ 3 แบบ (nth-child, last-child, adjacent sibling) ที่ทำได้ทั้งสองทาง นี่คือคำตอบเชิงตัวเลขของคำถามว่า “ฉันได้อะไรเพิ่มจากการใช้ lxml” selectolax เป็น CSS-only และไม่มี xpath() เลย ดังนั้น query แบบเจ็ดชนิดนี้จะกลายเป็นโค้ด Python หลายขั้นตอนหรือไม่ก็ทำไม่ได้ ถ้า logic การ scrape ของคุณพึ่งพาอย่างใดอย่างหนึ่งจากนี้ คุณก็ตัดสินใจแทนตัวเองไปแล้ว

(และใช่ ฮาร์เนสจับผมพลาดรอบสองตรงนี้อีก ผมเดาว่าชุดผลลัพธ์ของ string-length(text())>5 จะว่างเปล่า แต่จริง ๆ มี string ยาว 6 ตัวอักษรสองตัวที่เข้าเงื่อนไข ผมแก้ expectation ไม่ใช่แก้เครื่องมือ)

สามโหมดความเข้มงวด: etree vs recover vs lxml.html

XPath คือเหตุผลที่ควรเลือก lxml ส่วนสามโหมดความเข้มงวดคือเหตุผลที่ควร เก็บ มันไว้ใช้ต่อ

lxml strictness gears: etree, recover, and lxml.html

parser ส่วนใหญ่ให้พฤติกรรมเดียวเมื่อเจอ input ที่เสีย แต่ lxml ให้คุณถึงสามแบบ และคาดเดาได้พอจนผมป้อน markup ผิดรูป 6 กลุ่มผ่านทั้งสามโหมด พร้อม pre-register ว่าแต่ละเส้นทางควรทำงานอย่างไร

Input ที่เสียรูปlxml.etree (strict)etree + recover=Truelxml.html (lenient)
แท็กปิดไม่ครบ <root><a>x</root>โยน errorกู้คืนได้รับได้
ซ้อนแท็กผิด <b><i></b></i>โยน errorกู้คืนได้รับได้
entity ที่ไม่รู้จัก &nbsp;โยน errorกู้คืนได้รับได้
เครื่องหมาย & เดี่ยว ๆ (Tom & Jerry)โยน errorกู้คืนได้รับได้
มีหลาย root <a>1</a><b>2</b>โยน errorกู้คืนได้รับได้
XML ที่สมบูรณ์รับได้รับได้ (0 errors)รับได้
Boolean attribute <input disabled>โยน errorกู้คืนได้รับได้

ผล 7 จาก 7 ตรงกับ expectation ที่ตั้งไว้ lxml.etree จะโยน XMLSyntaxError กับ markup เสียทั้ง 6 แบบ พอใส่ recover=True ให้ parser เดิม มันจะกลืน error และประกอบ tree ที่ใช้งานได้กลับมา — และนี่คือส่วนที่มักถูกมองข้าม — จากนั้น parser.error_log จะบันทึกทุก error ที่มันกลืนไป ส่วน lxml.html รับทุกอย่างแบบไม่บ่น

ตัวจำแนกว่าอะไรคือ “โยน error”, “กู้คืน” หรือ “รับได้” จริง ๆ แล้วดูจากความยาวของ error_log ตอนรัน ไม่ได้ hard-code เอาเอง เพราะเหตุนี้ doc ที่สมบูรณ์แม้จะรันด้วย recover=True จึงถูกติดป้ายว่า “รับได้” (log ว่าง) ไม่ใช่ “กู้คืน” รุ่นแรกของ classifier ผมเคยติดป้ายทุกผลลัพธ์ที่ใช้ recover=True ว่า “กู้คืน” หมดเลย ทำให้ 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 ก่อนหน้าไปเรื่อย ๆ) มันจะคุม memory ให้คงที่ไม่ว่าเอกสารจะใหญ่แค่ไหน

lxml iterparse streams 300K records with about 1-2 MB RSS

ผมวัดลักษณะ memory โดยตรง — peak RSS ผ่าน ru_maxrss, แต่ละ subject รันใน process ใหม่แยกกัน, บน <record> จำนวน 300,000 รายการ ที่มีขนาดรวมราว 26.7 MB (26,744,801 bytes)

โหมดPeak RSS deltaหมายเหตุ
iterparse + clear (fast_iter)~1-2 MBปล่อย memory ไปเรื่อย ๆ; คงที่ไม่ว่าจำนวนจะเท่าไร
iterparse โดยไม่ clear~386 MBยังค้าง reference; หนักพอ ๆ กับโหลดทั้งก้อน
etree.parse (full load, anchor)~386 MBหนักตามคาด; ยืนยันว่า meter วัดขนาดได้จริง

โหมดแบบจำกัดคุม peak RSS delta ให้อยู่ราว 1-2 MB เทียบกับ full load ที่ประมาณ 386 MB — ต่างกันราว 0.3-0.4% ในเชิง magnitude — และ event ของ record แรกเกิดก่อนอ่านไฟล์จบด้วยซ้ำ แปลว่ามัน incremental จริง ไม่ใช่การสตรีมแบบหลอก ๆ เส้นที่สอนใจที่สุดคือบรรทัดกลาง รันลูป iterparse เดิม แต่ตัด clear() ออก memory ก็พุ่งกลับไปใกล้ 386 MB เพราะคุณยังถือ reference ทุกอย่างไว้ ชัยชนะจริงอยู่ที่ clear() ไม่ใช่ iterparse เพียว ๆ ส่วน anchor แบบ full-load ที่สูงกว่ามากก็ช่วยยืนยันว่า RSS meter มองเห็นช่องว่างของขนาดจริง ไม่ได้อ่านแบบตาบอด (การทดสอบ memory นี้ผมรันใน pack นี้เอง — เป็น footprint measurement ต่างจากตัวเลขเวลาที่นำมาใช้ใหม่)

เวอร์ชันใช้งานจริงก็คือ XML export ขนาดหลายกิกะไบต์ที่ไม่อาจยัดลง RAM ได้ ไม่มีทางเลือกแบบ selectolax เลย มีเพียง parser แบบสตรีมมิงของ 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 ได้ และ inspect ผ่าน nsmap ได้ นี่คือพฤติกรรมที่ดูแลและมีเอกสารรองรับครบถ้วน และเป็นอีกมิติหนึ่งที่ selectolax ไม่แตะ เพราะ selectolax เป็น HTML5-only และไม่ประมวลผล XML namespaces ทั่วไป

มีหนึ่งกับดักที่ documented ไว้และควรจำให้ขึ้นใจ XPath ไม่มีแนวคิดเรื่อง default namespace ถ้าคุณชี้ //book ไปที่เอกสารที่ประกาศ xmlns="urn:..." จะไม่เจออะไรเลย — prefix ว่างไม่ได้ถูกนิยามใน XPath ตามที่ docs ของ lxml ระบุไว้ คุณต้อง bind prefix ขึ้นมาเอง (//c:book พร้อม namespaces={"c": "urn:..."} ซึ่งเจอทั้ง 3 รายการ) หรือใช้ //*[local-name()='book'] แทน (ก็เจอ 3 เช่นกัน) ไม่ใช่บั๊ก — มันคือ XPath spec ที่ทำงานถูกต้องทุกอย่าง เพียงแต่จะทำให้คนแปลกใจครั้งเดียวเสมอ

หน้าเว็บจริงที่สกปรก: ความแม่นยำบน 11 หน้า scrape จริง

เทสต์สังเคราะห์นั้นสะอาด แต่เว็บจริงไม่สะอาด ผมนำหน้าเว็บที่ capture จริง 11 หน้า จาก fixture set ของ pack selectolax (ณ 2026-07-10, read-only) มาใช้ใหม่ แล้วให้ lxml.html ผ่านมันโดยมี lxml เป็นตัวทดสอบหลัก

Fixtureขนาดลิงก์error ที่ libxml2 กู้คืนStrict XML
news_bbc.html398 KB2554ok
docs_mdn_array.html243 KB5080raised
wiki_scraping.html227 KB4600raised
gov_whitehouse.html289 KB1540raised
oldstyle_craigslist.html561 KB3510raised
forum_reddit.html129 KB3180raised
docs_python.html80 KB3412raised
ecommerce_books.html51 KB940raised
news_hackernews.html35 KB2290raised
ecommerce_webscraper_allinone.html16 KB350raised
spa_quotes_js.html6 KB50raised

ทั้ง 11 หน้า parse ได้ด้วย lxml.html และจำนวนลิงก์, heading และ image ตรงกับจำนวนของ 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") (ตรวจว่าค่าไม่ว่าง) นับได้ 341 สองตัวที่เพิ่มมาคือ href="" ว่าง ๆ นั่นเอง นี่คือความต่างของวิธีนับ — attribute มีอยู่ vs 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

lxml default depth guard around 253 levels and huge_tree to 2045

ความลึกที่ร้องขอparser ปกติไปได้ถึงhuge_tree=True ไปได้ถึง
300253 (ส่วนที่เหลือถูกตัด)299 (กู้คืนได้)
1000253 (ส่วนที่เหลือถูกตัด)999 (กู้คืนได้)
5000253 (ส่วนที่เหลือถูกตัด)2045 (ยังตัดอยู่)

parser ค่าเริ่มต้นจะตัดที่ราว 253 ชั้น และทิ้งส่วนที่ลึกกว่านั้นแบบเงียบ ๆ นั่นไม่ใช่บั๊ก — แต่มันคือการป้องกัน DoS ของ libxml2 ซึ่งตั้งเพดาน nesting ไว้ราว 256 ชั้นเพื่อกันเอกสารอันตรายทำให้ stack พัง และมีอธิบายไว้ใน เธรด launchpad ของ lxml เกี่ยวกับ XML_PARSE_HUGE ตั้ง huge_tree=True แล้วความลึก 300 กับ 1,000 ก็กลับมาครบถ้วน แต่ความลึก 5,000 ยังไปได้แค่ 2,045 แม้เปิด huge_tree แล้ว — แปลว่ามีเพดาน recursion อีกรอบที่แข็งกว่าอยู่ใน libxml2 และ huge_tree ไม่ได้ปลดมัน

ดังนั้นสิ่งที่ควรทำจึงชัดเจน: ถ้าคุณ parse markup ลึก ๆ จากแหล่งที่ไว้ใจได้ ให้ใช้ lxml.html.HTMLParser(huge_tree=True) สิ่งที่ pack นี้เพิ่มจากข้อสังเกตเดิมคือกลไกที่แท้จริง (เป็น safety cap ไม่ใช่ข้อมูลเสีย), วิธีแก้ (huge_tree) และข้อเท็จจริงว่ามีเพดานชั้นที่สองซึ่งวิธีแก้นี้ไปไม่ถึง

DOM แบบอ่าน/เขียน, Serialization, Encoding

lxml เป็น tree แบบอ่าน/เขียนเต็มตัว ไม่ใช่แค่ตัวดึงข้อมูลแบบอ่านอย่างเดียว และผมตรวจ surface การแก้ไขทีละกรณี ทั้ง 8 การทำงานของ DOM ผ่านหมด: 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 element แบบ self-close อย่างถูกต้อง), pretty_print, C14N canonicalization (method="c14n" ซึ่งเป็นความสามารถเฉพาะของ lxml อีกเช่นกัน) และ round-trip ที่สะอาด

เรื่อง encoding คือจุดที่ lxml แยกตัวเองออกมาแบบเงียบ ๆ ถ้า feed เป็น bytes ที่ไม่ใช่ UTF-8 — เช่น "<p>café éè</p>".encode("latin-1") ผ่าน lxml.html.fromstring — มันกู้ café éè กลับมาได้ครบ ไม่มี replacement character แบบ U+FFFD ไม่มี byte หาย นี่สะท้อนบทบาทของมันในฐานะ “reference ที่สะอาด” ใน pack ของ selectolax โดยตรง เพราะ input เดียวกันกลับพังเงียบในอีกสองเอ็นจิน (Lexbor ให้ replacement character ส่วน Modest ตัด byte ทิ้งไปเลย) ตัวตรวจจับ charset ของ libxml2 ที่ lxml ใช้อยู่จึงนิ่งกว่าชัดเจนในจุดนี้

อีกด้านหนึ่งคือความเข้มงวดตอนคุณ ประกาศ encoding เอง encoding="latin-1" ใน XML declaration จะโยน XMLSyntaxError: Unsupported encoding: latin-1 แต่ encoding="ISO-8859-1" ซึ่งเป็นชื่อ canonical ตาม IANA จะ parse ผ่านและคืน café ได้ libxml2 ยอมรับเฉพาะชื่อ encoding แบบ canonical ไม่รับ alias ซึ่งเป็นรายละเอียดที่มีบันทึกไว้ตั้งแต่ launchpad #613302 น่ารำคาญถ้าไม่รู้ แต่จิ๊บจ๊อยถ้ารู้แล้ว

สุดท้ายคือวงจรชีวิตของ node ผมรันสถานการณ์ stale-handle สามแบบใน subprocess แยกกัน (ถ้า crash จริงจะเห็นเป็น exit code ที่ไม่ใช่ศูนย์): ถือ node ไว้หลัง tree ถูก garbage-collected, อ่าน handle หลัง drop_tree(), และใช้ node หลัง remove() ไม่มี segfault ในกรณีใดเลย — lxml จะคง reference ของ node ไปที่ tree เอาไว้เพื่อป้องกัน use-after-free ผลออกมาสะอาดเหมือนกับที่ selectolax ได้จากเทสต์นี้

ความเร็วและหน่วยความจำ (ยืมมา และบอกตรง ๆ ว่ายืม)

ทุกอย่างในส่วนนี้นำกลับมาใช้จาก pack ของ selectolax ณ วันที่ 2026-07-13 ชุดนี้เองไม่ได้ผลิตตัวเลขเวลาใหม่เลย และผมยอมพูดซ้ำอีกครั้งดีกว่าให้คุณคิดว่าผมจับเวลาใหม่

มิติค่า lxmlความหมาย
Pure parse p50 (10 MB)77.9 msเร็วกว่า selectolax-Lexbor ราว 33-34%
Full parse + extract p50 (1 MB / 10 MB)14.18 ms / 172.9 msใกล้เคียง Lexbor ในไฟล์เล็ก
100k-node CSS throughput3,002,646 nodes/sอยู่ในชั้นเร็วสุดของ C engine ทั้งสาม
10 MB RSS delta128.9 MBประหยัดสุดใน 6 parser ประมาณ 1.7x เมื่อเทียบกับ BeautifulSoup
Import cold start14.1 msเร็วกว่าการ import สไตล์ parsel ราว 2.3x

ตัวเลข pure-parse และ throughput แข็งแรง และ lxml คือ parser ที่ใช้ memory คุ้มที่สุดใน 6 ตัวที่วัดไว้ แต่ภาพเรื่อง threading ต้องมีคำเตือน ข้อมูลที่นำมาใช้ซ้ำชี้ว่า speedup แบบ 4 threads ได้เพียง 1.21x และถูกทำเครื่องหมายว่า inconclusive — แต่นั่นคือเส้นทาง shared default parser เอกสาร FAQ ของ lxml ระบุชัดว่า GIL จะถูกปล่อยระหว่าง parse ก็ต่อเมื่อแต่ละ thread ใช้ parser ของตัวเอง (หรือ default ที่ถูก copy) เท่านั้น ถ้าใช้ parser ร่วมกัน การเข้าถึงจะถูก serialize ผมตรวจสอบเชิงโครงสร้างแล้วว่า API สำหรับทำให้ถูกมีอยู่ (XMLParser.copy(), get/set_default_parser, และ XPathEvaluator ที่มี internal lock) แต่ผมไม่ได้วัด speedup แบบ per-thread-parser เพราะนั่นจะเป็นการวัดใหม่ และ pack นี้ไม่ได้ผลิตตัวเลขแบบนั้น ดังนั้นอย่าอ่าน 1.21x ว่าเป็นเพดานของ threading ใน lxml — มันเป็นแค่ผลของเส้นทาง shared ที่ naïve

และมีดอกจันกับทุกตัวเลขนี้อีกข้อ: นี่คือตัวเลขจากแพลตฟอร์มเดียวคือ macOS arm64 ข้ออ้างที่ว่า lxml pure parse ชนะ Lexbor นั้นขัดกับความเชื่อทั่วไปที่ parser ฝั่ง Lexbor มักเร็วสุดจริง ๆ ดังนั้นจึงควรถูก recheck บน Linux x86_64 ก่อนจะถือว่าปิดคดีแล้ว

ใบอนุญาต: ชัยชนะที่ไม่หวือหวาแต่มีค่า

lxml แจกจ่ายภายใต้ BSD-3-Clause และไลบรารี C ที่บันเดิลมาด้วย — libxml2 กับ libxslt — ต่างก็เป็น MIT ทั้งคู่ นี่คือสายอนุญาตแบบ permissive เต็มตัว ไม่มี copyleft แทรกอยู่เลย ซึ่งสำคัญทันทีเมื่อคุณต้อง redistribute เพื่อเทียบกัน wheel ของ selectolax บันเดิล LGPL-2.1 Modest และ Apache-2.0 Lexbor ดังนั้น lxml จึงเล่าเรื่องได้สะอาดกว่าเมื่อจะนำไปอยู่ในผลิตภัณฑ์ปิด

ยังมีข้อดีด้านการติดตั้งในทางปฏิบัติด้วย: lxml เผยแพร่ wheel ที่ prebuilt และ link libxml2 กับ libxslt แบบ static ดังนั้นโดยทั่วไป pip install lxml จึงไม่ต้องพึ่ง system libxml2 และไม่ต้องมี compiler บนเครื่อง — ประสบการณ์ต่างจากการ build จากซอร์สพอสมควร

lxml เหมาะอยู่ตรงไหน — และชั้น AI extraction จะเข้ามารับช่วงไหน

ถึงเวลาต้องพูดให้ชัดเรื่องขอบเขต เพราะคนมักสับสนกันได้ง่าย lxml คือไลบรารีสำหรับ parsing มันส่ง tree ให้คุณพร้อม query engine ที่ยอดเยี่ยม แต่ทุกอย่างรอบ ๆ tree ยังเป็นงานของคุณอยู่: ดึงหน้าเว็บ, render JavaScript, ฝ่าระบบป้องกัน bot, เขียนและดูแล XPath และจัดโครงสร้างผลลัพธ์ นั่นคือคนละชั้นกับบริการ extraction แบบ hosted และทั้งสองไม่ใช่คู่แข่งกันเท่าไรนัก แต่เป็นเพื่อนบ้านกันมากกว่า

สำหรับนักพัฒนาที่ไม่อยากดูแล stack ตั้งแต่ fetch-render-select-maintain ชั้นที่สูงกว่านั้นคือที่ที่เครื่องมืออย่าง Thunderbit ทำงาน — และสำหรับกลุ่มนี้ ตัวที่สำคัญคือ API, MCP server และ CLI ไม่ใช่ browser extension นะ Thunderbit Open API เปิด POST /distill เพื่อแปลงหน้าเว็บให้เป็น Markdown ที่สะอาด และ POST /extract เพื่อดึงข้อมูลแบบมีโครงสร้างตาม JSON Schema พร้อมสวิตช์ renderMode และ batch job สำหรับงานจำนวนมาก เอ็นจินเดียวกันยังมีในรูป 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 ที่คมมากบน tree ที่คุณเข้าใจ เลือก AI extraction API เมื่อคุณไม่อยากดูแล selectors และ rendering เองเลย หลายระบบจริงใช้ทั้งสองอย่างร่วมกัน — lxml สำหรับ feed ที่เป็นโครงสร้างและคุณควบคุมได้ ส่วนบริการ extraction สำหรับหน้าเว็บที่ยุ่งและยาวหางที่คุณไม่ได้ควบคุม

สิ่งที่รีวิวนี้ไม่ได้ทดสอบ

นี่คือรีวิวแบบ provisional ไม่ใช่สกอร์การ์ดสุดท้าย ดังนั้นขอระบุสิ่งที่ยังไม่ครอบคลุม

ตัวเลขเวลาและหน่วยความจำทั้งหมดเป็นข้อมูลนำมาใช้ซ้ำจากแพลตฟอร์มเดียว (macOS arm64, Python 3.14) และรับข้อจำกัดจาก pack เดิม — ผลที่ว่า “lxml เร็วกว่าในการ parse ล้วน” ขัดกับความเห็นส่วนใหญ่และควรถูก recheck บน Linux x86_64 การเพิ่มความเร็วของ per-thread-parser ยังไม่ได้ทดสอบ (ต้องวัดใหม่) ผมวัด memory ของ iterparse บน 300k records แต่ยังไม่ได้วัด XML จริงระดับหลาย GB, ไม่ได้วัด iterparse บน HTML เทียบ XML และไม่ได้ทำ soak test หลายชั่วโมง XSLT 1.0 ของ lxml, การตรวจสอบ RelaxNG / XMLSchema / DTD และ EXSLT extensions ยังไม่ได้ทดสอบในที่นี้เลย — เป็นพื้นที่ความสามารถขนาดใหญ่ แต่เกินกว่าขอบเขตแกนหลักของ parsing และ selection ผมสังเกตเพดานความลึกที่สองที่ 2,045 แต่ยังไม่ได้ระบุค่าคงที่ recursion ที่แน่นอนของ libxml2 ทดสอบเฉพาะ stable 6.1.1 ไม่ได้ทดสอบ alpha 7.0.0 ยังไม่ได้ทดสอบ Windows, source build และ free-threaded 3.14t build และภายใน XPath เอง ผมครอบคลุม built-in functions แต่ยังไม่ได้แตะ XPath variables, custom Python extension functions หรือการ reuse ของ object etree.XPath ที่ precompiled

บทสรุป

lxml ไม่ใช่ของใหม่ที่เร็วที่สุด และนั่นแหละคือเหตุผลที่แนะนำมัน มันคือ binding ของ libxml2 ที่มีอายุสองทศวรรษ พร้อมเอ็นจิน XPath 1.0 แบบเต็มรูปแบบซึ่งไม่มีทางเลือกหลักใน Python ที่ทำได้เท่า, มีโหมดความเข้มงวดในการ parse สามระดับที่คาดเดาได้พร้อม error log ตรงกลาง, มี parser แบบสตรีมมิงจริงสำหรับเอกสารที่ไม่พอใส่ใน memory, รองรับหลาย namespace และ encoding อย่างถูกต้อง และมีไลเซนส์แบบ permissive เต็มร้อย จุดคม ๆ ไม่กี่จุด — เพดานความลึกราว 253 ชั้นและตัวเลข threading แบบ shared-parser — ล้วนมีเอกสาร, ปรับได้ และตอนนี้ก็อธิบายแล้ว

ถ้าคุณเป็นเจ้าของ scraping pipeline เองและพึ่ง XPath มาก lxml ก็ยังเป็น parser ที่คุณควรหยิบมาใช้ ถ้าคุณไม่อยากดูแล selectors และ rendering เอง นั่นคือหน้าที่ของชั้น AI extraction อย่าง Thunderbit API, MCP และ CLI — แบ่งงานกันชัด ๆ ไม่ใช่การแข่งขันกัน ไม่ว่าทางไหน ให้ถือว่าตัวเลขพวกนี้ยังเป็น provisional และควรตรวจเวลาใหม่บนแพลตฟอร์มของคุณก่อนอ้างอิงใน design doc

ลองใช้ Thunderbit สำหรับการดึงข้อมูลเว็บ Get Started Free

คำถามที่พบบ่อย

lxml เป็น web scraper ไหม? ไม่ใช่ lxml คือ parser และ serializer — Python binding ของ libxml2/libxslt ที่แปลง markup ให้เป็น tree ซึ่งแก้ไขและ query ได้ มันไม่ได้ดึงหน้าเว็บ, render JavaScript หรือจัดการระบบป้องกัน bot; คุณต้องจัดการชั้น request เอง (ผ่าน requests, httpx, headless browser หรือ scraping service) แล้วส่ง bytes ให้ lxml

ควรใช้ lxml แทน BeautifulSoup หรือ selectolax เมื่อไร? เลือก lxml เมื่อคุณต้องใช้ XPath จริง ๆ BeautifulSoup สามารถใช้ lxml เป็น backend parser ได้ แต่ไม่มี XPath โดยตรง ส่วน selectolax เป็น CSS-only และเร็วในขอบเขตที่แคบกว่า ถ้า logic การเลือกของคุณต้องกรองตามข้อความ, ไล่ขึ้น parent หรือ ancestor, ดึง attribute/text node, หรือใช้ predicate แบบนับจำนวน 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 แบบ multithread ไหม? ปล่อย แต่มีเงื่อนไข เอกสาร FAQ ของ lxml ระบุว่า GIL จะถูกปล่อยระหว่าง parsing ก็ต่อเมื่อแต่ละ thread ใช้ parser ของตัวเอง หรือใช้ default parser ที่ถูก copy แล้ว ถ้าใช้ parser ร่วมกัน การเข้าถึงจะถูก serialize แทน speedup แบบ 4 threads ที่วัดซ้ำได้ 1.21x ในข้อมูลที่นำมาใช้ซ้ำ จึงสะท้อนเส้นทาง shared-parser แบบ naïve ไม่ใช่เพดานของ per-thread-parser ซึ่งยังไม่ได้วัด

lxml ยังมีการดูแลอยู่ในปี 2026 ไหม? ใช่ รุ่น stable 6.1.1 ออกเมื่อ 2026-05-18, repo ถูก push ล่าสุดเมื่อ 2026-07-02 และมี alpha 7.0.0 อยู่ระหว่างพัฒนา ด้วย GitHub stars ราว 3,000 และ libxml2 ที่ยัง active อยู่ข้างใต้ มันยังเป็นไลบรารีที่ทันสมัยและได้รับการดูแลอยู่ ไม่ใช่ของเก่าเก็บ

Ke
Ke
CTO ที่ Thunderbit | นักวิทยาศาสตร์ข้อมูลอาวุโสและผู้เชี่ยวชาญด้านแมชชีนเลิร์นนิง ด้วยประสบการณ์เกือบสิบปีในด้านแมชชีนเลิร์นนิงและวิทยาศาสตร์ข้อมูล เคเฉินเป็นศิษย์เก่ามหาวิทยาลัยโคลัมเบีย และอดีตนักวิทยาศาสตร์ข้อมูลอาวุโสที่ Walmart Labs ด้วยความเชี่ยวชาญลึกซึ้งที่ได้รับการยอมรับจากเพื่อนร่วมสายงานใน Python, R, Java และสถิติ เขาจึงแบ่งปันมุมมองที่ผ่านการพิสูจน์มาแล้วในการพัฒนาอัลกอริทึม AI ที่ซับซ้อนจากแนวคิดไปสู่สถาปัตยกรรมระดับใช้งานจริง
Topics
Web Scraping ToolsAI Web Scraper
สารบัญ
Thunderbit · เอเจนต์ข้อมูลเว็บด้วย AI

ดึงข้อมูลจากทุกหน้าได้ใน คลิกเดียว

ได้รับความไว้วางใจจากผู้ใช้กว่า 250,000+ คน
มีแพ็กเกจใช้ฟรี
จากหน้าเว็บสู่สเปรดชีต
อธิบายสิ่งที่คุณต้องการ — AI Agent ของ Thunderbit จะดึงข้อมูลให้และส่งออกไปยัง Excel, Google Sheets, Airtable หรือ Notion เริ่มใช้ได้ฟรี
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week