在那些连“漏抓”都能明确界定的测试样本中,goose3 的内容 token 精确率达到 1.0000,而且没有混入任何污染 token。导航、广告、侧边栏、评论、推广文案,一个词都没混进去。六个库对比里,没有哪个能做到这一点。
但它也拿到了全场最差的文章召回率——在全部 22 个测试样本中只有 0.8243,而 Mozilla Readability 是 1.0000——原因是其中两个样本它直接返回了空字符串。
这两个指标会被同一条评分规则绑在一起:空结果不会给条件精确率贡献任何值,而召回率会把漏掉的样本记下来。
什么是 goose3
goose3 是 Python 3 版本的延续,最早可追溯到 Gravity Labs 在 Scala 里开发的 Goose,后来又经过 python-goose 这一支。它是一个带元数据的文章提取器,不是纯文本转储工具:你创建一个 Goose,调用 extract(),就会拿到一个 Article 对象,里面大约有二十八个可访问字段——清理后的正文、标题、作者、发布时间、头图、meta 描述、标签、链接、推文等等。
官方参考:goose3 官方仓库。

测试版本:3.1.22,Apache 许可,912 个 GitHub stars,最后一次推送时间是 2026-07-23——在测试时仍在积极维护。Python 3.14.2。
这个 API 只需要两步半:
from goose3 import Goose
g = Goose()
try:
article = g.extract(raw_html=html)
text = article.cleaned_text
finally:
g.close() # 使用后要显式关闭
这里特别要提一下 close(),因为它太容易忘了,而且不会有任何提醒。这次评测没有跑循环探测去量化如果不关闭会不会留下会话、连接或内存,所以直接说“资源泄漏”证据还不够硬。就这段 API 用法来看,显式关闭应当算是它的生命周期要求。
取舍到底是什么,数据说了算
六个提取器,一个标注过的样本集,一个评分器。每个样本里的每个区块都被标成 article 或 boilerplate,并带有独一无二的标记 token,所以“有没有恢复这个单元”判断起来是精确的子串匹配,而不是相似度分数。
| 库 | 文章召回率(全部 22 个) | 模板内容泄漏率 | 内容 token 精确率 | 污染 token 数 | 是否产出结果 |
|---|---|---|---|---|---|
| Readability | 1.0000 | 0.2353 | 0.9109 | 35 | 22/22 |
| trafilatura | 0.9865 | 0.0588 | 0.9411 | 4 | 22/22 |
| newspaper4k | 0.9865 | 0.0000 | 0.9452 | 0 | 22/22 |
| resiliparse | 0.9054 | 0.0588 | 0.9381 | 7 | 22/22 |
| jusText | 0.8378 | 0.4706 | 0.8760 | 74 | 19/22 |
| goose3 | 0.8243 | 0.0000 | 1.0000 | 0 | 20/22 |
sixway-scores.json。召回率是按全部 22 个样本统计;泄漏率和精确率则只看同时包含文章单元和模板单元的 11 个样本。
读精确率这一列时,一定要和最后一列一起看。这里的精确率是以“有输出”为前提的——某个库如果在某个样本上返回空字符串,它对分子和分母都不贡献任何东西,所以在这个平均值里“拒答”是免费动作。分子是提取出来的非停用词 token 与标注文章 token 的多重集合重叠;分母是所有提取出来的非停用词 token。“污染 token 数”统计范围更窄:只计算与标注模板 token 的重叠。某个额外抽出来的 token 如果既不属于标注文章,也不属于标注模板,会拉低精确率,但不会增加这个污染计数;超过文章多重集合数量的重复 token 也会造成同样效果。这就是为什么 newspaper4k 可以显示 0 个污染 token,但精确率仍然低于 1.0000。goose3 的 1.0000 是在 11 个样本里的 10 个上打出来的;Readability、trafilatura、newspaper4k 和 resiliparse 则是在 11/11 个样本上都参与了评分。
不过在这个合成样本集里,1.0000 仍然有意义。在那 10 个被评分的样本里,goose3 一点标注过的模板 token 都没吐出来;Readability 在同样的页面上吐出了 35 个。如果模型要消费这些输出,那就意味着这 10 个样本里它没有在模板内容上浪费 token。但这并不能证明真实网页上也绝对零浪费,而且空结果还可能在后续流程里引入兜底或重试成本。
那两次沉默,分别意味着什么
goose3 恰好在两个样本上什么都没返回。其中一个是可以理解的,另一个则是实打实的限制。
几乎空白的文档。 一个页面只有一个 32 字符的文章单元。goose3 选择不处理。jusText 也是如此。在这组样本里,Readability 在全部 22 个页面上都产出了结果,所以它的表现不能拿来支持 goose3 在这里“沉默”是合理的。是否应该拒绝这种极小文档,取决于调用方对最小内容长度的要求。

