六個函式庫、一組帶標註的 fixture、一次評分。在這 22 個合成 fixture 中,樣板內容洩漏最高的函式庫——Mozilla 的 Readability,達到 23.5%——同時也是唯一一個把所有標註的文章單元完整取回來的工具。
這就是這個取捨最精準的一句話;而這個主題的大多數文章都沒把它說清楚,因為它們通常只算 precision,然後就停了。
這次實際量測了什麼
這組 fixture 的每個單元都有明確的 ground truth。頁面中的每個區塊——文章段落、導覽列、廣告、側欄、留言串、促銷區——都標記為 article 或 boilerplate,並附上一個獨特的 sentinel token。所以「萃取器有沒有抓回這個單元」判斷的是精確的子字串是否存在,而不是相似度分數。某個 sentinel 不是出現在輸出裡,就是沒出現。
22 個 fixture,91 個單元。六個萃取器:Mozilla Readability 0.6.0(透過 jsdom 30.0.1)、trafilatura 2.2.0、resiliparse 1.0.9、newspaper4k 0.9.6、goose3 3.1.22,以及 jusText 3.0.2。Python 3.14.2 與 Node 22 都在同一台機器上執行。本文沒有保留作業系統 / CPU、精確呼叫方式、重複次數或 warm-up 策略,所以表中的耗時欄位只能算是本地觀察,不能直接當成可移植的 benchmark。
在開始之前,我先強制自己遵守兩條規則。每個 Python 函式庫都要裝在自己空白的 virtualenv 裡,這樣它的 footprint 只屬於自己,不會繼承其他套件帶進來的東西。其次,沒有任何 runner 自己計算指標——每個工具只輸出原始萃取文字,最後由同一個 scorer 產出所有數字,確保六個工具是在完全相同的運算規則下比較,而不是各自用看起來差不多、其實不一樣的「precision」定義。
先看總表
| 函式庫 | 文章召回率(22 個全部) | 樣板內容洩漏率 | 內容 token precision | 回答 precision 的 fixture | 污染 token 數 |
|---|---|---|---|---|---|
| Readability | 1.0000 | 0.2353 | 0.9109 | 11/11 | 35 |
| trafilatura | 0.9865 | 0.0588 | 0.9411 | 11/11 | 4 |
| newspaper4k | 0.9865 | 0.0000 | 0.9452 | 11/11 | 0 |
| resiliparse | 0.9054 | 0.0588 | 0.9381 | 11/11 | 7 |
| jusText | 0.8378 | 0.4706 | 0.8760 | 10/11 | 74 |
| goose3 | 0.8243 | 0.0000 | 1.0000 | 10/11 | 0 |
Recall 是針對全部 22 個 fixture 彙總計算的。洩漏率、整體內容 token precision 與污染指標則只用那 11 個同時包含文章單元與樣板單元的 fixture;「回答」表示其中有多少個真的產生了輸出。完整的逐 fixture 數字可見 sixway-scores.json。
這張表裡有一列不是預設值。 resiliparse 的 extract_plain_text 預設是 main_content=False,而我呼叫時用的是 main_content=True。兩者差很多:如果用預設值,它會在整組測試中洩漏 17 個中的 17 個 樣板單元——所有導覽列、廣告、側欄、留言串和促銷區全都漏進來——但打開這個旗標後,只剩 17 個中的 1 個。上面其他函式庫都用預設值測試。所以 resiliparse 的 0.0588 洩漏率,是它在你要求主內容時的表現;單純執行 extract_plain_text(html) 則是另一個產品 (default-vs-main-content.json)。
第二欄和第一欄要一起看,因為只看其中一欄,很容易選錯工具。
Readability 從不漏抓。 22 個 fixture 全部都做到完美召回,這點它是唯一一個。代價也很明顯:17 個樣板單元裡有 4 個被漏進來,產生 35 個污染 token,洩漏率是 trafilatura 的四倍。它的 4 個漏抓案例裡,有 3 個是同一種型態——一個被標成中性類別的促銷區塊,剛好是文章的 sibling,而它的 sibling-append heuristic 把它一起吞進去了。如果你把輸出丟給模型,等於花了這些 token 的成本,還讓模型把它們當成文章來讀。
newspaper4k 是最均衡的。 零洩漏、零污染 token、0.9865 召回率,而且 22 個 fixture 全部都有輸出。如果我只能在不知道工作負載的情況下選一個,我會選它;而且這也不是多數人第一個想到的工具。
goose3 的 precision 完美,但測試裡召回率最差。 它輸出的每個內容詞都確實是文章內容;但它在兩個 fixture 上完全抓不到任何東西,而且在那兩個 fixture 上也沒有輸出。只要你有權利拒答,完美 precision 其實很便宜。
那個讓兩個函式庫看起來很漂亮的 precision 數字
這最後一點值得特別講清楚,因為我差點就把它原樣發出去了。
這裡的 precision 和 F1 都是「有產生輸出」才算。某個函式庫如果在某個 fixture 上回傳空字串,對分子和分母都沒有貢獻——所以拒答是免費的,保守型萃取器的 precision 看起來就會比徹底型萃取器更好,原因只是它保持沉默。
goose3 在 10 個有輸出的 scored fixture 上,整體 precision 是 1.0000。jusText 則是在 11 個中的 10 個 fixture 上得到 0.8760。Readability、trafilatura、resiliparse 和 newspaper4k 則是 11/11 都有回答。現在這張表把 precision 旁邊也放上分母,這樣拒答就不能躲在漂亮比例後面。
這還有一版更糟的情況。我的第一版 scorer 是在同樣 11 個「內容完整性」fixture 上平均文章召回率——也就是排除了沒有樣板內容的 fixture,這對衡量洩漏來說是正確的。那樣算出來,resiliparse 的 recall 是 1.0000。但如果看全部 22 個 fixture,它其實只有 0.9054,因為在某個文章完全放在 <li> 裡、完全沒有任何 <p> 的 fixture 上,它雖然有輸出,卻把 6 個文章單元中的 0 個 抓回來。那個 fixture 沒有樣板內容,所以被排除在平均之外,一個真實失敗就這樣躲在完美分數後面。
每個工具實際會在哪裡壞掉
| Fixture | 測試的是什麼 | 哪些工具抓不到任何內容 |
|---|---|---|
文章全部在 <li> 裡,沒有 <p> | 結構假設 | resiliparse (0/6)、goose3(無輸出) |
| 單一 129 字元的文章單元 | 短內容門檻 | jusText |
| 十個短段落,沒有長段落 | 短內容門檻 | jusText |
| 幾乎空白的文件 | 真正的空值邊界 | goose3、jusText |
這些都不是籠統的「萃取能力比較差」,而是具體、可重現的行為:
- resiliparse 和 goose3 都假設內容是段落。 你把它們指向一個正文是清單的頁面——像 changelog、spec、FAQ、食譜——resiliparse 會回傳文字,但完全沒有清單內容;goose3 則直接什麼都不回。resiliparse 在這裡更危險,因為它回一些東西,看起來像成功。
- jusText 有一道長度懸崖,而且非常陡。 下面會再講。
- 幾乎空白的文件本來就是少數情況下「什麼都不回」反而合理的案例,所以我不會因此怪任何一個函式庫。
jusText:不是斜坡,而是懸崖
jusText 在 22 個 fixture 中有 19 個有輸出,並且洩漏了 47% 的樣板內容——這是本次測試中的最高值,和它的名聲剛好相反。但真正有趣的數字,是那個讓我整批重跑的數字。
jusText 會先用語言停用詞表,根據 stopword 密度把每個區塊分級,然後再做一輪具上下文感知的處理:只有當 neargood 區塊旁邊已經有 good 區塊時,它才會被提昇成 good。一個區塊要自己直接變成 good,必須超過 length_high,預設是 200 個字元。如果文件裡沒有任何一段跨過這條線,就沒有東西能當種子,整個頁面最後就會一路退化成樣板內容。
我用一篇最長段落只有 151 個字元的文件來掃這個值:
length_high | 被判定為 good 的段落數 | 回傳字元數 |
|---|---|---|
| 200(預設) | 0 | 0 |
| 150 | 8 | 832 |
| 120 | 8 | 832 |
| 100 | 8 | 832 |
| 80 | 8 | 832 |
當只有一個段落跨過門檻時,輸出會從 0 直接跳到 832 字元;再怎麼繼續放寬,結果都不變。只要有一段過線,整份文件就被解鎖。
在得出這個結論前,我也把 length_low 掃過四個值、把 max_link_density 掃過兩個值——八種組合全部都還是 0。這個專案的規則是:如果要主張某個功能在負面情境下失效,至少要試三種參數型態,或者要有供應商自己標出的錯誤訊息;而只測到一個沒結果的參數,還不足以構成對函式庫的結論。數字可見 justext-length-threshold.json。
這並不是在說 jusText 萃取得差。在一個真實、自然語言寫成的頁面上、用預設值測試時,它回傳了 1,190 個字元的乾淨文章文字。這只是說,jusText 有一個公開的調整旋鈕,而那個旋鈕的預設位置,對短段落文件來說是錯的。
安裝什麼,以及匯入要花多少成本

