在这组测试样本里,泄露最多模板内容的提取器,反而一个内容都没漏掉

最后更新于 August 14, 2026
在这组测试样本里,泄露最多模板内容的提取器,反而一个内容都没漏掉
AI 摘要
Six libraries, one annotated fixture set, one scorer. In these 22 synthetic fixtures, the library with the highest boilerplate leak — Mozilla's Readability, at 23.5% — was also the only one that recovered every labelled article unit. That is the trade-off in one sentence, and most write-ups on this subject never surface it, because most of them score precision and stop. Feeding a model and paying per token? newspaper4k or goose3. Both leaked zero boilerplate units and zero contaminating tokens. newspaper4k if you want an answer on every page; goose3 if you would rather have silence than a guess, and your pages have paragraphs.

六个库、一个带标注的样本集、一个评分器。在这 22 个合成样本里,模板内容泄露率最高的库——Mozilla 的 Readability,达到 23.5%——同时也是唯一一个把所有标注的文章单元都找回来的。

一句话就能概括这种取舍,但关于这个话题的大多数文章都不会把它讲出来,因为它们通常只算精确率,然后就停了。

实际测量了什么

这组样本里的每个 fixture 都带有按单元标注的真值。页面中的每个区块——文章段落、导航栏、广告、侧边栏、评论线程、推广模块——都被标记为 articleboilerplate,并附带一个唯一的哨兵 token。所以,“提取器有没有把这个单元抓出来”判断的是严格的子串包含关系,而不是相似度分数。一个 sentinel 要么还留在输出里,要么就没了。

22 个 fixture,91 个单元。6 个提取器: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、准确调用方式、重复次数或预热策略,所以时间列更像一次本地观察,而不是可直接迁移的基准测试。

在任何测试开始前,我先给自己定了两条规矩。每个 Python 库都装进自己空白的虚拟环境里,这样它的依赖体量就是它自己的,不会继承同级库装进来的东西。还有,任何 runner 都不负责算指标——每个工具只输出原始提取文本,由同一个评分器统一产出所有数字。这样,6 个工具比较的是同一套算法,而不是 6 套看起来相似、其实定义各不相同的“精确率”。

头部表格

文章召回率(全部 22 个)模板内容泄露率内容 token 精确率有输出的精确率样本数污染 token 数
Readability1.00000.23530.910911/1135
trafilatura0.98650.05880.941111/114
newspaper4k0.98650.00000.945211/110
resiliparse0.90540.05880.938111/117
jusText0.83780.47060.876010/1174
goose30.82430.00001.000010/110

召回率是基于全部 22 个 fixture 汇总计算的。泄露率、汇总内容 token 精确率和污染率使用的是那 11 个同时包含 article 和 boilerplate 单元的 fixture;“有输出”表示在这 11 个中有多少个真的产出了结果。完整的逐 fixture 数字见 sixway-scores.json

这张表里有一行并不是默认行为。 resiliparse 的 extract_plain_text 默认参数是 main_content=False,而我调用时传了 main_content=True。两者差别很大:在默认设置下,它会把整套测试中的 17/17 个 boilerplate 单元都泄露出来——所有导航、广告、侧栏、评论线程和推广模块——而打开这个参数后只剩 1/17。上面其他库都用的是默认调用。所以,resiliparse 的 0.0588 泄露率,是它在你明确要求主内容时的表现;而单独调用 extract_plain_text(html),其实是另一个产品(default-vs-main-content.json)。

读第一列和第二列要放在一起看,因为只看其中任何一列,都会误判工具。

Readability 从不漏抓。 在全部 22 个 fixture 上召回率都满分,而且是唯一一个做到这一点的。代价也很明确:17 个 boilerplate 单元里漏进来 4 个,污染 token 35 个,泄露率是 trafilatura 的 4 倍。它的 4 次泄露里,有 3 次形态都一样——一个被标成中性类的推广块,作为文章的兄弟节点被包进去了,因为它的“兄弟节点追加”启发式会把这类内容吞进来。你如果把它的输出喂给模型,就等于为这些 token 付费,而模型也会把它们当作文章来读。

newspaper4k 是最均衡的那个。 零泄露、零污染 token、0.9865 的召回率,而且在全部 22 个 fixture 上都有输出。要是让我在不知道具体工作负载的情况下只选一个,我会选它;而且它并不是大多数人第一时间会想到的那个。

goose3 的精确率是满分,但召回率是测试里最差的。 它返回的每一个内容词都确实来自文章内容。但它在两个 fixture 上什么都没抓到,而且在这两个 fixture 上也都没有输出。只要你有权选择沉默,满分精确率其实很便宜。

让两个库“看起来很准”的那个精确率数字

