BeautifulSoup คือไลบรารีที่แทบทุกคนมักหยิบมาใช้เป็นตัวแรก ๆ เวลาจะเริ่ม web scraping ด้วย Python และมันก็เป็น HTML parser สายจริงจังที่ช้าที่สุดอย่างไม่ต้องสงสัย ทั้งสองอย่างนี้เป็นเรื่องจริง และไม่ใช่ข้อเสียโดยตัวมันเอง จุดที่น่าสนใจกว่าคือคำว่า “ช้า” กลับกลายเป็นตัวเลขที่วัดได้ ชั่งได้ และเอาไปตัดสินใจซื้อได้จริง ไม่ใช่แค่ความรู้สึก
ผมนำ bs4 (นั่นคือ beautifulsoup4 เวอร์ชัน 4.15.0 ที่เปิดตัวในเดือนมิถุนายน 2026 ภายใต้สัญญาอนุญาต MIT) ไปทดสอบทั้งความสามารถแบบใหม่และข้อมูลเวลาเดิมจากชุด benchmark เดิม ผลที่ได้สอดคล้องกันชัดเจน: คุณกำลังแลกความเร็วที่ช้าลงประมาณเป็นสิบเท่า กับ API ที่ใช้ง่ายที่สุดและความทนทานต่อ HTML ที่เสียหายดีที่สุดในวงการนี้ ว่าการแลกนี้คุ้มไหม ขึ้นอยู่กับลักษณะงานของคุณล้วน ๆ ดังนั้นรีวิวนี้จะพูดถึงทั้งสองด้านไปพร้อมกัน
BeautifulSoup แท้จริงแล้วคืออะไร และไม่ใช่อะไร
บทความสอนส่วนใหญ่มักข้ามจุดสำคัญที่สุดไป: BeautifulSoup ไม่ได้ parse HTML เอง แต่มันเป็นตัวห่ออีกชั้นหนึ่ง ภายใต้ระบบของมันจะส่งเอกสารไปให้ parser จริง 1 ใน 3 ตัว ได้แก่ html.parser ที่มากับ Python, lxml หรือ html5lib จากนั้นจึงห่อโครงสร้างที่ได้ด้วย API สำหรับไล่ดูและค้นหาข้อมูลที่ใช้งานง่ายมาก หน้าที่ของ bs4 ไม่ใช่การ parse แต่คือทำให้ผลลัพธ์ที่ได้ “เดินดูง่าย” ขึ้น
ผู้สร้างเองเรียกมันว่าเป็น “screen-scraping library” และแนวคิดก็แทบไม่เปลี่ยนมาตลอด: เอา HTML ที่เละจนเบราว์เซอร์ยังอึ้งไปป้อนให้มัน แล้วมันก็ยังคงควักข้อมูลที่คุณต้องการออกมาได้ ชื่อเสียงนี้ไม่ได้มาแบบลอย ๆ มีข้อยกเว้นเล็ก ๆ อยู่หนึ่งข้อ ซึ่งเดี๋ยวจะพูดถึงต่อไป
ก่อนจะไปต่อ ขอปักหมุดข้อเท็จจริงพื้นฐานไว้ก่อน:
| ฟิลด์ | ค่า |
|---|---|
| แพ็กเกจ | beautifulsoup4 (นำเข้าเป็น bs4) |
| เวอร์ชันที่ทดสอบ | 4.15.0 (อัปโหลดเมื่อ 2026-06-07) |
| เวอร์ชัน Python ที่รองรับ | >=3.7.0 |
| ไลเซนส์ | MIT |
| หน้าโฮมหลัก | crummy.com/software/BeautifulSoup |
| ซอร์ส + tracker แจ้งบั๊ก | Launchpad — ไม่ใช่ GitHub |
| สถานะการดูแล | ยัง active (4.15.0 ในเดือนมิถุนายน 2026 มีการปล่อย 6 เวอร์ชันในปีที่ผ่านมา) |
ประโยค “ไม่ใช่ GitHub” สำคัญกว่าที่เห็น เพราะ bs4 เป็นไลบรารีอายุ 20 ปีที่อยู่บน crummy.com และ Launchpad ดังนั้นการเช็กสุขภาพด้วยจำนวนดาวใน GitHub จึงใช้ไม่ได้ ให้ดูจากความถี่ในการปล่อยเวอร์ชันแทน ซึ่งในมุมนี้ถือว่ายังแข็งแรงดี
มีอีกจุดที่คนดูแล compliance ควรรู้ไว้: ตัว wrapper เป็น MIT ก็จริง แต่สิ่งที่ “ใช้ bs4” แล้วถูกดึงเข้ามาใน dependency tree จะขึ้นอยู่กับ backend ที่คุณติดตั้ง html.parser คือส่วนหนึ่งของมาตรฐาน library ของ Python เอง (ไลเซนส์ PSF ไม่มี dependency เพิ่ม) ส่วน lxml เป็น BSD แต่พึ่งพา libxml2/libxslt ซึ่งเป็น C dependency ภายนอกที่ต้องคอมไพล์เองหรือใช้ wheel ที่ build มาแล้ว html5lib เป็น pure Python และใช้ MIT ถ้าคุณอยากได้ dependency footprint ที่สะอาดที่สุด ตัว built-in html.parser ให้ได้ แต่บังเอิญว่ามันก็เป็น backend ที่มีข้อเสียใหญ่ที่สุดด้วย เดี๋ยวจะเล่าต่อ
ต้นทุนด้านความเร็วที่วัดได้ชัดเจน
ขอวางตัวเลขไว้ตรง ๆ ก่อน เพราะนี่คือหัวข้อหลัก และการปิดบังมันจะไม่ซื่อสัตย์ สำหรับงานจริงแบบ parse แล้ว extract — parse สตริง แล้วดึง <h3 class="title"> ทุกตัวกับ <a href> ทุกตัวออกมา — BeautifulSoup คือ parser ที่ช้าที่สุดในกลุ่มนี้ และไม่ได้ช้ากว่าแบบเฉียด ๆ ด้วย

