รีวิว Mozilla Readability: เก็บเนื้อหาได้แม่นยำ แต่มีปัญหาเรื่อง sibling leakage และ false negative ของตัวคาดการณ์

อัปเดตล่าสุดเมื่อ August 17, 2026
รีวิว Mozilla Readability: เก็บเนื้อหาได้แม่นยำ แต่มีปัญหาเรื่อง sibling leakage และ false negative ของตัวคาดการณ์
สรุปด้วย AI
Mozilla's Readability คือ JavaScript เวอร์ชัน standalone ของตัวดึงเนื้อหาที่อยู่เบื้องหลัง Firefox's Reader View เผยแพร่ในรูปแบบแพ็กเกจ Apache-2.0 ชื่อ @mozilla/readability โดยมันจะเลือกเนื้อหาบทความจาก DOM ที่มีอยู่จริง ถ้าใช้บน Node ก็จำเป็นต้องมีตัวจัดการ DOM อย่าง jsdom มาช่วย มันไม่ได้ดึงหน้าเว็บ ไม่ได้รัน JavaScript ของหน้า และไม่ได้ทำ schema extraction เวอร์ชันที่ทดสอบคือ npm latest ที่ 0.6.0 ซึ่งเผยแพร่เมื่อ 3 มีนาคม 2025 และตอนที่เช็กเมื่อ 27 กรกฎาคม 2026 รีโพนี้มี 11,361 ดาว (และถูก push ล่าสุดเมื่อ 9 กรกฎาคม 2026 ดังนั้น main จึงล้ำหน้าแพ็กเกจที่ปล่อยออกมาไปมาก)

Mozilla's Readability คือเวอร์ชัน JavaScript แบบ standalone ของตัวดึงเนื้อหาที่อยู่เบื้องหลัง Firefox's Reader View เผยแพร่ในรูปแบบแพ็กเกจ Apache-2.0 ชื่อ @mozilla/readability โดยมันจะเลือกเนื้อหาบทความจาก DOM ที่มีอยู่จริง ถ้าใช้บน Node ก็จำเป็นต้องมีตัวจัดการ DOM อย่าง jsdom มาช่วย มันไม่ได้ดึงหน้าเว็บ ไม่ได้รัน JavaScript ของหน้า และไม่ได้ทำ schema extraction

เวอร์ชันที่ทดสอบคือ npm latest ที่ 0.6.0 ซึ่งเผยแพร่เมื่อ 3 มีนาคม 2025 และตอนที่เช็กเมื่อ 27 กรกฎาคม 2026 รีโพนี้มี 11,361 ดาว (และถูก push ล่าสุดเมื่อ 9 กรกฎาคม 2026 ดังนั้น main จึงล้ำหน้าแพ็กเกจที่ปล่อยออกมาไปมาก) ฉันทดสอบมันบน jsdom 29.1.1 บน Node v22.22.3 บน macOS arm64 กับชุดไฟล์ HTML ที่มีป้ายกำกับ 22 ไฟล์ ซึ่งสร้างขึ้นมาเพื่อการทดลองนี้โดยเฉพาะ และทุกตัวเลขในบทความนี้มาจากชุดสภาพแวดล้อมดังกล่าวล้วน ๆ ในเชิงการใช้งานจริง นี่เป็นเครื่องมือที่เบาที่สุดตัวหนึ่งในกลุ่มนี้: ติดตั้งใช้เวลาไม่ถึงสองนาที ไม่ต้องใช้ binary ไม่มี browser ค้างใน cache และผลลัพธ์เหมือนเดิมทุกครั้งที่รัน สิ่งที่น่าสนใจของมันไม่ใช่การใช้งาน แต่คือความล้มเหลวของมัน “คาดเดาได้” จากค่าคงที่ไม่กี่ตัวในซอร์ส และหนึ่งในค่าคงที่นั้นมีอิทธิพลมากกว่าที่เอกสารยอมบอกไว้

จากชุด synthetic fixtures ที่ควบคุมได้ 22 ไฟล์ Readability ดึงบล็อกบทความที่ติดป้ายไว้กลับมาได้ครบทั้ง 74 บล็อก ผลลัพธ์นี้แม้จะถูกจำกัดอยู่ในกรอบทดสอบ ไม่ได้แปลว่ามันจะไม่เคยพลาดเนื้อหาบทความในโลกจริง: benchmark บนหน้าเว็บจริงสาธารณะแสดง recall ที่ 0.982 และแพตเทิร์นปัญหาที่รู้กันก็ไม่ได้เกิดซ้ำในการทดสอบนี้ จุดที่เห็นชัดที่สุดคือ sibling content ส่วนเกินถูกพ่วงเข้ามาเพราะกฎ link density ระดับซอร์สที่ 0.25 และความรุนแรงของมันก็เปลี่ยนไปตามชุดทดสอบที่ใช้

จริง ๆ แล้ว readability.js คืออะไร และไม่ใช่อะไรบ้าง

Readability คือ การให้คะแนนแบบ rule-based บน DOM มันจะไล่ดู element ที่มีแนวโน้มเป็นเนื้อหา ให้คะแนนคอนเทนต์แต่ละตัว แล้วส่งคะแนนขึ้นไปยัง ancestor จากนั้นเลือก subtree ที่ได้คะแนนสูงสุด ก่อนจะทำความสะอาดเพื่อลบสิ่งที่ดูเหมือนองค์ประกอบประกอบหน้าทิ้ง กระบวนการดึงบทความของมันมีแค่นี้จริง ๆ — ไม่มีโมเดล ไม่มี training data และไม่มีกฎเฉพาะรายเว็บ นี่แหละคือเหตุผลที่มันใช้งานได้แม้กับหน้าที่ไม่เคยเห็นมาก่อน และก็เป็นเหตุผลเดียวกันที่ความล้มเหลวของมัน “เดาได้จากซอร์ส” ซึ่งเป็นส่วนที่สนุก