同樣的 fixture、同樣的機器、每個函式庫都放在自己的空白 virtualenv 中。
| 函式庫 | 套件數 | site-packages | 冷啟動 import | 萃取 p50 |
|---|---|---|---|---|
| resiliparse | 5 | 21.0 MiB | 0.015 s | 0.06 ms |
| jusText | 3 | 22.4 MiB | 0.777 s | 0.56 ms |
| goose3 | 16 | 44.3 MiB | 2.181 s | 1.85 ms |
| newspaper4k | 22 | 47.5 MiB | 2.812 s | 2.69 ms |
| trafilatura | 17 | 69.9 MiB | 1.584 s | 0.51 ms |
| Readability + jsdom | 32 (npm) | 26 MiB | 0.473 s | 6.37 ms |
這次測試裡,resiliparse 的冷啟動與中位數萃取值都是最低:15 ms 和 0.06 ms。如果把跨 runtime 的比值算得太精準,會超出這套不完整測試流程能支持的範圍,尤其它最慢的一次單次萃取高達 1,098 ms。若要拿這些數字來估 serverless 容量,還需要分開看啟動、首次呼叫與穩態分布。
trafilatura 和 resiliparse 在品質上幾乎打平——內容 token F1 是 0.9697 對 0.9681,洩漏率也同樣是 0.0588——我不會因為這麼小的差距就宣布誰贏誰輸。但在 footprint 上差很多:21.0 MiB 對 69.9 MiB,5 個套件對 17 個套件。你真正交換的,是 resiliparse 對清單型版面不敏感,換來 trafilatura 多了三個額外依賴。
我自己的測試環境,在發文前先找出了兩個 bug
上面的比較,差點根本做不出來;而原因比表中的任何一列都更值得記錄。
這組 fixture 一開始其實看不見六個函式庫中的兩個。 原始 fixture 把每個單元都寫成一串獨特的亂數 token——像 zzart01vf64 zzart01v56i——這正是它能精確算 recall 的原因。但這也代表 fixture 裡完全沒有英文功能詞。Readability、trafilatura 和 resiliparse 是根據 DOM 結構做判斷,所以不受影響。goose3 和 jusText 則是根據詞彙、靠 stopword 計數做判斷,而 fixture 裡沒有任何 stopword 可數:兩者在 22 個 fixture 上都只回傳空字串。
如果直接放一張兩個函式庫都拿 0 分的表,看起來會很有權威,其實什麼都不是。我先在真實頁面上確認過:goose3 回了 1,017 個字元,jusText 回了 1,190 個字元。函式庫本身沒問題;是測試環境無法表現它們。
所以我把 fixture 重建成:保留 sentinel,但用英文散文承載它們——結構不變、class 不變、DOM 位置不變、單元邊界不變、sentinel 不變,只是一對一替換了 1,568 個 token。goose3 的分數因此從 0 變成 22 個中的 20 個。
接著重建又把另外兩件事弄壞了,而且那也是我造成的。 一個英文單字大概 6 個字元;zzart01vf64 大概 12 個字元。這種一對一替換把每個單元的長度幾乎砍半——單元文字總長從 21,646 個字元變成 10,986 個,最長單元也從 1,513 變成 622。這悄悄改寫了那些核心就是長度的 fixture。jusText 的行為本來就像一道長度懸崖,結果只因為這個改動,就從 22 個中的 19 個掉到 6 個。要是我把這個砍半版發出去,jusText 的數字會錯得像三倍一樣,而且是朝著「看起來更差」的方向錯。
第二個問題:從同一份共享語料重建後,stopword density 回來了,但 token-level scoring 所依賴的性質被破壞了。文章和樣板內容的詞彙必須彼此不重疊,不然「抽出的 token 是樣板 token」這種計算就會把 the 也算進去。22 個 fixture 裡有 10 個最後出現詞彙重疊,而原始版本是 0。修正的方法是:每個單元的內容詞都加上後綴,但功能詞維持原樣——讓 lexical 類型的函式庫有真正的 stopword 可以數,讓 scorer 看到的是彼此分離的內容詞彙。
這也就是為什麼這裡的 token-level 欄位叫 content_token_*,而不是沿用公開版 Readability 對 trafilatura 數字中的欄位名稱。那是不同的量,測的是內容詞,而把一個拿去冒充另一個,就是錯的。
在重建過程中,還有一件事不是我造成的:三個 link-density fixture 把 </a> 放在單字中間 —— <a href="/x">zzsibp015qlhf zzsi</a>bp015qbht —— 因為 anchor 是用字元偏移定位,才能對到精確比率。畫面上顯示出來的文字不變,所以原本的 scoring 根本沒注意到;但任何按 element 而不是按 text run 做處理的萃取器,會把這裡看成兩段,而不是一個字。這已經修好,並且把 linked-character delta 記錄下來,而不是默默吞掉。
誰適合用哪個
要餵給模型,而且是按 token 計費? 選 newspaper4k 或 goose3。兩者都沒有漏進任何樣板單元,也沒有污染 token。newspaper4k 適合你想要每個頁面都一定有答案;goose3 則適合你寧可沉默也不要猜,而且你的頁面本來就有段落。
想優化 Python 路徑上的延遲? 把 resiliparse 納入評比。這裡它的 import 與中位數萃取都是最低,而且在品質上和 trafilatura 很接近——但前提是要開 main_content=True,而且這不是預設值。先檢查清單很多的版型,也不要把這些本地時間直接拿去當跨 runtime 的精準速度比。
要做歸檔,或任何「漏掉內容比多抓內容更糟」的工作? 用 Readability。它是唯一一個在所有 fixture 上都完整抓回每個文章單元的工具,35 個多出來的 token 價格很低,因為替代方案是少掉一整段文章。
多語言工作? 可以把 jusText 納進候選名單,因為它有附語言 stoplist。這次研究沒有測多語言萃取,所以這個功能只是值得你去評估,不代表它會贏。請拿代表性的段落長度去測 length_high。
不是文章的內容呢? 這些都不適合。它們都建立在「頁面只有一個主要正文」這個假設上;商品列表、搜尋結果頁、儀表板,會用完全不同的方式把這個假設打破,而且沒有任何參數能補救。
託管 API 的位置在哪裡
上面談的全部都是你自己跑的函式庫:你提供 HTML,它回你文字。它們的失敗模式會隨頁面形狀而變,所以要先用你的語料驗證預設值。結構化欄位萃取、以及抓取 / 渲染,本來就不在這次比較範圍內。
作者註記: Thunderbit 是我們提供的託管服務,適合 URL 輸入的工作流程與結構化輸出。它沒有跑這些 fixture,所以不代表我們在做品質比較。真正要分的,是你手上已經有 HTML、想在本地做文字萃取,還是想把抓取、渲染與營運交給服務處理。
更誠實的說法是:如果你已經有 HTML,只是想拿到文字,這六個工具裡有一個是免費而且好用的,而這張表告訴你是哪一個。如果你是在大規模抓頁面,或者你想要的是資料列而不是散文,那就是另一種採購了。
如果你是在 hosted fetcher 之間做選擇,我們的 web scraping API roundup 有整理這個領域;SEO 與資料 API 的成本比較 則整理了它們的收費。自架部分則可看 開源 scraper 總覽;如果你真正需要的是 Markdown 而不是純文字,請看 用 Python 將 HTML 轉成 Markdown,因為大部分損失都發生在那裡。
結論
沒有真正的贏家;如果有一張表硬要選出贏家,那就是在對一個真實取捨說謊。
在你選工具前,先建立一小份驗收語料:要包含只有清單的文章、短段落、促銷 sibling、幾乎空白的頁面,以及那些「什麼都不要抓」比「抓到污染內容」更好的例子。文章回收、樣板洩漏和拒答要分開評分。在這些 fixture 上,Readability 偏向召回率,newspaper4k 則是最均衡的一列,而 resiliparse 是一個低延遲候選,但對清單內容有盲點;在沒有用你的頁面形狀驗證之前,這些標籤都不該直接外推。
我真正會跟你說的,其實比這些更窄:先用你自己的頁面形狀跑一次 fixture,再決定。當初我手上那套測試環境裡,有兩個函式庫根本看不到;而其中一個還拿到完美 recall,卻把整體失敗藏了起來。比較表只是起點,不是答案。
試試 Thunderbit 進行網頁資料擷取 Get Started Free
常見問題
這些數字能和這些函式庫公開的 benchmark 直接相比嗎? 不能,我也不會那樣引用。這些是受控 fixture,使用的是合成但有標註的單元,所以六個工具看到的位元組完全相同,彼此比較是公平的。像 scrapinghub 的 article-extraction benchmark 這類公開數字使用的是實際世界語料,測的是另一個也更難的問題。這張表是用來比較這六個工具彼此之間,不是拿來跟論文中的數字硬對。
如果 Readability 和 trafilatura 都是基於 DOM,為什麼 Readability 的洩漏率高那麼多? 因為兩者切邊界的位置不同。Readability 的 4 個漏抓裡,有 3 個都是中性分類的促銷區塊,位在文章 sibling 旁邊;它的 sibling-append heuristic 會因為「相鄰、長、低連結密度的內容大概也是故事的一部分」這個假設把它們一起吃進去。很多時候這個假設是對的,但在這些 fixture 裡,它們就是促銷區塊。trafilatura 對於要不要 append 更保守,所以只漏了同一類單元中的 1 個。
我該相信 goose3 和 jusText 的 precision 數字嗎? 可以,但前提是一定要一起看樣本數。它們都只在 11 個同時包含文章與樣板單元的 fixture 中,有 10 個有分數,因為其中一個 fixture 它們直接沒有輸出;而沒有輸出的 fixture 不會對比例的任一邊產生貢獻。goose3 的 1.0000 precision 對它有回答的頁面來說是真的;它在全部 22 個 fixture 上的 0.8243 recall 則是同一件事的另一面。
jusText 的長度門檻在真實頁面上重要嗎?
完全取決於你的段落長度。新聞文章如果每段都有 300 個字元,那第一段就會跨過 length_high,表現正常——這也是為什麼 jusText 在真實頁面、預設值下可以回傳 1,190 個乾淨字元。短段落、清單項目或商品簡介頁面,可能永遠跨不過去,最後 jusText 就會直接回空字串,而不是回傳半成品。最好明確設定,而不是等到上線後才發現。
這裡沒有測什麼? 完全沒有測真實世界頁面。也沒有測多語言萃取,儘管 jusText 的 stoplist 正是它的主打賣點。沒有測高負載下的記憶體。任何不是文章的頁面——商品列表、搜尋結果、儀表板——都沒測。也沒有處理編碼邊界案例。再者,Node 和 Python 兩個生態系是拿函式庫行為在比,不是拿 runtime 性能在比,所以跨過 runtime 邊界的毫秒數,應該當作數量級參考,而不是精準比例。