ตัวเลขเวลาเหล่านี้นำมาจาก benchmark rig ของ selectolax ชุดเดิม (เครื่องเดียวกัน วิธีรัน 3 ครั้งเหมือนกัน อัปเดต ณ 2026-07-13) รีวิวนี้ไม่ได้รัน benchmark เวลาใหม่เอง เพื่อหลีกเลี่ยงการชนกันของ CPU และการทำงานซ้ำซ้อน ค่า median p50 หน่วยมิลลิวินาที:
| ขนาดหน้าเว็บ | bs4 (html.parser) | bs4 (lxml) | selectolax-Lexbor | lxml | bs4-hp ช้ากว่า | bs4-lxml ช้ากว่า |
|---|---|---|---|---|---|---|
| 1 KB | 0.323 | 0.281 | 0.027 | 0.036 | 12.0x | 10.5x |
| 10 KB | 2.049 | 1.705 | 0.160 | 0.166 | 12.8x | 10.7x |
| 100 KB | 20.599 | 16.540 | 1.464 | 1.423 | 14.1x | 11.3x |
| 1 MB | 232.558 | 181.855 | 14.901 | 14.177 | 15.6x | 12.2x |
| 10 MB | 2788.746 | 2261.557 | 159.935 | 172.933 | 17.4x | 14.1x |
สรุปคือ bs4(html.parser) ช้ากว่า C parser อย่าง selectolax-Lexbor ราว 12–17 เท่า และแม้จะเปลี่ยนไปใช้ backend lxml ก็ยังเหลือความช้าระดับ 10.5–14 เท่า อยู่ดี คือยังห่างอยู่เป็นหนึ่ง order of magnitude สาเหตุเป็นเรื่องโครงสร้าง ไม่ใช่บั๊ก: ไม่ว่า backend ใดจะเป็นคน parse bs4 จะสร้างอ็อบเจ็กต์ Python เต็มรูปแบบ (Tag หรือ NavigableString) ให้ทุกโหนดทุกตัว ชั้นการ materialize อ็อบเจ็กต์นี้คือภาระที่ C parser ไม่ต้องจ่าย
สังเกตว่าตัวคูณยิ่งสูงขึ้นเมื่อหน้าใหญ่ขึ้น — 12.0x ที่ 1 KB และ 17.4x ที่ 10 MB นั่นบอกว่ามันไม่ใช่ overhead ตอนเริ่มต้นแบบคงที่ที่เฉลี่ยทิ้งได้ แต่มันคือภาษีต่อโหนดที่โตตามจำนวนโหนดแบบเส้นตรง
แต่ขอเปลี่ยนมุมมองนิดหนึ่ง เพราะคำว่า “ช้ากว่า 10 เท่า” ฟังดูน่ากลัวกว่าความจริง ในหน้า 1 MB นั้นคือ 232 ms เทียบกับ 15 ms ถ้างานของคุณคือ “ดึงข้อมูลจากหน้าเว็บไม่กี่ร้อยถึงไม่กี่พันหน้า ขนาดหน้าละไม่กี่ร้อย KB” ความต่างระดับนี้แทบไม่รู้สึกเลย — คุณจะไม่สะดุดกับมัน และการพยายาม optimize จุดนี้ก็แทบไม่ช่วยอะไร แต่ถ้างานของคุณคือ pipeline ที่ต้องวิ่งกับหนึ่งล้านหน้า ตัวเลขเดียวกันนี้จะกลายเป็นความแตกต่างระหว่างงานที่เสร็จกับงานที่ไม่เสร็จ ผลลัพธ์เป็นตัวเลขเดียวกัน แต่คำตัดสินตรงข้ามกัน สิ่งสำคัญคือต้องชั่งกับปริมาณงานจริงของคุณ ไม่ใช่ชั่งกับ benchmark
ไม่ใช่ เปลี่ยน backend แล้วจะหายช้า
มีความเชื่อผิด ๆ ว่าถ้าโยน backend lxml ให้ bs4 แล้วมันจะเร็วเท่า lxml ได้ คำตอบคือไม่ได้ และควรเข้าใจว่าทำไม สำหรับงาน CSS query แบบ batch ที่มี 100,000 โหนด (เลือกทุก <a> แล้วอ่าน href โดย tree ถูกสร้างไว้ก่อนแล้ว) ความเร็วต่างกันชัดมาก:
| Parser | Query p50 | Nodes/sec |
|---|---|---|
| lxml | 33.30 ms | 3,002,646 |
| selectolax-Modest | 34.19 ms | 2,924,550 |
| selectolax-Lexbor | 39.46 ms | 2,534,027 |
| bs4 (lxml) | 250.56 ms | 399,111 |
bs4(lxml) ทำได้ราว 399,000 โหนด/วินาที — ช้ากว่าระบบ C ทั้งสามตัวประมาณ 6.3–7.5 เท่า ทั้งที่ backend ของมันเองก็เป็น lxml อยู่แล้วก็ตาม backend ช่วยเร่งตอนสร้าง tree เท่านั้น แต่ตอน query และ traversal ยังผ่าน soupsieve เข้าสู่ Tag ของ bs4 อยู่ดี และทุกโหนดที่ match ก็ยังต้องถูกห่อเป็นอ็อบเจ็กต์ Python อยู่เหมือนเดิม ดังนั้นภาพจำว่า “ให้ bs4 ใช้ lxml แล้วมันจะเร็วแบบ lxml” จึงผิด backend ช่วยแค่บางช่วง และช่วงที่ช้าที่สุดไม่ใช่ช่วงนั้น
ต้นทุนด้านหน่วยความจำและ cold start ก็เป็นส่วนหนึ่งของราคาเช่นกัน บนเอกสาร 10 MB bs4 ใช้ resident memory ประมาณ 1.5–1.75 เท่า ของ selectolax หรือ lxml (218–226 MB เทียบกับ 129–145 MB) — สาเหตุเดิมคือหนึ่งโหนดต่อหนึ่งอ็อบเจ็กต์ Python และการ import bs4 ใช้เวลาประมาณ 33.4 ms เทียบกับ 14.1 ms ของ lxml.html จึงช้ากว่า 2.36 เท่า ตัวหลังนี้อาจเป็นเศษเสี้ยวเล็ก ๆ สำหรับโปรเซสที่รันนาน แต่ถ้าเป็น CLI หรือ serverless function ที่ cold start บ่อย ๆ นี่ก็เป็นต้นทุนจริงเล็ก ๆ ที่ควรรู้ไว้
ทำไมหลายเธรดก็ไม่ช่วย
ถ้าสัญชาตญาณของคุณเวลางาน CPU-bound ช้าคือ “งั้นโยน threads เข้าไปสิ” bs4 จะลงโทษสัญชาตญาณนั้น บนหน้า 1 MB ที่ถูก parse 48 ครั้ง เทียบระหว่าง single-thread กับ four threads:
| Parser | 1 thread | 4 threads | Speedup |
|---|---|---|---|
| selectolax-Lexbor | 0.563 s | 0.159 s | 3.54x |
| lxml | 0.459 s | 0.378 s | 1.21x |
| bs4 (lxml) | 6.945 s | 26.842 s | 0.26x |
อ่านแถวล่างสุดอีกที: ใช้ 4 threads แล้ว bs4 ช้าลงประมาณ 3.9 เท่า ไม่ได้เร็วขึ้นเลย สัญญาณจากข้อมูลจริงบอกว่า “น่าจะถือ GIL อยู่” เพราะการสร้าง tree ของ bs4 เป็น pure Python จึงไปรันพร้อมกันไม่ได้ภายใต้ Global Interpreter Lock พอเพิ่ม threads เข้าไปก็มีแต่ overhead ของการจัดตารางเวลามาซ้อนในงานที่จริง ๆ แล้วไม่ได้วิ่งขนานได้ ส่วน selectolax ที่เร็วขึ้นราว 3.5 เท่าก็เพราะ core ฝั่ง C ปลดล็อก GIL ให้ทำงานได้ bs4 ไม่มีช่องทางแบบนั้น
สำหรับยุค free-threading ข้อสรุปเชิงปฏิบัติคือ ถ้าคุณต้อง parallelize BeautifulSoup ให้ใช้ multiprocessing (ProcessPoolExecutor) ไม่ใช่ threads selectolax และ lxml สามารถ scale ด้วย threads ได้ แต่ bs4 ทำแบบนั้นไม่ได้ มีข้อควรระวังเรื่องความเข้มงวดในการสรุปด้วย: นี่เป็นการสังเกตหนึ่งครั้งที่ thread count เดียว (4) และหน้าเดียว (1 MB) ส่วนกลไก “ถือ GIL” เป็นข้อสันนิษฐานที่อนุมานจากเวลาที่วัดได้ ไม่ใช่สิ่งที่พิสูจน์ด้วยการ instrument code path ที่ถือ lock จริง ๆ แนวโน้มชัด แต่กลไกที่แน่นอนยังเป็นข้อสรุปชั่วคราว
ค่าเริ่มต้นของ backend คือกับดัก อ่านตรงนี้ก่อน
ถ้าจะเอาอะไรไปจากรีวิวนี้อย่างเดียว ขอให้เอาข้อนี้ไปก่อน BeautifulSoup(html) แบบไม่ส่งอาร์กิวเมนต์ตัวที่สอง จะใช้ html.parser และ html.parser ไม่ได้รองรับกฎ optional-end-tag ของ HTML5 ฟังดูเป็นเรื่องเชิงทฤษฎี จนกระทั่งมันทำข้อมูลคุณเสียแบบเงียบ ๆ