上面这一点值得讲得更具体,因为这几乎让我把一个误导性的结论发出去。

这里的精确率和 F1 都是“在有输出的前提下”计算的。某个库如果在某个 fixture 上返回空字符串,它既不进入分子,也不进入分母——所以选择不回答是没有成本的,保守型提取器的精确率也就会因为“沉默”而显得比彻底型提取器更漂亮,哪怕两者本质上没有区别。

goose3 在那 10 个有输出的 fixture 上,汇总精确率是 1.0000。jusText 在 11 个里有 10 个有输出,精确率是 0.8760。Readability、trafilatura、resiliparse 和 newspaper4k 都是 11/11 有输出。现在表格里把这个分母也放在精确率旁边,这样“没有回答”就不能再躲在一个好看的比率后面。

其实还有一个更糟的版本。我的第一个评分器,是在那 11 个只看内容保真度的 fixture 上算文章召回率的——也就是排除了那些没有 boilerplate 的 fixture,而这对于衡量泄露是正确的。它给 resiliparse 算出了 1.0000 的召回率。但如果看全部 22 个 fixture,resiliparse 其实是 0.9054,因为在那个文章完全放在 <li> 元素里、没有任何 <p> 的 fixture 上,它虽然有输出,却把 6 个 article 单元全都漏掉了。那个 fixture 没有 boilerplate,所以它被排除在平均值之外,一个真实失败就这样藏在了满分下面。

每个工具真正会在哪儿出问题

Fixture测试什么谁一个都抓不回来
文章完全在 <li> 中,没有 <p>结构假设resiliparse(0/6)、goose3(无输出)
单个 129 字符的文章单元短内容阈值jusText
10 个短段落,没有长段落短内容阈值jusText
几乎空白的文档真正的空边界goose3jusText

这些都不是泛泛的“提取能力更差”,而是具体、可复现的行为:

  • resiliparse 和 goose3 都默认文章是段落。 你把它们丢到一个主体内容是列表的页面上——比如 changelog、规范文档、FAQ 或食谱——resiliparse 会返回文本,但完全没有列表内容;goose3 则直接什么都不给。这里 resiliparse 更危险,因为“有东西返回”很容易让人误以为成功了。
  • jusText 有一道长度断崖,而且非常陡。 下面会展开说。
  • 几乎空白的文档本来就有可能返回空,这种情况下我不会把它算作问题。

jusText:不是斜坡,而是断崖

jusText 在 22 个 fixture 里有 19 个有输出,而且泄露了 47% 的 boilerplate——这是全场最高,和它的口碑正好相反。但真正有意思的是那个让我重跑了整套测试的数字。

jusText 先根据某种语言停用词表的停用词密度给每个 block 分类,然后再跑一遍上下文敏感的过程:只有当一个 neargood block 紧挨着一个已经是 good 的 block 时,它才会被提升成 good。一个 block 要想自己单独变成 good,必须超过 length_high,默认值是 200 字符。只要文档里没有任何段落跨过这条线,就不会有任何东西去“种下”这个提升,整页就会一起退化成 boilerplate。

我在一个最长段落只有 151 字符的文档上扫了一遍:

length_high良好段落数返回字符数
200(默认)00
1508832
1208832
1008832
808832

只要有一段超过阈值,结果就会从 0 直接跳到 832 字符;再怎么继续放宽,结果都不变。也就是说,只要有一个段落越线,就会把整篇文档“解锁”。

不过在下这个结论前,我还把 length_low 试了 4 个值、max_link_density 试了 2 个值——一共 8 种组合,全都返回 0。这个项目的规则是:想证明某个负面能力,至少要从 3 种参数形态去试,或者得有厂商自己报出来的字段错误;而一个参数无效,并不能说明整个库有问题。数字都在 justext-length-threshold.json 里。

这并不是说 jusText 抽得差。放到一个真实的自然语言页面上,默认设置下它返回了 1,190 字符的干净文章文本。它真正说明的是:jusText 有一个公开可调的参数,行为像开关,而这个开关的默认位置,对短段落文档来说并不合适。

安装了什么,以及导入要付出什么代价

Measured results chart: Install footprint vs cold import

相同的 fixture、相同的机器,每个库都装在自己空白的虚拟环境里。

包数量site-packages 体积冷启动导入提取 p50
resiliparse521.0 MiB0.015 s0.06 ms
jusText322.4 MiB0.777 s0.56 ms
goose31644.3 MiB2.181 s1.85 ms
newspaper4k2247.5 MiB2.812 s2.69 ms
trafilatura1769.9 MiB1.584 s0.51 ms
Readability + jsdom32(npm)26 MiB0.473 s6.37 ms