整篇文章全由 <li> 元素构成。 六个文章单元,没有一个放在 <p> 标签里。goose3 直接返回空字符串。
第二个结果让我挺意外,因为 goose3 默认配置里明明写着 parse_lists=True。所以我又做了三组配置对照,再加一个正常控制组——因为一次没跑通,并不能直接证明库有问题:
| 配置 | 仅列表页面 | <p> 控制组 |
|---|---|---|
| 默认值 | 0 字符 | 937 字符 |
strict=False | 0 字符 | 937 字符 |
parse_lists=True(显式设置) | 0 字符 | 937 字符 |
三种配置下全都是 0,而控制组三种情况下都能返回 937 个字符。也就是说,parse_lists=True 的作用,是决定列表能不能保留在已经找到的文章内部——它并不能让候选评分器把“列表本身”当成文章。goose3 的节点评分逻辑需要类似段落的块,才能把正文定位出来;如果一整页正文就是列表,它就找不到可用的正文块。
能被支持的结论更窄一些:像这个合成样本一样的正文——六个文章单元,全是 <li>,没有任何段落型候选——会返回空字符串。把变更日志、API 文档、食谱、FAQ 页面和对比类文章当成真实回放样本是合理的,因为它们可能列表很多;但单靠这一个样本,不能证明这些页面类型普遍都会失败。
只有当调用方检查“非空输出”时,空字符串才是机器可检测的失败。它比“看起来像文本、但其实一个字都没提到正文”更容易拦截,但如果监控只看异常,它依然属于静默失败。生产环境里,调用方必须做最小输出检查,并配一个兜底或明确的失败页记录。
安装和运行环境的真实情况
pip install goose3 会拉下 16 个包,占用 44.3 MiB,耗时大约 6 到 9 秒。冷启动导入在一个全新的子进程里测得:2.181 秒。
官方参考:PyPI 上的 goose3。
| 库 | 包数量 | 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 |
install-and-import.json。每个库都放在独立的空虚拟环境里,所以不会继承别的库的占用。
体积和速度都属于中游。它能在 Python 3.14.2 上干净安装并正常导入,这在这个类别里并不算理所当然。
上线前,先记住三个默认值