ผมทดสอบ HTML ที่เสียรูปแบบอย่างตั้งใจ 15 ตัวอย่างกับ backend ทั้ง 3 แบบ โดยตั้ง assertion เชิงโครงสร้างที่ไม่ผูกกับ backend ไว้ล่วงหน้าทุกตัวก่อนรัน (จะได้ไม่มีใครเลือกผู้ชนะทีหลังได้) ผลออกมาเป็นดังนี้:
| Backend | ตรงตามความคาดหวัง / 15 |
|---|---|
| lxml | 15 |
| html5lib | 15 |
| html.parser | 12 |
ความล้มเหลวทั้ง 3 กรณีมีต้นตอเดียวกัน ลองดู table ที่ปิดไม่ครบ: <table><tr><td>a<td>b<tr><td>c<td>d</table> เมื่อใช้ html.parser ข้อความที่ดึงออกมาจะเป็น ['abcd','bcd','cd','d'] — <td> แต่ละตัวกลืนทุกอย่างที่อยู่หลังมัน เพราะ parser ซ้อน cell เข้าไปแทนที่จะปิด cell ให้ถูกต้อง ส่วน lxml และ html5lib จะให้ ['a','b','c','d'] ถูกต้องเหมือนกัน รายการ <li> ที่ไม่ปิดก็เป็นแบบเดียวกัน: <li>a<li>b<li>c จะได้ ['abc','bc','c'] จาก html.parser แต่เป็น ['a','b','c'] แบบสะอาดจากอีกสองตัว Attribute ที่ซ้ำกันก็พลิกเหมือนกัน — <div id="first" id="second"> จะเก็บ "second" ใน html.parser แต่ lxml/html5lib จะเก็บ "first" ซึ่งตรงกับสเปก HTML5 ที่ให้เก็บตัวแรก
ทำไมเรื่องนี้ถึงอันตรายไม่ใช่แค่กวนใจ: เพราะมันเกิดขึ้น โดยไม่แจ้ง error สแครปเปอร์ที่เรียก BeautifulSoup(html) แบบลวก ๆ แล้วไปเจอ table หรือ list ที่ปิดไม่ครบ — ซึ่งพบได้บ่อยมากบนเว็บเก่า HTML ที่เขียนมือ หรือ template ที่ลืมปิดแท็ก — จะดึงข้อความจาก cell ข้างเคียงมาปนกัน ได้ข้อมูลสกปรกกลับมา และไม่เคยบ่นสักครั้ง วิธีแก้มีแค่อาร์กิวเมนต์เดียว: BeautifulSoup(html, "lxml") หรือ BeautifulSoup(html, "html5lib")
ถ้าจะให้ fairness กับ html.parser จริง ๆ อีก 12 จาก 15 ตัวอย่างที่เสียรูปแบบกลับได้ผลเหมือนกันทั้งสาม backend เช่น แท็กซ้อนผิดแบบ <b><i></b></i>, โครงสร้าง html/body ที่ขาดหาย, attribute ที่ไม่ใส่เครื่องหมายคำพูด, closing tag ที่ลอยเดี่ยว, comment ที่ปิดไม่ครบ, form ที่ซ้อนกัน, ตัวพิมพ์ใหญ่เล็กปนกัน และอื่น ๆ ความทนของ bs4 นั้นดีจริงในภาพรวม จุดที่ต่างกันกระจุกอยู่เกือบทั้งหมดในกลุ่ม optional-end-tag และทั้งหมดนี้ไม่ใช่เรื่องใหม่ — เอกสาร “Differences between parsers” ของ bs4 เองก็พูดตรง ๆ ว่า html.parser “ยืดหยุ่นน้อยกว่า” สิ่งที่ matrix ของ malformed HTML เพิ่มเข้ามาคือเคสเฉพาะที่ทำให้ “ยืดหยุ่นน้อยกว่า” กลายเป็นข้อมูลที่ผิดจริง
สิ่งที่คุณไม่ต้องยอมแลก: API และ CSS คือจุดเด่นที่สุด
ดังนั้น bs4 จะช้า เป็น single-thread และมีกับดักจาก backend ค่าเริ่มต้น แต่คนก็ยังหยิบมาใช้กันอยู่ดี เพราะอีกครึ่งของสิ่งที่แลกมัน “ใช้ง่าย” จริง ๆ และผลทดสอบก็ยืนยันแบบชัด ๆ