มีอยู่ 3 เรื่องที่มันไม่ใช่ และสิ่งเหล่านี้ทำให้คนสับสนบ่อยมาก:

  • ไม่ใช่ตัว fetch หน้าเว็บ มันรับ document ไม่ใช่ URL การดึงหน้า การ retry การรับมือ bot และ headers เป็นหน้าที่ของคุณ
  • ไม่ใช่ renderer ไม่มีการรัน JavaScript สิ่งที่คุณส่งให้มันเป็น DOM แบบไหน มันก็เห็นแค่นั้น
  • ไม่ใช่ตัว extract แบบมีโครงสร้าง คุณจะได้ title, byline, excerpt, content (HTML), textContent, length, siteName แต่ไม่ได้ schema ไม่ได้แถวข้อมูลแบบ typed และไม่ได้ {name, price}

ค่าคงที่ 4 ตัวคือแกนหลักของการทำงาน

System diagram: Four constants do most of the work

แค่เปิดดู Readability.js ใน node_modules ก็จะเข้าใจพฤติกรรมของมันได้มากกว่าอ่านหน้า docs หลายเท่า กลไกหลัก 4 อย่างอธิบายพฤติกรรมส่วนใหญ่ของไลบรารีนี้ได้:

  • คะแนนคอนเทนต์ต่อย่อหน้าที่ถูกนับคะแนน: 1 + (commaCount + 1) + min(floor(len / 100), 3) ย่อหน้าที่สั้นกว่า 25 ตัวอักษรจะไม่ถูกนับเลย คะแนนจะถูกส่งต่อไปยัง ancestor แบบมีตัวหาร — parent ได้คะแนนเต็ม ปู่ย่าตายายได้ครึ่งหนึ่ง และ ancestor ที่ลึกกว่านั้นใช้ level · 3
  • DEFAULT_CHAR_THRESHOLD = 500 — ความยาวบทความขั้นต่ำสำหรับการ parse แบบ “สำเร็จ” ถ้าต่ำกว่านี้ ระบบจะรันการกวาดใหม่อีกครั้งโดยตัด cleanup flags ออกบางส่วน
  • regex ของ unlikelyCandidates — จะ match class และ id ที่มี substring อย่าง comment, footer, menu, related, sidebar, social, sponsor โหนดที่ match จะถูกตัดทิ้งก่อนเริ่มให้คะแนน
  • กฎพ่วง sibling ใน grabArticle — หลังเลือก top candidate แล้ว ระบบจะพิจารณา siblings ของมันเพื่อรวมเข้ามาด้วย sibling จะถูกพ่วงถ้า score ของตัวเองสูงพอ, หรือ nodeLength > 80 && linkDensity < 0.25, หรือ nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && มีจุดประโยคอยู่

link density คือ Σ(linkText.length · coef) / textLength โดย coef = 0.3 สำหรับ href เปล่า ๆ ที่เป็น # และเป็น 1 สำหรับอย่างอื่น กฎนี้อธิบาย sibling leakage ที่วัดได้ในงานนี้ได้อย่างดี แต่มันไม่ใช่อัลกอริทึมดึงเนื้อหาทั้งหมด

การตั้งค่า และ dependency ที่ไม่ได้อยู่ในคำโปรย

แค่ npm install @mozilla/readability jsdom ก็ใช้งานได้แล้ว ใช้เวลาไม่ถึงสองนาที ไม่มี binary ไม่มีการดาวน์โหลดหลังติดตั้ง ไม่มี browser ค้างอยู่ใน cache ในมิติของการติดตั้ง นี่แทบจะสมบูรณ์แบบ

แต่คำว่า “ไม่มี dependency” นั้นหมายถึงตัว อัลกอริทึม ไม่ใช่ runtime Readability ทำงานบน document ที่มีชีวิตจริง และถ้าอยู่บน Node คุณต้องจัดหา DOM implementation เอง — ในที่นี้คือ jsdom เวอร์ชัน 29.1.1 jsdom ไม่ได้เล็ก และสำหรับ pipeline ส่วนใหญ่ มันคือค่าใช้จ่ายหลักในลูป ไม่ใช่ตัว extraction ควรเผื่องบไว้ตรงนี้

มีอีกหนึ่ง footgun ที่ทำให้ฉันต้องรันใหม่: Readability.parse() จะ mutate DOM ที่ส่งเข้าไป ถ้าคุณ parse jsdom document เดิมซ้ำรอบที่สอง มันจะเห็นเอกสารที่รอบแรกกวาดไปแล้ว ทุกครั้งที่ parse ใน harness นี้ ฉันจึงสร้าง jsdom ใหม่เสมอ ถ้าคุณกำลังวนลูปหลายหน้าแล้วพยายาม reuse document object เพื่อประหยัดเวลา นั่นคือบั๊กที่คุณกำลังจะเจอ

วิธีที่ฉันทดสอบ

ฉันไม่ได้เอามันไปยิงกับเว็บข่าวจริง เพราะหน้าเว็บจริงให้คะแนนมาโดยไม่บอกเหตุผลว่า “ทำไม” ซึ่งสำหรับ heuristic แล้วคำว่า “ทำไม” นั่นแหละคือคุณค่าหลักของมัน ดังนั้นฉันจึงสร้าง 22 HTML fixtures ที่มี 91 บล็อกติดป้ายกำกับ (74 article, 17 boilerplate) โดยทุกคำในแต่ละบล็อกจะถูกเติมด้วย sentinel string เฉพาะของบล็อกนั้น ๆ คำศัพท์ของแต่ละบล็อกไม่ทับกัน ดังนั้น token ที่ดึงออกมาจะชี้กลับไปยังบล็อกเดียวแบบแน่นอน และคำว่า “recovered” หรือ “leaked” จึงเป็นการเช็ก membership แบบตรงตัว ไม่ใช่การ match แบบคลุมเครือ