直接看随包附带的 Configuration 对象,而不是看文档,会发现一共有 19 个设置项。其中有三个,挺容易让人踩坑。
它会暴露自己。 browser_user_agent 的默认值是 Goose/3.1.22。如果让 goose3 自己去抓网页,所有被访问的服务器都会记录下库名和精确版本号。这很诚实,但也等于留了指纹。最好自己显式设置,或者自己先抓 HTML,再把 raw_html 传进去。
它默认指向 MacPorts 的二进制。 imagemagick_convert_path 默认是 /opt/local/bin/convert,imagemagick_identify_path 默认是 /opt/local/bin/identify。在我机器上这两个都不存在——/opt/local 属于 MacPorts,而大多数人并没有装;Homebrew 的二进制通常在 /opt/homebrew。默认值在不开启图片抓取时是无害的(enable_image_fetching 默认也是 False,这个设定很合理),但如果你以为一开就能做头图提取,那这里会悄悄失效。
它默认以英语为中心。 target_language 默认是 en,并且 use_meta_language=True,所以页面如果自己声明了语言,它会跟随;如果没有声明,就回退到英语。做英文内容没问题,处理其他语言时最好显式设置。
其余参数都算正常:parser_class 用的是 lxml,http_timeout 是 30 秒,strict 开着,log_level 是 ERROR,parse_headers 和 keep_footnotes 也都开着,images_min_bytes 是 4,000。
内存,以及坏 HTML 会把它折腾成什么样
这组评测里每篇都标了“未测试”的两件事,现在都测了。
更大的压力测试背景在这里:十库内存与畸形 HTML 对比。
峰值常驻内存 通过 /usr/bin/time -l 测量,每个单元都是一个全新的进程——导入基线是库在闲置时本身要占的内存,峰值则包含了文档本身。
| 库 | 运行时 | 导入基线 | 226 KB 峰值 | 10 MB 峰值 |
|---|---|---|---|---|
| html2text | python3.14 | 18.7 | 19.9 | 71.2 |
| pyquery | python3.14 | 30.3 | 33.9 | 172.5 |
| resiliparse | python3.14 | 20.5 | 25.1 | 225.1 |
| markdownify | python3.14 | 23.9 | 28.9 | 278.5 |
| goose3 | python3.14 | 44.1 | 52.4 | 398.5 |
| cheerio | node22 | 66.8 | 76.5 | 398.5 |
| justext | python3.14 | 30.3 | 36.6 | 431.2 |
| newspaper4k | python3.14 | 52.6 | 61.8 | 668.5 |
| trafilatura | python3.14 | 52.5 | 64.8 | 927.1 |
| turndown | node22 | 47.8 | 68.4 | 2947.1 |
memory-results.json。Python 和 Node 的基线彼此不能直接比较;两边都包含了解释器本身。
goose3 的基线是 44.1 MiB,在 10 MB 文档上峰值到 398.5 MiB。放在这里展示的 Python 库里,它的导入基线排第三高,这对冷启动敏感的部署来说值得注意。
畸形 HTML。 十二个文档,每个都只破坏一件事——未闭合标签、嵌套错乱的行内元素、带空格却没加引号的属性、乱入的闭合标签、完全没有 <html>、重复属性、在标签中间截断的文档、错误实体、未闭合的 <script>、撒谎的 charset 声明、包含标记的注释,以及 600 层嵌套——再加上 两个同尺寸的正常控制样本。因为“它什么都没返回”只有在库对同样大小的干净文档也不响应时,才能说明它对畸形 HTML 的处理真的有问题。
goose3 在 14 个样本里0 个报错,在 10 个里返回空结果,在这些畸形样本上总共恢复了 2/33 个标记物(malformed-results.json)。有一个样本不计入这个统计:按照 HTML5 规范,未闭合 <script> 后面的内容都算脚本内容,所以在那里丢掉它是正确的,反而把它恢复出来才算偏差。
优缺点总结
优点。 在 10 个内容保真样本里,只要它有输出,就没有一个标注过的模板 token 混进去。虽然大约二十八个文章字段都能拿到,但准确率只是做了盘点,没有单独打分。图片抓取默认关闭,这个设计合理。Python 3.14 可干净安装。仍在维护。Apache-2.0 许可。如果调用方明确检查,空结果很容易拦下。
缺点。 全场最低的文章召回率,只有 0.8243,而且原因全是“完全没返回”,不是“返回错了”。文章是列表的页面,不管什么配置都会输出空字符串。44.3 MiB 和 2.2 秒冷启动放在 resiliparse 的 21.0 MiB 和 15 毫秒旁边就显得很重。需要手动 close()。两个默认项指向了多数机器上都不对的路径。
适合谁,不适合谁
适合用 goose3 的场景,是把提取出的正文送进模型或数据库,而且标注过的模板内容代价很高,页面又是常规的段落型文章。这个样本集里,只要它回答了,就没有吐出标注过的模板 token。元数据层是有的,但这里没有验证;标题、作者、日期和头图的准确率,还需要单独的真值样本来判断,不能直接当成选型优势。
不适合用它 的场景,是你的语料里列表型内容很多——那你会拿到空字符串,而且没有任何解释。冷启动成本重要的话也别选它,resiliparse 的导入速度快了 145 倍。还有,如果你要求每一页都必须有结果,那它也不合适,因为“没有答案”在这里就是一个真实结果:22 个样本里有 2 个,而且都没有报错,只是静默返回了空字符串。
值得试的搭配方式: 用 goose3 做主提取器,当 cleaned_text 为空,或者低于你设定的最小内容阈值时,再走 fallback。Readability 在这 22 个样本里把所有文章单元都恢复出来了,包括 goose3 那两个空结果样本。这个合成结果支持的是这种架构模式,而不是承诺 fallback 在真实网页上永不漏抓。
托管 API 适合放在哪里
这次评测测试的是 goose3 的 raw_html 提取路径:也就是说,HTML 在 goose3 看到之前就已经先拿到了。goose3 也有自己的网络抓取路径,从它的 User-Agent 设置就能看出来,但这里没有测。JavaScript 渲染和反爬行为也都没测。
如果想看六个提取器在同样样本上的对比,可以看这里:六库正文提取对比。
像我们自己的 Thunderbit 这样的托管抓取/渲染/提取服务,解决的是另一层责任边界。Thunderbit 这次没有参加基准测试。关键区别在于:一个是给定 HTML 做文章提取,另一个是由托管服务去抓 URL 并处理页面;本文并没有提供同指标下的性能或质量对比。
更公平的说法是:goose3 的字段集合是固定的,而且就是为文章形态设计的——当你的页面本来就是文章时,这正合适;当你的页面是商品列表时,就不对了。如果你手里已经有 HTML,而且页面确实是文章,goose3 便宜,而且很干净。
如果你看的是托管方案,完整视角可以参考我们的 网页抓取 API 盘点;如果你想看自托管替代品,可以看 开源爬虫总览。如果文本最终要喂给模型,用 Python 把 HTML 转成 Markdown 会更能看清楚 fidelity 到底损失在哪一层。
所以,goose3 值不值得用?
值得,如果你的工作负载本来就是段落型文章,而且调用方会把空输出视为提取失败,而不是成功。
在这组样本里,goose3 只要有回答,就没有混入任何标注过的模板 token,但也返回了两个空字符串:一个是几乎空白的页面,一个是纯列表正文。这反映的是精确率和覆盖率之间的取舍,不代表它在产品层面就一定是这个脾气。
如果召回更重要,那就配一个带明确最小输出判断的 fallback。Readability 在这 22 个样本里恢复了所有文章单元;newspaper4k 则表现为 0 个标注模板单元泄漏、0.9865 召回率,并且在全部 22 个样本上都有输出。这些结果说明的是,这个合成样本集下它们的默认表现,不是对未知生产流量的承诺。
goose3 的价值,在于“错一个词”的代价高于“少一页”的代价。
试用 Thunderbit 进行网页数据提取 Get Started Free
常见问题
goose3 的完美精确率是真的吗,还是因为它有时直接不答? 两者都有,而且可以拆开看。它在 11 个包含模板内容的样本里只参与了 10 个的评分,所以平均值里少了一个样本——这部分确实是“拒答”造成的。但在那 10 个样本里,它面对的是专门设计得很刁钻的模板内容,结果一个污染 token 都没吐出来,而 Readability 漏出了 35 个。对它有回答的页面来说,这个精确率是真的;召回率那一列才体现出它的拒答行为。
为什么 goose3 在文章是列表的页面上会什么都不返回?
它的候选评分需要段落形状的块来定位正文,而纯 <li> 页面根本没有这种块。默认的 parse_lists=True 并不会改变这一点——我专门试过,再加上 strict=False,三个配置都只得到 0 个字符;而以 <p> 为基础的控制组在三个配置下都返回了 937 个字符。parse_lists 只是决定列表能不能保留在“已经找到的文章”里。
我必须调用 close() 吗?
是的,最好像上面那样用 try/finally 显式关闭。这个评测没有测不关闭时到底会积累什么,所以我不主张把它说成“已量化的循环泄漏”;但它确实说明了 Goose 有一个需要调用方自己管理的生命周期。
goose3 默认发出的 User-Agent 是什么?
默认是 Goose/3.1.22——也就是库名加精确版本号。这个只在它自己去抓网页时才会生效;如果你传的是 raw_html,就完全绕开了这件事。如果你真要让它抓网页,最好自己显式设置 User-Agent;默认值会把你在访问的每台服务器上暴露得很清楚。
这次评测没有测试什么?
完全没有测真实网页——这里都是受控样本。虽然 target_language 是一等配置,但多语言提取没测。元数据字段(标题、作者、日期、头图)只是盘点了,并没有做准确率评分。图片抓取也没测,它默认关闭,而且 ImageMagick 路径指向的是多数机器并没有的包管理器位置。并发或持续负载下的内存行为也没测,吞吐量在高负载下如何同样没测;内存表只测了一个全新进程处理一篇文档。