ผมทดสอบ API 29 จุด ครอบคลุม search, CSS, การเดิน tree, การดึงข้อความ และการแก้ไข DOM ทุกจุดผ่านหมด และผลแต่ละจุดคำนวณจากการเทียบค่าที่ได้จริงกับค่าที่คาดไว้ ไม่ใช่การดูด้วยตา ความสามารถ 2 อย่างต่อไปนี้คือเรื่อง ergonomics ที่ parser ฝั่ง C ไม่ได้ให้มา:
- ใช้ฟังก์ชันเป็น predicate ใน
find/find_allคุณสามารถเขียนsoup.find(lambda t: t.name == "a" and "btn" in t.get("class", []))เพื่อบอกเงื่อนไขซับซ้อนใน Python บรรทัดเดียวได้เลย ไม่ต้อง “เลือกทุกอย่างก่อนแล้วค่อยกรอง” สองจังหวะ - การไล่ tree แบบมีชื่อและเดินได้สองทาง
.parent,.next_sibling,.find_parent,.stripped_strings,.descendantsการเดินโครงสร้างอ่านเหมือนภาษาอังกฤษและย้อนกลับไปมาได้ selectolax ต้องใช้หลายขั้นกับบางกรณี หรือไม่รองรับเลย
นี่คือด้านที่ “ซื้อเวลาให้นักพัฒนา” อย่างชัดเจน และไม่ใช่คำโฆษณา เพราะผ่านไปแล้ว 29 เช็กสีเขียวจริง ๆ
มี 2 กับดักที่ควรรู้ไว้ เพราะรีวิวที่แฟร์ต้องพูดทั้งสองด้าน ข้อแรก boolean attribute: <input disabled> จะคืนค่าว่าง "" สำหรับ disabled ใน bs4 (ส่วน selectolax คืน None) ทั้งคู่ถือว่า falsy ดังนั้น if node.get("disabled") จะพลาด attribute แบบ boolean ที่มีอยู่จริงได้เงียบ ๆ ในทั้งสองไลบรารี วิธีเช็กที่ปลอดภัยคือ "disabled" in tag.attrs ข้อสอง get_text(strip=True) จะต่อข้อความจากหลายโหนดเข้าด้วยกันโดยไม่ใส่ตัวคั่นหลัง strip ดังนั้น "...with " + "link1" จะกลายเป็น "withlink1" ถ้าต้องการรักษาขอบเขตคำ ให้ส่ง separator=" " เข้าไป ตอนใช้ ทั้งสองจุดไม่ใช่ปัญหาเฉพาะของ bs4 แต่เป็นกับดักข้ามไลบรารี
และนี่คือส่วนที่หลายคนแปลกใจ: การเลือก bs4 ไม่ได้ทำให้คุณเสีย coverage ทาง CSS ไปเลย engine สำหรับ CSS ของมันคือ soupsieve ซึ่งเป็น implementation ที่สมบูรณ์ที่สุดในการเปรียบเทียบครั้งนี้ บน base matrix 41 กรณี (นำมาจาก rig ของ selectolax) soupsieve ได้ 41/41 ซึ่งเป็นคะแนนเต็มเพียงรายเดียวในสนามนี้ เหนือกว่า selectolax-Lexbor ที่ 39/41 และ cssselect (lxml/parsel) ที่ 37/41 จากนั้นผมยังทดสอบ extended cases อีก 20 กรณีที่เอกสารของ soupsieve ระบุไว้ และมันได้ 20/20 รวมถึง selectors ที่ Lexbor ปฏิเสธตรง ๆ เช่น :lang(en), :-soup-contains('featured') ที่มีเฉพาะใน soupsieve, :is(), :where() และ :has(> a) ช่องว่างจริง ๆ มีแค่ XPath (เพราะ soupsieve รองรับเฉพาะ CSS) และ pseudo-element ของ parsel อย่าง ::text / ::attr() ซึ่งเป็นส่วนขยายของ Scrapy ถ้าคุณอยู่กับ XPath การย้ายมาจะเจ็บพอสมควร
คำตัดสินของหัวข้อนี้ชัดมาก: สิ่งที่คุณยอมเสียเมื่อเลือก BeautifulSoup คือความเร็ว ไม่ใช่ความสะดวกในการเขียนโค้ด และก็ไม่ใช่ coverage ของ CSS แน่นอน
กับดักในงานโปรดักชัน 2 อย่างที่ควรเผื่องบไว้
นอกเหนือจาก backend ค่าเริ่มต้น ยังมีพฤติกรรมอีก 2 อย่างที่จะกัดคุณ โดยเฉพาะในงานที่รันนาน หรือกับข้อมูลที่ไม่ใช่ UTF-8
วงจรอ้างอิง: ใช้ decompose() ในลูปยาว ๆ
Tag ของ bs4 ทุกตัวจะถือ reference ไปยัง parent และ ลูกของมันเอง ทำให้เกิด reference cycle CPython ที่อาศัย reference counting อย่างเดียวจะเก็บวงจรนี้ไม่ได้ — นั่นเป็นหน้าที่ของ generational garbage collector เพื่อดูว่ามันสำคัญแค่ไหน ผมสร้างและลบ tree 300 รอบโดยปิด GC จากนั้นนับจำนวน Tag ที่ยังค้างอยู่ในหน่วยความจำ:

| สถานการณ์ | จำนวน Tags ที่ยังค้างหลัง del |
|---|---|
| ปิด GC | 120,900 (300 รอบ ไม่มีอะไรถูก reclaim) |
| เปิด GC | 26,598 (generational GC ทำงานระหว่างลูป) |
หลังสั่ง gc.collect() บังคับ | 0 (เก็บคืนหมด) |
| กลุ่มควบคุมที่ไม่มีวงจรอ้างอิง (list ของสตริง, ปิด GC) | delta 0 |
เมื่อปิด GC แล้ว del soup ไม่ได้ reclaim อะไรเลย — อ็อบเจ็กต์ทั้ง 120,900 ตัวค้างอยู่ในหน่วยความจำ เพราะ reference cycle ทำให้ reference counting ใช้งานไม่ได้ การเรียก gc.collect() เพียงครั้งเดียวก็เก็บคืนได้ทั้งหมด กลุ่มควบคุมที่ไม่มีวงจรอ้างอิง (list ธรรมดาของสตริง ซึ่งรู้แน่ว่าไม่มี cycle) มี delta เป็นศูนย์ พิสูจน์ได้ว่าการสะสมเกิดจากวงจรของ bs4 จริง ไม่ใช่ noise จากการวัด เอกสารของ bs4 เองก็ระบุไว้ว่าอ็อบเจ็กต์เหล่านี้ “เชื่อมโยงกันหนาแน่น ... ซึ่งเป็นประเภทที่ garbage collector จัดการยาก” ดังนั้นนี่คือพฤติกรรมที่มีเอกสารกำกับอยู่แล้ว สิ่งที่การทดสอบเพิ่มเข้ามาคือจำนวนอ็อบเจ็กต์ที่ค้าง และข้อพิสูจน์ว่า collect() เคลียร์ให้เป็นศูนย์
กฎใช้งานจริงคือ ถ้าคุณมี pipeline ที่ parse หน้าใหญ่จำนวนมากในลูปแคบ ๆ และโค้ดของคุณ (หรือการตั้งค่าบางอย่างที่ throughput สูง) ปิด GC หรือไม่เรียกมันบ่อยพอ tree ของ bs4 จะค้างและหน่วยความจำจะค่อย ๆ สูงขึ้น ให้เรียก soup.decompose() หลังจบแต่ละหน้า — bs4 มีเมธอดนี้มาเพื่อทำลายวงจรและคืนหน่วยความจำเร็วขึ้นโดยตรง ส่วน tree ฝั่ง C ของ selectolax และ lxml ไม่มีปัญหานี้เลย
Encoding: UnicodeDammit คือข้อได้เปรียบเงียบ ๆ ของ bs4
bs4 มีคอมโพเนนต์ที่ parser เร็ว ๆ ไม่มี นั่นคือ UnicodeDammit ซึ่งสแกน encoding ของเอกสารและแปลงเป็น Unicode ให้อัตโนมัติ ผมให้มันเจอ matrix 8 กรณีแบบ “encoding ที่ประกาศไว้ vs encoding จริง”:

| กรณี | encoding จริง | UnicodeDammit เดาได้ว่า | กู้คืนได้ไหม |
|---|---|---|---|
| utf8_no_decl | utf-8 | utf-8 | ได้ |
| utf16_bom | utf-16 | utf-16le | ได้ |
| gbk_chinese | gbk | gb18030 | ได้ (เป็น superset) |
| shiftjis | shift_jis | cp932 | ได้ (เป็น superset) |
| latin1_declared_utf8 | latin-1 (ประกาศว่า utf-8) | iso-8859-1 | ได้ (มองข้ามคำโกหก) |
| latin1_no_decl | latin-1 | cp720 | ไม่ได้ |
| cp1252_no_decl | cp1252 | cp862 | ไม่ได้ |
| utf8_declared_latin1 | utf-8 (ประกาศว่า latin-1) | iso-8859-1 | ไม่ได้ (เชื่อตามที่ประกาศ) |
จาก 8 กรณี กู้คืนได้ 5 กรณี UTF-8, UTF-16 ที่มี BOM, GBK, Shift-JIS และแม้แต่ latin-1 ที่ถูกติดป้ายผิดก็กลับมาได้ถูกต้อง โดยคำเดาแบบ superset (GBK→gb18030, Shift-JIS→cp932) ก็ยังถอดได้ไม่มีปัญหา ส่วนสองโหมดที่ล้มเหลวควรรู้ไว้: byte sample สั้น ๆ ของ latin-1/cp1252 มักถูกเข้าใจผิดเป็น DOS code page เพราะตัวตรวจจับเชิงสถิติไม่น่าเชื่อถือบนข้อมูลสั้น และ glyph แบบกล่องจาก DOS ไปซ้อนทับกับ code point ของ Latin-1; อีกกรณีคือเมื่อ <meta charset> ประกาศผิด UnicodeDammit จะเชื่อตามที่ประกาศไว้ เอกสารของ bs4 ก็เตือนไว้ทั้งสองอย่างว่า sample บางตัว “สั้นเกินกว่าที่ Unicode, Dammit จะจับล็อกได้” และข้อมูลที่มากขึ้นจะช่วยให้เดาได้ดีขึ้น
เมื่อเทียบกับ selectolax ที่จะปล่อยให้ bytes ที่ไม่ใช่ UTF-8 เสียหายเงียบ ๆ แล้วคาดหวังให้คุณ decode เอง ข้อนี้ถือเป็นข้อได้เปรียบจริงของ bs4 อย่างน้อยมัน “พยายามเดา” และมักเดาถูก แต่ไม่ใช่การรับประกัน ถ้ารู้ encoding อยู่แล้ว อย่าให้มันเดาเอง ให้ระบุชัดไปเลย: BeautifulSoup(bytes, from_encoding="...")
จริง ๆ แล้ว backend ต่างกันบนหน้าเว็บจริงไหม
matrix ของ malformed แสดงว่า backend ต่างกันเมื่อเจอ input ที่เสียรูปแบบโดยตั้งใจ คำถามต่อไปที่ชัดเจนคือ แล้วมันสำคัญในโลกจริงไหม ดังนั้นผมจึงรัน backend ทั้ง 3 แบบกับหน้าเว็บจริงที่ดึงมา 11 หน้า — BBC, Wikipedia, Craigslist, MDN, old.reddit, เอกสาร Python, Hacker News, Books to Scrape, webscraper.io, whitehouse.gov และหน้า quotes ที่เรนเดอร์ด้วย JS — โดยเทียบจำนวนลิงก์ หัวเรื่อง และรูปภาพ
ผลคือทั้ง 3 แบบเห็นตรงกันทั้งหมดในทั้ง 11 หน้า ไม่มีความต่างเลย ซึ่งหมายความว่าความเห็นไม่ตรงกันจากส่วนกับดักจะโผล่เฉพาะกับ HTML ที่เสียรูปแบบอย่างตั้งใจเท่านั้น; เมื่อเว็บไซต์ production สมัยใหม่มีโครงสร้างดีพอ — แม้จะเป็นเว็บที่ “รก” ก็ตาม — การเลือก backend ก็ไม่เปลี่ยนสิ่งที่คุณดึงออกมาได้ มุมมองเชิงปฏิบัติคือ สำหรับเว็บกระแสหลักที่มีโครงสร้างดี html.parser ใช้ได้สบายและไม่ต้องพึ่ง dependency เพิ่ม จะต้องเปลี่ยนไปใช้ lxml หรือ html5lib ก็ต่อเมื่อคุณกำลังสแครป HTML ที่เก่า เขียนมือ หรือผิดมาตรฐานอย่างเห็นได้ชัดเท่านั้น
มีข้อสังเกตเล็ก ๆ จากการรันนั้นด้วย เพราะเป็น edge case จริง หน้า MDN มี <template> และ backend ของ bs4 ทุกตัวคืนลิงก์ 508 ตัว — หมายความว่า bs4 จะ flatten เนื้อหาใน <template> เข้าสู่ tree หลัก ซึ่งทำให้ bs4 อยู่ฝั่งเดียวกับ lxml และตรงข้ามกับ selectolax-Lexbor ที่ทำตามสเปก HTML5 อย่างเคร่งครัด (<template> คือ DocumentFragment ที่ไม่มีผล) จึงคืน 497 และทำให้ลิงก์ 11 ตัวภายใน template หายไปแบบเงียบ ๆ ดังนั้น bs4 จะจับข้อมูลใน <template> ได้ — เป็นประโยชน์ แต่ก็อาจทำให้คุณเก็บ “เนื้อหาผี” ที่เบราว์เซอร์ไม่มีวันแสดงออกมา ไม่ได้หมายความว่าฝั่งใดผิด เพียงแต่ตีความสเปกต่างกัน และคุณควรรู้ว่ากำลังได้แบบไหน
BeautifulSoup เหมาะกับตรงไหน และไม่เหมาะกับตรงไหน
แทนที่จะยัดทุกอย่างลงคะแนน 0–100 เดียวซึ่งจะซ่อน trade-off ที่สำคัญไป นี่คือ scorecard แยกตามมิติ พร้อมข้อควรระวังของแต่ละแถว:
| มิติ | สิ่งที่การทดสอบพบ | ข้อควรระวัง |
|---|---|---|
| ติดตั้ง / เริ่มใช้งานครั้งแรก | เป็น wrapper ล้วน ไม่ต้องตั้งค่าเบราว์เซอร์; html.parser ไม่ต้องมี dependency; มี wheel สำเร็จรูป | backend lxml ต้องมี C dependency |
| ความเร็วเทียบกับ C parser | ช้ากว่า 12–17x (html.parser) / 10.5–14x (lxml backend) ทุกขนาด | ใช้ rig เดียว และเป็นข้อมูล selectolax ที่นำกลับมาใช้ |
| Throughput ของ CSS query | ช้ากว่าราว 6–7.5x บน 100k โหนด; backend lxml ไม่ได้ช่วยตรงนี้ | ข้อมูลนำกลับมาใช้; จ่ายภาษี Python Tag |
| หน่วยความจำ | ใช้ 1.5–1.75x ของ selectolax/lxml; หนักที่สุด | ใช้ข้อมูลนำกลับมาใช้; วัดด้วย RSS |
| Cold start ตอน import | ช้ากว่า 2.36x (33.4 เทียบกับ 14.1 ms) | ข้อมูลนำกลับมาใช้; รายการเล็ก |
| การสเกลด้วย threads | bs4-lxml ช้าลง ~3.9x ที่ 4 threads (ถือ GIL) | สังเกตครั้งเดียว; ควรใช้ multiprocessing |
| ความสะดวกของ API | ผ่าน 29/29 probes; function-predicate find + navigation สองทาง | กับดัก boolean attr และ strip ขอบเขตคำ |
| CSS coverage | soupsieve ดีที่สุด: 41/41 base + 20/20 extended; รองรับ :lang | ไม่มี XPath, ไม่มี ::text |
| ความทนของ 3 backend | lxml/html5lib 15/15; html.parser 12/15 | ต่างกันเฉพาะ HTML ที่เสียรูปแบบ |
| ความสอดคล้องบนหน้าเว็บจริง | backend ทั้ง 3 ตรงกัน 11/11; ทุกตัว flatten <template> (508) | เว็บที่โครงสร้างดี: backend ไม่สำคัญ |
| GC ของ reference cycle | tree มีวงจร; 300 รอบค้าง 120,900 objects, collect เคลียร์เป็นศูนย์ | ลูปยาว ๆ ต้องใช้ decompose() |
| Encoding | UnicodeDammit กู้ได้ 5/8; เดาผิดกับ sample สั้น และเชื่อตาม declaration ที่ผิด | สังเกตครั้งเดียว |
| การดูแลรักษา | ยัง active (4.15.0, มิถุนายน 2026); MIT | โฮมอยู่บน crummy/Launchpad ไม่ใช่ GitHub |
แล้ว BeautifulSoup เหมาะกับใคร? คนที่ให้ค่ากับ API อ่านง่ายและการ parse ที่ยืดหยุ่น มากกว่าความเร็วล้วน ๆ โดยเฉพาะงานปริมาณปานกลาง เช่น prototype, การสแครปแบบครั้งคราว, เครื่องมือภายใน, หรือทีมที่ต้นทุนเวลา developer แพงกว่าเวลารัน ใครควรไปทางอื่น? pipeline ระดับล้านหน้า ที่ความช้าทวีต้นทุนเป็นเงินจริง งานที่ต้อง parallel ระดับ thread และคนที่ผูกกับ XPath แน่น ๆ
ขอเสริมตรงตำแหน่งของมันในสแตกการสแครปจริง และตำแหน่งที่เครื่องมือของเราเข้ามา BeautifulSoup สมมติว่าคุณ “มี HTML อยู่แล้ว” มันไม่ fetch หน้าเว็บ ไม่ render JavaScript และไม่จัดการ anti-bot หรือ CAPTCHA ใด ๆ เลย — นั่นเป็นงานอีกชั้นหนึ่ง และเป็นงานที่ยากจริงบนเว็บสมัยใหม่ นี่คือจุดที่ AI scraping API อยู่คนละเลเยอร์: developer stack ของ Thunderbit — ทั้ง REST API, MCP server และ CLI — จะจัดการการ fetch, การ render JS และปัญหา anti-bot แล้วส่งกลับมาเป็น Markdown สะอาด (POST /distill) หรือ JSON แบบมีโครงสร้างตาม schema (POST /extract) โดยไม่ต้องเขียน selector เองเลย ทั้งสองอย่างไม่ใช่คู่แข่ง แต่เป็นของที่เสริมกัน bs4 ใช้ parse HTML ที่คุณมีอยู่แล้ว; API, MCP และ CLI ของ Thunderbit ช่วยเอา HTML ที่คุณเข้าถึงไม่ได้ง่าย ๆ มาให้ ถ้าคอขวดของคุณคือการ parse bs4 คือคำตอบที่ใช้ได้ แต่ถ้าคอขวดคือการ เข้าถึงข้อมูล นั่นคืออีกชั้นหนึ่ง
ลองใช้ Thunderbit เพื่อดึงข้อมูลเว็บ
สรุปสั้น ๆ
BeautifulSoup ให้คุณได้ API ที่ใช้ง่ายที่สุด ความทนทานต่อ HTML ที่เสียรูปแบบดีที่สุด และ engine CSS ที่ครบที่สุดในการเปรียบเทียบนี้ — แต่ต้องแลกด้วยต้นทุนความเร็วประมาณหนึ่ง order of magnitude และการกินหน่วยความจำที่หนักที่สุด นั่นคือข้อแลกเปลี่ยนทั้งหมดแบบตรงไปตรงมา backend html.parser ที่เป็นค่าเริ่มต้นคือกับดักตัวจริงเพียงข้อเดียว: มันทำให้ table และ list ที่ปิดไม่ครบเพี้ยนแบบเงียบ ๆ ดังนั้นถ้าข้อมูลของคุณอาจจะสกปรก ให้ส่ง "lxml" หรือ "html5lib" ไปเสมอ Threads จะไม่ช่วยให้เร็วขึ้น — multiprocessing ต่างหากที่ช่วยได้ และในลูปยาว ๆ ให้เรียก decompose() ทุกหน้าเพื่อกัน reference cycle สะสม
มีข้อจำกัดสองข้อที่ควรปิดท้ายไว้ อย่างแรก ทุกอย่างที่นี่วัดบนแพลตฟอร์มเดียว (macOS arm64, Python 3.14, wheel สำเร็จรูป) และตัวคูณเวลาเป็นข้อมูลที่นำมาจาก selectolax rig เดิม (bench เดิม อัปเดต ณ 2026-07-13) ไม่ได้รันใหม่ จึงรับข้อจำกัดของแพลตฟอร์มเดียวนั้นมาด้วย และเครื่อง Linux x86_64 หรือ setup ที่คอมไพล์จากซอร์สอาจทำให้ตัวเลขเปลี่ยนไปได้บ้าง อย่างที่สอง ไม่มีอะไรในผลลัพธ์ชุดนี้เป็นการค้นพบใหม่ bs4 เป็นไลบรารีอายุ 20 ปี ดังนั้นพฤติกรรมทุกอย่างที่ทดสอบล้วนมีเอกสารหรือมีบันทึกสาธารณะอยู่แล้ว คุณค่าของบทความนี้ไม่ใช่การเปิดโปงข้อมูลใหม่ แต่คือการใส่ตัวเลขจริงลงไปบน trade-off ที่เอกสารเดิมอธิบายไว้แค่เชิงคุณภาพ
คำถามที่พบบ่อย
BeautifulSoup ช้าไหม?
ช้าอย่างมีนัยสำคัญครับ/ค่ะ บนงาน parse พร้อม extract มันช้ากว่า C parser อย่าง selectolax-Lexbor ประมาณ 12–17 เท่าเมื่อใช้ backend เริ่มต้น html.parser และช้ากว่า 10.5–14 เท่าเมื่อใช้ backend lxml เพราะมันสร้างอ็อบเจ็กต์ Python ให้ทุกโหนด ความสำคัญของเรื่องนี้ขึ้นอยู่กับขนาดงาน: บนหน้า 1 MB คือ 232 ms เทียบกับ 15 ms ซึ่งแทบไม่รู้สึกสำหรับไม่กี่พันหน้า แต่มีความหมายมากสำหรับ pipeline ระดับล้านหน้า
ควรใช้ parser ตัวไหนของ BeautifulSoup — html.parser, lxml หรือ html5lib?
ถ้าเป็นเว็บไซต์ทั่วไปที่โครงสร้างดี html.parser ใช้ได้และไม่เพิ่ม dependency แต่ข้อเสียคือมันไม่รองรับ optional-end-tags ของ HTML5 ดังนั้นบน table หรือ list ที่ปิดไม่ครบ มันจะกลืนข้อความข้างเคียงมาปนกันโดยไม่แจ้ง error ถ้า input ของคุณอาจเสียรูปแบบ เขียนมือ หรือเก่า ให้ส่ง "lxml" หรือ "html5lib" แบบชัดเจน — ทั้งสองตัวได้คะแนนเต็ม 15/15 บน malformed-HTML matrix ในขณะที่ html.parser ได้ 12/15
BeautifulSoup ประมวลผลแบบขนานด้วย threads ได้ไหม?
ไม่ได้ครับ/ค่ะ การสร้าง tree ของ bs4 เป็น pure Python และถือ GIL อยู่ ดังนั้นการเพิ่ม threads จะทำให้ช้าลง ไม่ใช่เร็วขึ้น — ในการทดสอบ 4 threads ใช้เวลามากกว่า 1 thread ราว 3.9 เท่าในการ parse หน้า 1 MB ถ้าจะ parallelize bs4 ให้ใช้ multiprocessing (ProcessPoolExecutor) ส่วนไลบรารีที่มี C core อย่าง selectolax และ lxml ต่างหากที่ได้ประโยชน์จากระดับ thread
BeautifulSoup รับมือ HTML ที่เสียหายได้ดีไหม?
โดยรวมดีมาก — จากตัวอย่าง HTML ที่เสียรูปแบบหลายแบบ (แท็กซ้อนผิด โครงสร้างขาดหาย attribute ไม่ใส่เครื่องหมายคำพูด และอื่น ๆ) backend ทั้ง 3 ตัวกู้กลับมาได้เรียบร้อย จุดอ่อนหลักคือ html.parser แบบค่าเริ่มต้นกับ optional-end-tags: <td>/<li> ที่ปิดไม่ครบจะถูกซ้อนแทนที่จะถูกปิด ทำให้ข้อความที่ดึงออกมาเพี้ยน เปลี่ยนไปใช้ backend lxml หรือ html5lib แล้วปัญหากลุ่มนี้จะหายไป
BeautifulSoup กับ lxml อะไรดีกว่ากัน?
มันเป็นคนละเครื่องมือกันครับ/ค่ะ lxml เร็วกว่ามากทั้งตอนสร้าง tree และตอน query และยังรองรับ XPath ด้วย ส่วน BeautifulSoup เอา lxml (รวมถึงตัวอื่น) มาห่อด้วย API ที่ใช้ง่ายกว่ามาก และจริง ๆ แล้วมี CSS coverage กว้างกว่าด้วย soupsieve แต่ไม่ควรคาดหวังว่าแค่เลือก backend lxml แล้ว bs4 จะเร็วเท่า lxml — backend ช่วยแค่เร่งช่วง parse ในขณะที่ query และ traversal ยังต้องจ่ายต้นทุนอ็อบเจ็กต์ Python ต่อโหนดของ bs4 อยู่ ทำให้ยังช้ากว่าประมาณ 6–7.5 เท่าบนงานเลือกชุดใหญ่
ลองใช้ Thunderbit เพื่อดึงข้อมูลเว็บ Get Started Free