这次测试里,resiliparse 的冷启动和中位提取时间都是最低的:15 ms0.06 ms。但直接拿跨运行时的比值来解读,会超出这套并不完整的流程能支撑的范围,尤其是它单次最慢提取到了 1,098 ms。要把这些数字用于 serverless 规格规划,还需要分别看启动、第一次调用和稳定态分布。

trafilatura 和 resiliparse 在质量上几乎打平——内容 token F1 是 0.9697 对 0.9681,泄露率也同样是 0.0588——我不会因为这么小的差距就硬判胜负。但在体积上,它们差得很远:21.0 MiB 对 69.9 MiB,5 个包对 17 个包。你真正做出的取舍,其实是 resiliparse 的“看不见列表”能力,换 trafilatura 多出 3 个依赖。

我自己的测试系统里,在发布前发现的两个 bug

上面的对比差点就发不出来,而原因比表格里的任何一行都更值得一提。

最初的 fixture 集,看不见这 6 个库中的 2 个。 原始 fixture 把每个单元都写成一串独特的乱码 token——像 zzart01vf64 zzart01v56i——这正是让召回率可以做精确比较的原因。但这也意味着 fixture 里根本没有英文功能词。Readability、trafilatura 和 resiliparse 是按 DOM 结构决定的,所以不受影响。goose3 和 jusText 是按词汇决定的,会统计停用词;可里面没有停用词可统计,所以这两个库在全部 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 的数字就会被错误地拉低三倍,而且方向还是那种会让它看起来更差的错误。

第二个问题:所有单元都从同一个共享语料里取词,确实恢复了停用词密度,但也破坏了 token 级评分依赖的一个关键属性。文章 token 和 boilerplate token 必须彼此不重叠,否则“被提取出来的 token 里包含 boilerplate token”这种统计就会把 the 也算进去。22 个 fixture 里有 10 个最终出现了词汇重叠,而原始版本是 0 个。修复办法是:给每个单元的内容词加后缀,同时把功能词保持原样——这样既能让词法型库有真正的 stopword 可数,又能让评分器拿到不重叠的内容词表。

这也就是为什么这里的 token 级列都叫 content_token_*,而不是直接沿用之前公开的 Readability 对 trafilatura 那组数字。它们不是同一个量,统计对象只限于内容词;把一个数说成另一个,就是错的。

重建过程中,还发现了一个不属于我的问题:3 个 link-density fixture 把 </a> 插在了一个单词中间——<a href="/x">zzsibp015qlhf zzsi</a>bp015qbht——因为这个 anchor 是按字符偏移放进去的,用来卡一个精确比例。渲染后的文本并没有变化,所以原始评分没发现,但任何按元素而不是按文本片段工作的提取器,看到的就是两个碎片,而不是一个词。这个问题已经修复,并且把链接字符偏移的变化记录了下来,没有悄悄吞掉。

谁适合用哪个

给模型喂数据,而且按 token 计费? 选 newspaper4k 或 goose3。它们都没有泄露 boilerplate 单元,也没有污染 token。newspaper4k 适合你希望每个页面都有结果;goose3 适合你宁愿没有答案也不要猜测,而且你的页面是段落型的。

优化一个对延迟很敏感的 Python 流程? 先把 resiliparse 加进对比。它在这次测试里有最低的导入时间和中位提取时间,而且质量上和 trafilatura 很接近——但前提是你要显式打开 main_content=True,这不是默认值。先检查列表型布局,不要把这些本地时间直接换算成精确的跨运行时速度比。

做归档,或者任何“漏内容比多带点内容更糟”的场景? 选 Readability。它是唯一一个在所有 fixture 上都找回了全部文章单元的库,如果代价只是 35 个多余 token,那比丢掉一整段要便宜得多。

需要多语言? 可以把 jusText 作为候选,因为它自带语言停用词表。本研究没有测试多语言抽取,所以这只能算一个值得评估的理由,不是它赢了的证据。要拿 length_high 去对照你真实的段落长度。

任何不是文章的页面? 这些库都不适合。它们都默认页面有一块主文本,而商品列表、搜索结果页或仪表盘都会把这个假设打穿,任何参数都补不回来。

托管 API 在哪里合适

上面说的全都是你自己运行的库:输入 HTML,输出文本。它们的失败模式会随页面形状变化,所以必须用你的语料去验证默认值。结构化字段提取、抓取和渲染,不在这次比较范围内。

作者注: Thunderbit 是我们用于“URL 输入→结构化输出”工作流的托管服务。这次没有把它放进这些 fixture 里跑,所以这里不暗示任何质量对比。真正该做的判断是:你手里已经有 HTML,只想本地提取文本,还是希望抓取、渲染和运维都交给服务处理。

