六个库、一个带标注的样本集、一个评分器。在这 22 个合成样本里,模板内容泄露率最高的库——Mozilla 的 Readability,达到 23.5%——同时也是唯一一个把所有标注的文章单元都找回来的。
一句话就能概括这种取舍,但关于这个话题的大多数文章都不会把它讲出来,因为它们通常只算精确率,然后就停了。
实际测量了什么
这组样本里的每个 fixture 都带有按单元标注的真值。页面中的每个区块——文章段落、导航栏、广告、侧边栏、评论线程、推广模块——都被标记为 article 或 boilerplate,并附带一个唯一的哨兵 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 数 |
|---|---|---|---|---|---|
| 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 |
召回率是基于全部 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 |
| 几乎空白的文档 | 真正的空边界 | goose3、jusText |
这些都不是泛泛的“提取能力更差”,而是具体、可复现的行为:
- 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(默认) | 0 | 0 |
| 150 | 8 | 832 |
| 120 | 8 | 832 |
| 100 | 8 | 832 |
| 80 | 8 | 832 |
只要有一段超过阈值,结果就会从 0 直接跳到 832 字符;再怎么继续放宽,结果都不变。也就是说,只要有一个段落越线,就会把整篇文档“解锁”。
不过在下这个结论前,我还把 length_low 试了 4 个值、max_link_density 试了 2 个值——一共 8 种组合,全都返回 0。这个项目的规则是:想证明某个负面能力,至少要从 3 种参数形态去试,或者得有厂商自己报出来的字段错误;而一个参数无效,并不能说明整个库有问题。数字都在 justext-length-threshold.json 里。
这并不是说 jusText 抽得差。放到一个真实的自然语言页面上,默认设置下它返回了 1,190 字符的干净文章文本。它真正说明的是:jusText 有一个公开可调的参数,行为像开关,而这个开关的默认位置,对短段落文档来说并不合适。
安装了什么,以及导入要付出什么代价

相同的 fixture、相同的机器,每个库都装在自己空白的虚拟环境里。
| 库 | 包数量 | site-packages 体积 | 冷启动导入 | 提取 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。但直接拿跨运行时的比值来解读,会超出这套并不完整的流程能支撑的范围,尤其是它单次最慢提取到了 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 才是大多数损失发生的地方。
结论
没有赢家;如果有一张表硬要写出赢家,那就是在撒谎,因为它会掩盖真实的取舍。
在做选择之前,先建一个小型验收语料库:包括只含列表的文章、短段落、推广兄弟节点、几乎空白的页面,以及“返回空内容比带污染更好”的例子。把文章找回、模板内容泄露和拒答分别评分。在这些 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 生态之间比较的是库行为,不是运行时性能,所以跨边界的毫秒数据只能当作数量级看,而不能当作精确比值。