ขั้นตอน extraction กับขั้นตอน scoring ถูกแยกจากกันอย่างตั้งใจ ตัวรัน Node จะปล่อยออกมาแค่ raw extracted text, ค่า boolean ของ isProbablyReaderable และค่า link density ที่วัดได้ ส่วน precision และ recall ทั้งหมดจะคำนวณทีหลังจาก raw text เทียบกับ label โดยสคริปต์แยกต่างหาก ไม่มีการเขียนค่าคงที่ของ metric ด้วยมือใน harness เลย นั่นเป็นวิธีเดียวที่ฉันเชื่อในตัวเลขของตัวเอง

จากนั้นฉันก็ป้อน ไบต์ชุดเดียวกันทุกประการ ให้ trafilatura 2.1.0 เพื่อเทียบกันบน testbed เดียวกัน ทุก fixture ถูก parse สามครั้ง และทั้ง 22 ไฟล์ให้ข้อความที่เหมือนไบต์ต่อไบต์ทุกครั้ง

คุณตรวจสอบแต่ละขั้นตอนได้ ไม่ต้องเชื่อเพียงตารางสรุป tests/build_fixtures.mjs สร้าง HTML ที่ใส่ annotation และ ground truth; tests/run_readability.mjs บันทึกผล extraction และ output ของ predictor; และ tests/metrics.py คำนวณคะแนนจากบันทึกเหล่านั้นทีหลัง Raw output ของ Readability, metrics ที่คำนวณได้ และการเปรียบเทียบ input ชุดเดียวกันถูกเก็บไว้ใน artifacts/raw/ การแยกแบบนี้สำคัญมากเวลาผลลัพธ์ดูน่าสงสัย เพราะคุณจะบอกได้ว่า parser คืนข้อความแปลก ๆ, ชุด label ผิด, หรือโค้ด scoring ตีความผิด การรันซ้ำใน pack นี้ยืนยันข้อกล่าวอ้างในบทความนี้ได้ก็จริง แต่ก็ยังเป็นเพียงการตรวจ harness ไม่ใช่หลักฐานว่าการใช้งานจริงจะมีการกระจายตัวของปัญหาแบบเดียวกัน

ขอบเขตที่จำกัดนี้มีความสำคัญและไม่ใช่เรื่องเล็ก: นี่คือหน้า synthetic ที่ควบคุมได้ ไม่ใช่ corpus ของโลกจริง ตัวเลขที่เชื่อถือได้สำหรับหน้าเว็บจริงมาจาก public article-extraction-benchmark ซึ่งให้คะแนน readability_js 0.6.0 — เวอร์ชันที่ทดสอบตรงนี้ — ที่ word-F1 0.947 ± 0.005 (precision 0.914 ± 0.008, recall 0.982 ± 0.003) จากหน้าเว็บจริงราว 181 หน้า ฉันอ้างอิงผลนั้น แต่ไม่ได้ทำซ้ำเอง

ใช้แถว benchmark ของ release ปัจจุบันในที่นี้ และตัดแถวประวัติที่ถูกแทนที่แล้วออก Fixtures ที่ควบคุมได้เพิ่มการแตกย่อยรายบล็อกเพื่อดูว่า content shape แบบไหนกระตุ้นกฎอะไร แทนที่จะมาแทน corpus หน้าเว็บจริงสาธารณะ

Recall สมบูรณ์แบบในชุด fixture synthetic

74 จาก 74 ใน fixtures ทั้ง 22 ไฟล์ Readability ไม่ได้ตกหล่นบล็อกบทความที่ติดป้ายไว้เลยแม้แต่บล็อกเดียว — และใน 11 fixtures แบบสะอาดที่ผสม article กับ boilerplate ค่า micro-averaged token recall อยู่ที่ 1.000 ไม่มีประโยคของบทความหายไปเลย

แต่ต้องใส่ข้อจำกัดสองข้อไว้ด้วย:

เหล่านี้คือหน้า synthetic แบบ single-column ที่สะอาด หน้าเว็บจริงมักซ้อนชั้นลึกกว่า มีโฆษณาคั่นกลางเนื้อหา และบางครั้งก็บังย่อหน้าเปิดด้วย artifact ของการให้คะแนน ซึ่งปัญหากลุ่มนี้ถูกรายงานไว้ใน tracker แล้ว (#437, #901 และการตัด content ก่อนตารางใน #922) Fixtures ของฉัน ไม่ได้กระตุ้นปัญหาเหล่านั้นเลย ดังนั้นฉันไม่ได้บอกว่ามันถูกแก้แล้ว — ฉันแค่บอกว่าการทดสอบของฉันไปไม่ถึงจุดนั้น บนหน้าเว็บจริง benchmark recall ของเวอร์ชันนี้คือ 0.982 ไม่ใช่ 1.000

อย่างไรก็ดี ทิศทางของผลลัพธ์นี่แหละที่มีประโยชน์ ปัญหาหลักของ Readability ไม่ใช่การโยนบทความทิ้ง แต่คือสิ่งที่มันพ่วงมาด้วย

ตัวเลข precision และทำไมมันต้องดูจาก 3 มุม

Measured results chart: Three scopes behind the precision story

ตัวเลขเดียวพูดง่าย แต่ปกป้องยาก ใน 11 fixtures แบบผสม Readability เก็บ 5 จาก 17 บล็อก boilerplate — leak rate 0.294

แต่นั่น ไม่ใช่ leak rate ของโลกจริง มีการวัดอยู่ 3 แบบที่สะท้อนคนละเรื่อง และมีเพียงแบบเดียวที่อธิบายหน้าเว็บทั่วไปได้:

ตัวเลขนี้กำลังวัดอะไรผลลัพธ์
ชุด fixture ที่ถ่วงน้ำหนักแบบโจมตี — 6 จาก 11 หน้าแบบผสมถูกสร้างมาเพื่อเอาชนะ sibling gate โดยเฉพาะเก็บ boilerplate 5 จาก 17 บล็อก (0.294)
หน้า realistic เพียงหน้าเดียว — เนื้อหา <article> ที่มี nav, ad banner, sidebar, comments, footer ล้อมรอบ และมี promo class กลาง ๆ อีก 1 บล็อกตัด chrome 5 จาก 6 บล็อก; เก็บ 1 บล็อก
~181 หน้าเว็บจริง, public benchmark (ไม่ใช่รอบทดสอบของฉัน)precision 0.914, recall 0.982, word-F1 0.947readability_js 0.6.0

อ่านแถวแรกในฐานะ stress test ไม่ใช่การพยากรณ์ Readability ไม่ได้รั่ว boilerplate 29% บนเว็บจริง บนหน้า realistic ทุกอย่างที่มี class ที่ regex unlikelyCandidates match ได้ — nav-menu, ad-banner, sidebar, comments, site-footer — ถูกตัดออกหมดเกลี้ยงทั้ง 5 บล็อก ตัวที่รอดอยู่คือบล็อกเดียวที่ฉันออกแบบให้หลบ regex นี้

เกต 0.25: จุดที่การลบ boilerplate หยุดทำงาน

กฎพ่วง sibling มีระบุไว้ในซอร์สอยู่แล้ว แต่เท่าที่ฉันหาดู ยังไม่มีใครวัด “จุดเปลี่ยน” ของมันแบบชัด ๆ ว่ามันสลับเมื่อไหร่ ดังนั้นฉันจึงสร้าง gradient ขึ้นมา: มี <p class="teaser-block"> ที่ไม่ได้ใส่ class ชี้นำใด ๆ อยู่นอก <article>, มีบทความสี่ paragraph ที่ชัดเจนจนต้องชนะในฐานะ top candidate และสิ่งเดียวที่เปลี่ยนคือความยาวกับ link density ของ promo เท่านั้น โดยวัด density ด้วยสูตรของ Readability เองจริง ๆ ไม่ได้เดาเอา:

บล็อกโปรโมชันความยาว inner textเกิน 80 ตัวอักษรไหมlink density ที่วัดได้ผลลัพธ์
ไม่มีลิงก์เลย126ใช่0.000เก็บไว้
ลิงก์สั้น 1 จุด126ใช่0.143เก็บไว้
ลิงก์ยาว 1 จุด126ใช่0.278ตัดทิ้ง
ครึ่งหนึ่งของข้อความเป็นลิงก์126ใช่0.476ตัดทิ้ง
1 ประโยค ลงท้ายด้วยจุด60ไม่ใช่0.000เก็บไว้
ข้อความเดียวกัน แต่ไม่มีจุด59ไม่ใช่0.000ตัดทิ้ง

เงื่อนไขในซอร์สใช้ threshold ที่ 0.25; ตัวอย่างที่วัดได้คร่อมค่านี้พอดี โดย 0.143 ถูกเก็บและ 0.278 ถูกตัด อีกสาขาหนึ่งเก็บประโยค 60 ตัวอักษรที่ลงท้ายด้วยจุด และตัดเวอร์ชัน 59 ตัวอักษรที่ไม่มีจุดออก ส่วน article recall ยังอยู่ที่ 4/4 ในแต่ละฝั่ง ดังนั้นตัวอย่างนี้จึงแยกให้เห็นผลด้าน precision ล้วน ๆ

นอกกรอบทดสอบ เกตนี้มีความหมายประมาณว่า: ข้อความร้อยแก้วที่ยาว ไม่ค่อยมีลิงก์ และเป็นกลาง ๆ ที่อยู่ข้างบทความ ก็คือบทความ ซึ่งจริง ๆ แล้วมันอธิบายอะไรหลายอย่างที่ไม่ใช่บทความ — เช่นข้อความ “Related reading” ที่เขียนเป็นย่อหน้า, โปรโมตจดหมายข่าว, โน้ตจากบรรณาธิการ, หรือ teaser แบบ sponsored ที่ทีมการตลาดเขียนเป็นประโยคเต็มแล้วลบลิงก์ออกเพื่อติดตามผล

ใน RAG index ย่อหน้าโปรโมตที่มีลิงก์น้อยอาจถูกดึงออกมาเป็น chunk และทำให้ retrieval หรือ generation มองมันเป็นเนื้อหาบทความ กฎในซอร์สทำให้ failure mode นี้เกิดขึ้นได้จริง; แต่รีวิวนี้ไม่ได้รันการประเมิน retrieval หรือการอ้างคำพูดจากโมเดลแบบ end-to-end

ถ้าจะตั้ง hard filter เฉพาะเว็บไซต์ ให้ pre-filter container ที่รู้ว่าเป็น source DOM, เก็บ ancestry ของ source-node ไว้เทียบก่อน serialize, หรือใช้การกรองแพตเทิร์นข้อความที่ตรวจสอบแล้วอย่างระมัดระวังภายหลัง HTML ที่ได้กลับมาอย่างเดียวอาจไม่เหลือหลักฐานแล้วว่าโหนดนั้นเดิมอยู่นอก primary container ส่วน sibling gate เองไม่ได้เปิดให้ปรับผ่านตัวเลือกสาธารณะ

3 สมมติฐานที่ fixtures หักล้าง

Fixtures หักล้าง 3 สมมติฐาน: ว่า charThreshold จะปัดบทความสั้นทิ้ง, ว่า semantic tags จำเป็น, และว่าคอนเทนต์สั้นที่ไม่ใช่ prose จะถูกตัดทิ้ง หลักฐานด้านล่างคือส่วนที่เกี่ยวข้องจริง ๆ ไม่ต้องมี preregistration มากำกับ

charThreshold = 500 ไม่ใช่หน้าผา

ความเชื่อที่แพร่กันคือบทความที่สั้นกว่า 500 ตัวอักษรจะได้ null ไม่จริง ฉันไล่ความยาว body ตั้งแต่ 120 ถึง 1500 ตัวอักษร เทียบกับค่า charThreshold ที่ 200, 500, และ 1000:

ความยาว bodyParse สำเร็จทุก threshold ไหมความยาวที่ดึงออกมา
120ใช่161
300ใช่342
460ใช่509
520ใช่569
800ใช่841
1500ใช่1555

คงที่ทุกค่า ความยาวที่ดึงออกมาเท่ากันในทุก threshold ทุกขนาด body threshold ไม่ได้ใช้ตัดสินว่าจะคืนค่าหรือไม่ — มันแค่ใช้ตัดสินว่าจะรัน grab ซ้ำด้วย cleanup flags ที่ถูกถอดออกหรือเปล่า และถ้าหน้าสะอาดก็ไม่มีอะไรให้ลบ ผลลัพธ์จึงเหมือนเดิมทั้งสองทาง ขอบเขต null ที่แท้จริงคือ “ไม่มีข้อความที่ดึงได้เลย”

และนั่นก็นำไปสู่ failure จริงที่เจอ ซึ่งแย่กว่าการคืน null แบบผิด ๆ ฉันป้อนหน้าที่แทบว่าง — มีแค่ nav bar กับคำโปรย 4 คำ มันตอบกลับมาได้ปกติ และ “article” ที่ได้กลับมา รวม nav เข้าไปด้วย พูดอีกแบบคือ ถ้าไม่มีบทความจริง Readability ก็จะยก boilerplate มาใส่ป้ายว่าเป็นบทความให้คุณ ถ้าคุณ crawl จำนวนมากแล้วถือว่าผลลัพธ์ที่ไม่เป็น null เท่ากับ “หน้านี้มีเนื้อหา” ความคิดนั้นผิด

semantic tags ไม่ได้เป็นตัวแบกงาน

ฉันคาดว่าการดึงเนื้อหาจะตกลงเมื่อเอาโครง semantic ออกไป เนื้อหาบทความชุดเดิม แต่มีสองสกิน: แบบหนึ่งใช้ <main><article><h1> และ class ที่บอกความหมายชัดเจน อีกแบบใช้ <div class="x1"> และ paragraph เป็น <div> เปล่า ๆ ผลคือ ดึงกลับมาได้ 4 จาก 4 บล็อกบทความทั้งสองแบบ และไม่มี boilerplate รั่วในทั้งคู่ เมื่อบทความเป็นก้อนข้อความที่หนาที่สุดบนหน้าอย่างชัดเจน การให้คะแนนจากความยาวและเครื่องหมายจุลภาคก็หาเจอได้เองโดยไม่ต้องพึ่ง semantic help ความเชื่อที่ว่า “Readability ต้องมี <article> tags” จึงเป็นแค่ folklore

ข้อจำกัดที่ต้องพูดตรง ๆ ของข้อนี้คือ หน้าเว็บของฉันมี content block เดียวที่เด่นชัด กรณีที่ semantic น่าจะมีคุณค่าจริง ๆ คือหน้าที่มี subtree หนาแน่น สองก้อน แข่งกัน ซึ่งฉันไม่ได้ทดสอบจุดตัดสินนั้น

คอนเทนต์ที่ไม่ใช่ prose ถูกเก็บไว้ครบ

กฎที่ว่า “paragraph ที่สั้นกว่า 25 ตัวอักษรจะไม่ถูกนับ” ทำให้ฉันคิดว่าจะมีการสูญหายบน table และ caption แต่ผิดอีกแล้ว — กฎนั้นมีผลต่อการ ให้คะแนน candidate ไม่ใช่การ เก็บรักษา พอ container ชนะ ทุกอย่างข้างในก็ถูกพ่วงมาหมด:

ประเภทคอนเทนต์ภายในบทความReadabilitytrafilatura
ย่อหน้า prose (×2)เก็บไว้เก็บไว้
เซลล์ตารางข้อมูล (×2)เก็บไว้เก็บไว้
บล็อกโค้ด <pre>เก็บไว้เก็บไว้
บรรทัด <p> แบบ one-line สั้นกว่า 25 ตัวอักษร (×2)เก็บไว้เก็บไว้
<figcaption>เก็บไว้ตัดทิ้ง
รวม8/87/8

นี่คือแกนที่ cleaner แบบแรงกว่ากลับแพ้ ถ้าเว็บไซต์ของคุณเป็นเอกสารสอนใช้งาน, tutorial หรืออะไรก็ตามที่มี code blocks และภาพพร้อมคำบรรยาย Readability ที่เก็บ subtree ที่ชนะทั้งก้อนนั้นถือว่าเป็นข้อดี

isProbablyReaderable บอกว่าไม่ แต่ parse() บอกว่าใช่

System diagram: isProbablyReaderable says no when parse() says yes

README แนะนำให้เรียก isProbablyReaderable(doc) เป็นด่านตรวจล่วงหน้าแบบประหยัดก่อนจะยอม parse จริง ในการทดสอบของฉัน เกตนี้ปฏิเสธหน้าเว็บ 3 รูปแบบที่ parse() กลับจัดการได้สบาย:

รูปแบบหน้าผลจากตัวทำนายparse()ปรับด้วยอะไรได้บ้าง
เนื้อหาอยู่เฉพาะในองค์ประกอบ <li>falseสำเร็จไม่มีเลย — false ทุกค่า minScore 1–80 และ ทุกค่า minContentLength 40–200
10 ย่อหน้า แต่ละย่อหน้าสั้นกว่า 140 ตัวอักษรfalseสำเร็จminContentLength ≤ 100 (minScore ไม่ช่วย)
ย่อหน้าเดียว ความยาว 408 ตัวอักษรfalseสำเร็จminScore ≤ 10 (score ประมาณ 16.4)
บทความปกติ (control)trueสำเร็จ

ความล้มเหลวทั้ง 3 แบบมีสาเหตุคนละอย่าง และมีเพียง 2 แบบที่ปรับได้ กรณี <li> เป็นเรื่องโครงสร้าง: predictor ให้คะแนนแค่ node ประเภท p, pre, และ article (รวมถึง parent ของ div > br) ดังนั้นหน้าที่เนื้อหาอยู่ใน list items ล้วน ๆ จะไม่ match อะไรเลย ได้คะแนนเป็นศูนย์ และ ไม่มีการจูน threshold ไหนช่วยได้ ซึ่งเป็นรูปแบบที่เคยถูกรายงานไว้แล้วใน issue #662 กรณีหลายย่อหน้าที่สั้นเป็นเรื่องของ minContentLength ที่ตัดแต่ละ paragraph ทิ้งก่อนจะให้คะแนน ดังนั้นย่อหน้าที่ดูมีน้ำหนักสิบอันจึงรวมกันเป็นศูนย์ การลดค่านั้นช่วยได้ ส่วนการปรับ minScore ไม่ช่วย กรณีย่อหน้าเดียวเป็นคณิตศาสตร์ล้วน ๆ: score คือ sqrt(408 − 140) ≈ 16.4 ต่ำกว่าค่า minScore เริ่มต้นที่ 20 — ย่อหน้าเดี่ยวต้องยาวถึง 540 ตัวอักษร เพื่อจะข้ามเกณฑ์ด้วยตัวเอง

README บอกไว้แล้วว่าตัว predictor มี false negatives แต่สิ่งที่ฉันอยากเสริมคือกฎใช้งานจริง: อย่าใช้มันเป็นด่านเดียว ถ้าหน้านั้นสำคัญ ให้ parse แล้วตรวจความยาวผลลัพธ์ การ parse ไม่แพงนักเมื่อเทียบกับต้นทุน jsdom ที่คุณจ่ายไปแล้ว

ไบต์ชุดเดียวกัน แต่ใช้ตัวดึงเนื้อหาคนละตัว

การรัน trafilatura 2.1.0 กับ fixtures ชุดเดียวกัน ทำให้อ่านผลได้ชัดกว่าการเอาตัวเลขสองตัวที่วัดจาก testbed คนละชุดมาเทียบกัน เพราะ input เหมือนกันแบบ byte-for-byte:

ตัวชี้วัด (11 mixed fixtures)@mozilla/readabilitytrafilatura
Article block recall1.0001.000
Boilerplate blocks kept5/17 (0.294)1/17 (0.059)
Token F1 (micro)0.9480.969
Non-prose recall8/87/8
บทความสั้นมาก (120 ตัวอักษร), token F10.8000.571

ไม่มีตัวไหนชนะขาดใน fixtures ชุดนี้ trafilatura เก็บ sibling blocks ได้น้อยกว่า ขณะที่ Readability เก็บ short content และ non-prose ได้มากกว่า ความแม่นยำเชิง token ของทั้งสองตัวถูกกดลงจาก heading text ที่ไม่ได้ติดป้ายกำกับ ดังนั้นการนับ block-level leak จึงเป็นสัญญาณตรงที่สะอาดกว่า benchmark บนหน้าเว็บจริงสาธารณะจัดลำดับ word-F1 ของทั้งสองตัวไว้ใกล้ ๆ กันอยู่ก็จริง แต่ corpus และ metric คนละแบบ และนี่ไม่ใช่การยืนยันข้าม testbed

ความทนทานแบบสั้น ๆ: ฉันรัน twin ของ canonical page ที่ตั้งใจทำให้เสียรูปแบบ (<p> ไม่ปิด, <b>/<i> ซ้อนผิด, มี </div> หลุดมา) และ recall ยังเป็น 3/3 พร้อมไม่มี leak เหมือนเวอร์ชันที่ถูกต้อง เครดิตส่วนนี้เป็นของ jsdom HTML5 tree builder ที่ซ่อมความเละก่อนที่ Readability จะได้เห็น ไม่มี fixture ไหนทำให้ parser ล้ม

ข้อดีและข้อเสีย

ข้อดี

  • Recall ของบทความแข็งมาก: ดึงบล็อกที่ติดป้ายไว้ได้ 74/74 จาก 22 synthetic fixtures และ token recall 1.000 บนชุดผสม
  • เฟอร์นิเจอร์ของหน้าเว็บที่ถูก class ด้วย regex ถูกลบทิ้งได้สม่ำเสมอ — nav, ad banner, sidebar, comments และ footer หายหมดบนหน้า realistic (5 จาก 6)
  • คอนเทนต์ที่ไม่ใช่ prose ถูกเก็บครบ: ตาราง, โค้ด <pre>, คำบรรยายรูป และบรรทัดสั้นกว่า 25 ตัวอักษรถูกเก็บทั้งหมด (8/8) ในขณะที่ trafilatura ตัด caption ทิ้ง
  • ไม่พึ่ง semantic markup — บทความที่อยู่ใน <div> แบบ neutral ให้คะแนนเหมือนเวอร์ชัน <article>/<main>
  • บทความสั้นไม่ถูกปัดทิ้งผิด ๆ: ดึงเนื้อหาที่สะอาดได้ลงมาถึง 120 ตัวอักษร และผลเหมือนกันทุกค่า charThreshold 200/500/1000
  • ผลลัพธ์ deterministic เต็มรูปแบบ: fixtures ทั้ง 22 ให้ข้อความเหมือนเดิมครบทั้ง 3 รอบ
  • ติดตั้งสองนาที, Apache-2.0 และเวอร์ชันบน npm ก็คือเวอร์ชันที่ฉันทดสอบ (0.6.0) พูดง่าย ๆ คือไม่มีอะไรล้าสมัยในนี้

ข้อเสีย

  • กฎพ่วง sibling ถูกโจมตีได้: ข้อความโปรโมตที่ยาว มีลิงก์น้อย และใช้ class แบบเป็นกลาง แยกไม่ออกจากบทความ และจะถูกพ่วงไปถ้า linkDensity < 0.25
  • บนหน้าที่มีคอนเทนต์น้อย มันจะคืน boilerplate ในฐานะ บทความ แทนที่จะคืน null — fixture ที่แทบว่างของฉันกลับมาพร้อม nav bar เป็น body
  • isProbablyReaderable ให้ false negative กับหน้าเว็บ 3 แบบ และมี 1 แบบที่ต่อให้จูนก็แก้ไม่ได้
  • ต้องมี DOM เต็มรูปแบบตอน runtime — ภาพว่า “ไม่มี dependency” ซ่อนค่าใช้จ่ายของ jsdom เอาไว้ ซึ่งเป็นต้นทุนหลักในลูป
  • parse() mutate document ต้นฉบับ ดังนั้นต้องสร้าง DOM ใหม่สำหรับแต่ละหน้า
  • ไม่มีการ fetch, ไม่มีการ render JavaScript และไม่มี structured output มันเป็นแค่หนึ่งขั้นตอนใน pipeline ไม่ใช่ทั้ง pipeline
  • miss บนหน้าเว็บจริงที่ tracker รายงานไว้ (ย่อหน้าเปิดและก่อนตาราง) ไม่เกิดซ้ำใน fixtures ของฉัน ดังนั้นฉันบอกไม่ได้ว่ามันหายากจริงหรือแค่หน้าที่ทดสอบไม่เคยไปถึงจุดนั้น

ควรใช้เมื่อไหร่ และควรข้ามเมื่อไหร่

ควรหยิบ Readability มาใช้ถ้าคุณมี HTML อยู่แล้วและอยากดึงบทความออกมาด้วย JavaScript ล้วน ๆ ภายในบริการ Node ที่เพิ่ม Python dependency ไม่สะดวก (ฉันไม่ได้เก็บ timing ในรูปแบบการกระจายจริงจัง ดังนั้นฉันไม่ขออ้างเรื่องความเร็ว นอกจากบอกว่า “jsdom build เป็นตัวกินเวลาในลูป ไม่ใช่ extraction” Reader mode, การเก็บบทความออฟไลน์, email newsletter, ปุ่ม “clean view”, browser extension, pipeline เอกสารที่มีโค้ดบล็อกและ caption — นี่คือสนามของมัน และตัวเลข recall ก็บอกว่ามันทำได้ดีด้วย พฤติกรรมของมันยังอ่านจากซอร์สได้ตรง ๆ ซึ่งมีค่าสูงกว่าที่คิดเวลาคุณต้องอธิบายกับเพื่อนร่วมงานว่าบล็อกหนึ่งผ่านเข้ามาได้อย่างไร

ควรข้ามมันถ้าตัวชี้วัดที่คุณถูกประเมินคือความแม่นยำในการลบ boilerplate และยิ่งควรข้ามถ้าคุณป้อนเข้าดัชนี LLM ที่ย่อหน้าโปรโมตหลุด ๆ กลายเป็น chunk ให้ค้นคืนได้ ควรข้ามถ้าหน้าเว็บของคุณ render ฝั่ง client เพราะมันอ่านแค่ DOM ที่คุณยื่นให้ และไม่ได้รัน JavaScript ควรข้ามถ้าสิ่งที่คุณต้องการคือ {title, price, sku} ไม่ใช่ prose — ไม่มีการตั้งค่าแบบไหนที่จะเปลี่ยน content extractor ให้กลายเป็น schema-driven extractor ได้ และถ้าคุณกำลังประมวลผลหน้าที่คำถามว่า “หน้านี้มีบทความจริงไหม” เป็นเรื่องสำคัญ อย่าเชื่อผลลัพธ์ที่ไม่เป็น null แบบดื้อ ๆ ว่านั่นคือคำตอบ

ทางเลือกอื่น และ Thunderbit อยู่ตรงไหนในสแต็กนี้

ทั้งหมดนี้ไม่ได้เป็นการโจมตีไลบรารี Apache-2.0 ฟรีจาก Mozilla — Readability คือ infrastructure มันอยู่ใน Firefox มาหลายปี และสำหรับการดึงแบบ reader-mode มันคือ reference implementation ด้วยเหตุผลที่ดี ถ้าอยากเห็นภาพกว้างขึ้น ฉันมีการเปรียบเทียบต่อเนื่องไว้ใน open-source scrapers roundup และบทสำรวจภาพรวมใน the best web scraping tools

ถ้าอยากดูการเทียบกันของทั้ง 6 extractor บน fixtures ชุดเดียวกัน ให้ดู six-library extraction comparison

หมายเหตุผู้เขียน: Thunderbit เป็นตัวเลือกแบบ managed สำหรับการ render และ extraction จาก URL โดยตรง ฉันไม่ได้รันมันบน fixtures ชุดนี้ ดังนั้นจึงไม่มีการสรุปว่าคุณภาพเท่ากันหรือไม่ ขอบเขตที่เกี่ยวข้องคือคุณมี DOM อยู่แล้วและอยากทำการ extraction ในเครื่อง หรืออยากได้การ fetch/render และ structured output ในรูปแบบบริการ การ self-host อาจไม่ต้องจ่ายค่าใช้งาน vendor แต่ก็ยังมีต้นทุนด้านโครงสร้างพื้นฐานและการดูแลรักษา

ความแลกเปลี่ยนที่ตรงไปตรงมาคือ: Readability ฟรี โปร่งใส และคุณควบคุมมันได้เอง — คุณอ่านกฎเกตที่ตัดสินผลลัพธ์ของคุณได้แบบตรงตัว ซึ่งเป็นสิ่งที่ managed API ให้ไม่ได้ สแต็กแบบ managed มีค่าใช้จ่ายแต่ซ่อนกลไกไว้ ทว่าแลกมากับการรับภาระ fetch-render-structure ที่ไม่งั้นคุณต้องประกอบเอง ถ้าคุณอยากได้ฝั่งที่มี AI ช่วยของสเปกตรัมนี้ ฉันเคยเขียนเกี่ยวกับ scraping any website with AI และ AI crawlers ไว้อีกที่ เลือกตามขั้นตอนที่คุณอยากถือครองจริง ๆ

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

บทสรุป

Readability เป็นตัวเลือกที่น่าพิจารณาเมื่อคุณมี DOM อยู่แล้ว รันบน JavaScript/Node และยอมรับการมี sibling content เกินมาบ้างดีกว่าการตัดเนื้อหาหายมากเกินไป ในชุด fixture นี้มันดึงบล็อกบทความที่ติดป้ายไว้ได้ครบ 74 บล็อก และเก็บตาราง โค้ด และคำบรรยายรูปไว้ได้ ผลลัพธ์นี้อยู่ภายใต้ข้อจำกัดของหน้า synthetic แบบ single-column; recall บนหน้าเว็บจริงอยู่ที่ 0.982, miss ที่รู้กันก่อนหน้านี้แบบเปิดย่อหน้าแรกหรือก่อนตารางไม่เกิดซ้ำ และหน้าที่มีคอนเทนต์น้อยอาจคืน boilerplate มาเป็นบทความแทน

แค่ประเมินจุดอ่อนให้ถูก มันพลาดหลัก ๆ ในมิติ precision และจุดนั้นอยู่ตรงบรรทัดที่ระบุชัดในซอร์ส: sibling ที่ยาวเกิน 80 ตัวอักษรและมี link density ต่ำกว่า 0.25 จะถูกพ่วงเข้ากับบทความ ไม่ว่ามันจะเป็นส่วนหนึ่งของบทความจริงหรือไม่ก็ตาม ฉันเห็นจุดสลับนี้เกิดขึ้นที่ 0.143 เทียบกับ 0.278 บนข้อความชุดเดียวกัน benchmark บนหน้าเว็บจริงรายงาน precision 0.914 และ recall 0.982 ถ้าคุณเอาข้อความที่ดึงออกไปใส่ index ที่วันหนึ่งโมเดลจะอ้างกลับมา ตรวจทั้ง boilerplate ที่ค้างอยู่และเนื้อหาที่หายไป อย่าคิดเองว่าความผิดพลาดแบบใดแบบหนึ่งไม่มีอยู่

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

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

Mozilla Readability ลบ boilerplate ได้ทั้งหมดไหม? ไม่ทั้งหมด และตัวเลขขึ้นกับว่าคุณวัดอะไรอยู่มาก บน public real-page benchmark readability_js 0.6.0 ได้ precision 0.914 — หมายความว่าประมาณ 8.6% ของสิ่งที่มันคืนมาไม่ใช่ body content บนหน้า realistic ที่ฉันทดสอบ มันตัด chrome blocks ได้ 5 จาก 6 บล็อก (nav, ad, sidebar, comments, footer หายหมด) และเก็บไว้แค่ย่อหน้าโปรโมตที่มี class เป็นกลาง ในชุด fixture ที่ฉันตั้งใจถ่วงน้ำหนักบล็อกให้เอาชนะแฮนริสติก มันเก็บ 5 จาก 17 — ตัวเลขหลังสุดเป็น stress test ไม่ใช่อัตราจริงของโลกจริง

ต้องใช้ jsdom ถึงจะใช้ readability.js บน Node ได้ไหม? ใช่ หรือไม่ก็ต้องมี DOM implementation ตัวอื่น Readability เป็น JavaScript ล้วนก็จริง แต่มันทำงานบนอ็อบเจ็กต์ document แบบมีชีวิต ดังนั้นบน Node คุณต้องจัดหา DOM เอง — ในการตั้งค่าของฉันคือ jsdom 29.1.1 คำอธิบายแบบ “ไม่มี dependency” หมายถึงตัวอัลกอริทึม ไม่ใช่ runtime และอย่าลืมว่า parse() จะ mutate document ที่รับเข้าไป ดังนั้นควรสร้าง DOM ใหม่สำหรับแต่ละหน้า ไม่ใช่ reuse ตัวเดิม

ตัวเลือก charThreshold ทำอะไรจริง ๆ? ไม่ใช่อย่างที่หลายคนคิด มัน ไม่ได้ ทำให้บทความสั้นกลับ null — ฉันดึงบทความที่สะอาดกลับมาได้ถึง 120 ตัวอักษร และความยาวที่ดึงออกมาเท่ากันทุกค่า charThreshold ที่ 200, 500 และ 1000 threshold นี้ใช้กำหนดว่าจะให้ parser รัน grab ซ้ำโดยถอด cleanup flags ออกหรือไม่; ถ้าหน้าสะอาดก็ไม่มีอะไรต้องลบ ผลลัพธ์จึงเหมือนเดิมทั้งสองทาง กรณีที่เป็น null จริง ๆ คือหน้าที่ไม่มีข้อความให้ดึงเลย และแม้แต่หน้าที่มีแค่ nav ก็ยังไม่เป็น null เพราะมันคืน nav ออกมาเป็นบทความ

ควรเรียก isProbablyReaderable ก่อน parse() ไหม? ใช้เป็นสัญญาณบอกแนวโน้ม ไม่ใช่ด่านตัดสิน มันคืน false บนหน้า 3 รูปแบบที่ parse() ทำงานสำเร็จ: เนื้อหาที่อยู่ใน <li> เท่านั้น, 10 ย่อหน้าที่แต่ละย่อหน้าสั้นกว่า 140 ตัวอักษร, และย่อหน้าเดียวที่ยาว 408 ตัวอักษร กรณี <li> แก้ด้วยการจูนไม่ได้ เพราะ predictor ให้คะแนนเฉพาะ p, pre, และ article; กรณีหลายย่อหน้าสั้นต้องลด minContentLength; ส่วนกรณีย่อหน้าเดียวยาวต้องลด minScore เพราะย่อหน้าเดี่ยวต้องยาวถึง 540 ตัวอักษรจึงจะผ่านค่าเริ่มต้น ถ้าหน้านั้นสำคัญ ให้ parse แล้วดูผลลัพธ์

Readability หรือ trafilatura สำหรับการดึงบทความ แบบไหนดีกว่า? บนไบต์ของ fixture ชุดเดียวกัน trafilatura เก็บ boilerplate ได้น้อยกว่า (1/17 บล็อก เทียบกับ 5/17) ขณะที่ Readability ดึง short content ได้มากกว่า และยังเก็บ <figcaption> ที่ trafilatura ตัดทิ้ง เลือกตามความยอมรับความผิดพลาดและ runtime ตัว benchmark สาธารณะเป็นบริบทอีกชุดหนึ่ง ไม่ใช่การยืนยันผลของ fixture ชุดนี้

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

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

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