更诚实的说法是:如果你已经拿到了 HTML,并且只想要文本,这 6 个库里总有一个免费又好用,而这张表能告诉你该选谁。如果你要大规模抓页面,或者你要的是表格而不是散文,那就是另一笔采购了。

如果你是在比较托管抓取器,我们的 web scraping API roundup 覆盖了这个领域;我们的 SEO 和数据 API 成本对比 则整理了它们的收费。如果你更关心自托管方案,开源爬虫总览 会给你更大的视角;而如果你真正需要的是 Markdown 而不是纯文本,那么 Python 中把 HTML 转成 Markdown 才是大多数损失发生的地方。

试用 Thunderbit 进行网页数据提取

结论

没有赢家;如果有一张表硬要写出赢家,那就是在撒谎,因为它会掩盖真实的取舍。

在做选择之前,先建一个小型验收语料库:包括只含列表的文章、短段落、推广兄弟节点、几乎空白的页面,以及“返回空内容比带污染更好”的例子。把文章找回、模板内容泄露和拒答分别评分。在这些 fixture 上,Readability 更偏召回,newspaper4k 的表现最均衡,而 resiliparse 是一个有延迟优势、但会看漏列表内容的候选;这些标签一旦离开测试过的页面形态,就不能直接外推。

我真正想告诉你的,比这些都更窄一点:在你自己的页面形状上跑一遍 fixture,再决定选谁。最开始那套测试环境里,有 2 个库根本看不见样本集,而其中一个还拿了个掩盖彻底失败的满分召回率。比较表只是起点,不是答案。

试用 Thunderbit 进行网页数据提取 Get Started Free

常见问题

这些数字能和这些库公开的基准测试直接比较吗? 不能,我也不会那样引用。这里是带标注单元的受控合成 fixture,所以 6 个库看到的字节完全一样,它们之间的比较是公平的。但像 scrapinghub 的 article-extraction benchmark 这类公开结果用的是现实语料,衡量的是另一件更难的事。这个表只能用来比较这 6 个库彼此之间,不要拿它去和论文里的数字对照。

为什么 Readability 和 trafilatura 都是基于 DOM,Readability 的泄露率却高这么多? 因为它们划边界的方式不同。Readability 的 4 次泄露里,有 3 次都是被标成中性的推广块,作为文章的兄弟节点存在;它的 sibling-append 启发式认为,紧挨着文章、链接密度又低的长内容,大概率也属于正文。很多时候确实如此。但在这些 fixture 里,那就是推广内容。trafilatura 对“追加什么”更严格,只漏了其中一个单元。

我该信 goose3 和 jusText 的精确率吗? 可以,但前提是连样本数一起看。它们都只在那 11 个同时包含 article 和 boilerplate 的 fixture 里的 10 个上打了分,因为有一个 fixture 它们什么都没输出,而没有输出的 fixture 既不进分子也不进分母。goose3 的 1.0000 精确率在它有回答的页面上是真实的;但它在全部 22 个 fixture 上只有 0.8243 的召回率,这只是同一事实的另一面。

jusText 的长度阈值在真实页面上重要吗? 完全取决于你的段落有多长。新闻文章如果每段有 300 个字符,那么一开始就会跨过 length_high,行为正常——这也是为什么 jusText 在真实页面上默认能返回 1,190 字符的干净文本。可如果页面主要是短段落、列表项或商品简介,就可能永远跨不过去,然后 jusText 会直接返回空字符串,而不是给你一个不完整答案。与其上线后再发现,不如直接显式设置它。

这里没有测试什么? 根本没有真实世界页面。也没有测试多语言抽取,尽管 jusText 的停用词表正是它最大的卖点。没有测高负载下的内存。任何不是文章的页面——商品列表、搜索结果、仪表盘——都没测。也没有测编码边界情况。还有,Node 和 Python 生态之间比较的是库行为,不是运行时性能,所以跨边界的毫秒数据只能当作数量级看,而不能当作精确比值。

Ke
Ke
Thunderbit 首席技术官 | 高级数据科学家与机器学习专家 Ke Shen 拥有近十年的机器学习和数据科学经验,毕业于哥伦比亚大学,曾任 Walmart Labs 高级数据科学家。他在 Python、R、Java 和统计学方面拥有深厚且备受同行认可的专业能力,并分享如何将复杂的 AI 算法从理论落地到生产级架构的实战经验。
目录
Thunderbit · AI 网页数据代理

一键 内提取任意页面数据

深受 250,000+ 用户信赖
提供免费方案
从网页到表格
描述你的需求——Thunderbit 的 AI 代理会帮你抓取并导出到 Excel、Google Sheets、Airtable 或 Notion。可免费开始。
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week