6つのライブラリ、1つの注釈付きフィクスチャセット、1人の採点器。22個の合成フィクスチャでは、ボイラープレート漏れが最も多かったライブラリ――Mozilla の Readability、23.5%――が、実はすべてのラベル付き記事ユニットを回収できた唯一の実装でもありました。
要するに、この1行がトレードオフです。多くの解説記事は精度だけを見てそこで話を終えるため、この本質が表に出ません。
何を測ったのか
このセットの各フィクスチャには、ユニットごとの正解データがあります。ページを構成する各ブロック――記事本文の段落、ナビ、広告、サイドバー、コメント欄、プロモーション枠――に article か boilerplate のラベルを付け、さらに固有のセントネル文字列を埋め込んであります。つまり「このユニットを抽出器が回収できたか」は、類似度ではなく厳密な部分文字列の一致で判定します。セントネルが出力に残るか、残らないか。そのどちらかです。
22個のフィクスチャ、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 を同じマシンで動かしました。なお、この記事では OS/CPU、正確な実行コマンド、繰り返し回数、ウォームアップ方針は保持していないため、時間の列はローカル観測であって、持ち回り可能なベンチマークではありません。
実行前に自分に課したルールが2つあります。まず、各 Python ライブラリはそれぞれ空の独立した virtualenv にインストールし、他のライブラリが持ち込んだ依存関係を引き継がないようにしました。次に、どのランナーもメトリクスを計算しません。各ツールは生の抽出テキストだけを吐き出し、最終的な数値は1つの採点器がすべて出します。つまり、6つのツールを「それぞれ似た定義の precision」で比べるのではなく、同じ計算式で比べています。
まず結論の表
| ライブラリ | 記事再現率(22件全体) | ボイラープレート漏れ | コンテンツトークン精度 | 精度を算出できたフィクスチャ数 | 混入トークン数 |
|---|---|---|---|---|---|
| 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個すべてのフィクスチャで集計しています。漏れ率、全体のコンテンツトークン精度、混入率は、記事ユニットとボイラープレートユニットの両方を含む11個のフィクスチャで計測しています。「精度を算出できた数」は、そのうち何件で出力があったかを示します。詳細なフィクスチャ別数値は sixway-scores.json にあります。
この表の1行だけはデフォルトではありません。 resiliparse の extract_plain_text は main_content=False が既定値ですが、ここでは main_content=True で呼び出しました。差は小さくありません。既定値のままだと、セット全体で17/17のボイラープレートユニット――ナビ、広告、サイドバー、コメント欄、プロモーション枠のすべて――を漏らしますが、フラグを有効にすると1/17に下がります。上の他のライブラリはすべて既定値で呼んでいます。したがって、resiliparse の 0.0588 の漏れ率は main content を指定したときの挙動であり、extract_plain_text(html) 単体は別物です (default-vs-main-content.json)。
最初の列と2列目は、必ず一緒に読んでください。どちらか片方だけ見ると、選ぶべきツールを誤ります。
Readability は取りこぼしゼロです。 22個すべてのフィクスチャで完全再現率。これはこの一覧で唯一です。ただし代償があります。17個のボイラープレートユニットのうち4つを拾ってしまい、混入トークンは35個。漏れ率は trafilatura の4倍です。4件の漏れのうち3件は同じ形で、記事の兄弟要素として置かれた中立クラスのプロモーションブロックを、兄弟要素の追加ヒューリスティックが飲み込んでしまっています。ここで出力をモデルに渡すなら、そのトークン代を払うことになり、モデルはそれを記事として読んでしまいます。
newspaper4k はバランス型です。 漏れゼロ、混入トークンゼロ、再現率0.9865、しかも22個すべてのフィクスチャで出力あり。ワークロードを知らない状態で1つ選ぶなら、私ならこれを選びます。しかも、一般に最初に思い浮かべる名前でもありません。
goose3 は精度は完璧ですが、テストでは再現率が最も低いです。 返したコンテンツトークンはすべて記事由来でした。しかし2つのフィクスチャでは何も回収できず、その2件では出力もありませんでした。出力しないことを許されるなら、完璧な precision は安くつきます。
2つのライブラリをよく見せすぎた precision の数字
この最後の点は、具体的にしておく価値があります。危うく私自身がそのまま公開するところだったからです。
ここでの precision と F1 は、出力がある場合に限って計算されています。あるフィクスチャで空文字列を返すライブラリは、分子にも分母にも何も足しません。つまり、答えないことは無料で、慎重な抽出器の precision は、ただ黙っているだけで徹底型の抽出器より良く見えてしまいます。
goose3 の総合 precision は、出力があった10個のスコア対象フィクスチャで 1.0000 でした。jusText は 11件中10件で 0.8760。Readability、trafilatura、resiliparse、newspaper4k は 11件中11件に答えています。今の表では precision の横に分母を並べたので、棄権が都合よく隠れることはありません。
さらにひどい版もありました。最初の採点器は、コンテンツ忠実性を測る11個のフィクスチャだけで記事再現率を平均していました。これは、漏れを測るには正しいやり方です。すると resiliparse は再現率 1.0000 になりました。しかし22個すべてで見ると resiliparse は0.9054です。というのも、記事が <p> を一切含まない <li> 要素の中にだけ存在するフィクスチャでは、出力は返すものの、記事ユニットを6中0しか回収できなかったからです。そのフィクスチャにはボイラープレートがないため平均の対象外になり、実際の失敗が完璧なスコアに隠れていました。
どこで壊れるのか
| フィクスチャ | 何を試しているか | 何も回収できないもの |
|---|---|---|
記事が完全に <li> の中にあり、<p> がない | 構造依存の仮定 | resiliparse(0/6)、goose3(出力なし) |
| 129文字の単一記事ユニット | 短文しきい値 | jusText |
| 短い段落が10個、長い段落なし | 短文しきい値 | jusText |
| ほぼ空の文書 | 真の無入力境界 | goose3、jusText |
これらはすべて、単なる「抽出性能が悪い」ではなく、再現可能な個別挙動です。
- resiliparse と goose3 はどちらも段落を前提にしています。 本文がリスト形式のページ――更新履歴、仕様書、FAQ、レシピなど――を与えると、resiliparse はリスト内容を含まないテキストを返し、goose3 は何も返しません。ここでは、何か返ってくる分だけ resiliparse のほうが危険です。見た目は成功に見えるからです。
- jusText には長さの崖があり、その落差は鋭いです。 詳しくは後述します。
- ほぼ空の文書については、何も返さないのが正しい場合もあるので、そこはどちらのライブラリも責めるべきではないでしょう。
jusText: なだらかな坂ではなく、崖
jusText は22個中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 |
ちょうど1つの段落が閾値を超えた瞬間に、0文字から832文字へ一気に跳ね上がり、その後はどれだけ緩めても何も変わりません。段落が1つでも線を越えると、文書全体が解放されるのです。
ただし、それを結論づける前に length_low を4種類、max_link_density を2種類、計8通りでスイープしましたが、すべて0でした。このプロジェクトでは、ある機能が使えないという主張には、ベンダー自身のエラーメッセージでフィールド名が示されるか、少なくとも3種類のパラメータ形状を試す必要があります。1つのパラメータで何も起きないだけでは、そのライブラリ全体の評価にはなりません。数値は justext-length-threshold.json にあります。
だからといって、jusText の抽出が悪いと言っているわけではありません。実際の自然文ページでは、既定設定のまま1,190文字のきれいな本文を返しました。言いたいのは、ドキュメントに明記されたノブがスイッチのように振る舞い、その既定位置が短い段落の文書には合っていない、ということです。
何を入れると、どれだけ重いか

同じフィクスチャ、同じマシン、各ライブラリは空の仮想環境に個別インストール。
| ライブラリ | パッケージ数 | 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 でした。サーバーレス用途でこの数字を使うなら、起動、初回呼び出し、定常状態を分けた分布が必要です。
品質面では trafilatura と resiliparse が事実上の同点です。コンテンツトークン F1 は 0.9697 対 0.9681、漏れ率はどちらも 0.0588。これで勝者を決めるつもりはありません。ですがフットプリントでは全然違います。21.0 MiB 対 69.9 MiB、5パッケージ対17パッケージ。実際に選んでいるのは、resiliparse のリストへの弱さと trafilatura の追加3依存のどちらを受け入れるか、という話です。
公開前に見つかった、自前のテストベッドの2つの不具合
上の比較は、あと一歩で成立しないところでした。その理由には、単一の行より大きな意味があります。
フィクスチャセットでは6つのライブラリのうち2つが見えませんでした。 元のフィクスチャは、各ユニットを zzart01vf64 zzart01v56i のような固有のナンセンス文字列で表していました。これがあるからこそ再現率を厳密に測れます。ただし同時に、英語の機能語が1つも入っていないことにもなります。Readability、trafilatura、resiliparse は DOM 構造を見て判断するので影響を受けませんが、goose3 と jusText は stopword を数える方式です。数えるものがありませんでした。そのため、22個すべてで空文字列が返りました。
6つのライブラリが0点の表を出しても、それは権威があるように見えるだけで、何の意味もありません。実際のページで確認すると、goose3 は1,017文字、jusText は1,190文字を返しました。ライブラリは正常でした。テストベッドがそれを表現できなかったのです。
そこで、セントネルを保持したまま、英語の散文を持たせてフィクスチャを作り直しました。構造、クラス、DOM上の位置、ユニット境界、セントネルは同じ。置き換えたトークン数は 1,568。goose3 は 0 から 22件中20件へ改善しました。
しかし再構築は自分で別の2点を壊し、しかも両方とも私の責任でした。 英単語はおよそ6文字、zzart01vf64 はおよそ12文字です。1対1で置き換えると、各ユニットの長さが半分になり、ユニットテキストの 21,646 文字が 10,986 文字へ、最長ユニットは 1,513 文字から 622 文字へ落ちました。これは、長さが本質のフィクスチャを静かに書き換えることになります。jusText は長さの崖で挙動が変わるため、それだけで 22件中19件から6件へ落ちました。もし半分にした版を公開していたら、jusText の数値は3倍も違っていたはずで、しかも「悪いライブラリ」に見えやすい方向の誤差でした。
もう1つ。すべてのユニットを1つの共通コーパスから引いたことで stopword 密度は戻りましたが、トークンレベル採点が依存する性質が壊れました。記事とボイラープレートの語彙は互いに重なってはいけません。そうでないと「抽出されたトークンがボイラープレートトークンでもある」が the を数えてしまうからです。22個中10個のフィクスチャで語彙が重なり、元のセットでは0でした。修正として、各ユニットの内容語に接尾辞を付け、機能語は素のままにしました。つまり、語彙ベースのライブラリには本物の stopword を数えさせ、採点器には重複しない内容語彙を残したわけです。
だからこそ、ここでのトークン列は content_token_* と名付けてあり、Readability と trafilatura の公開比較で使った数値とは流用していません。測っている量が違うため、片方をもう片方として扱うのは誤りです。
再構築中に、私の責任ではない問題も1つ見つかりました。3つのリンク密度フィクスチャでは、<a> の終了タグが単語の途中に入っています。<a href="/x">zzsibp015qlhf zzsi</a>bp015qbht のような形です。これは、正確な比率に合わせるためにアンカー位置を文字オフセットで置いたからです。表示されるテキストは変わらないので元の採点では気づかれませんでしたが、要素単位で処理する抽出器から見ると、他の実装が1単語と見る場所が2つの断片になります。そこは修正し、リンクによる文字差分は静かに飲み込まず、記録として残しました。
どれを使うべきか
モデルに渡して、トークン単位で課金されるなら? newspaper4k か goose3 です。どちらもボイラープレート漏れゼロ、混入トークンゼロでした。毎ページで何かしら返してほしいなら newspaper4k。推測より無言のほうがいい、しかもページが段落中心なら goose3。
レイテンシ重視の Python パスを最適化したいなら? resiliparse を比較対象に入れてください。今回の観測ではインポートと中央値抽出が最速で、品質も trafilatura にかなり近いです。ただしそれは main_content=True の場合で、既定ではありません。まずはリスト主体のレイアウトを確認し、このローカル時間を厳密なクロスランタイム速度比にはしないでください。
アーカイブ用途、または余分な内容より欠落のほうが痛い場合は? Readability です。全フィクスチャで全記事ユニットを回収できたのはこれだけで、35個の余計なトークンは、段落を1つ失うより安い代償です。
多言語対応が必要なら? jusText は候補に入ります。言語別 stoplist を備えているからです。ただし、この研究では多言語抽出を検証していません。なので、それは「評価する理由」であって「勝つ証拠」ではありません。length_high は代表的な段落長に合わせて試してください。
記事以外を扱うなら? この6つはどれも向いていません。どれも「ページには主となる散文が1つある」という前提で作られており、商品一覧、検索結果ページ、ダッシュボードはその前提を崩します。パラメータでは直りません。
マネージドAPIの出番
ここまでのものはすべて、自分で HTML を渡してテキストを受け取るライブラリです。失敗のしかたはページ形状によって変わるので、選んだ既定値は自分のコーパスで検証してください。構造化フィールド抽出や、取得・レンダリングの処理はこの比較の範囲外です。
補足として、Thunderbit は URL を渡すワークフローと構造化出力向けのマネージドサービスです。このフィクスチャでは実行していないため、品質比較を示すものではありません。判断の境界は、すでに HTML を持っていてローカルのテキスト抽出器が欲しいのか、それとも取得・レンダリングと運用をサービスに任せたいのか、です。
率直に言えば、すでに HTML が手元にあり、テキストだけ欲しいなら、この6つのうち1つは無料で十分良いです。この表はその選び方を示しています。大量取得や、散文ではなく行データが欲しいなら、それは別の買い物です。
ホスト型フェッチャー同士で選ぶなら、web scraping API roundup がその分野を、SEO とデータ API のコスト比較 が料金をまとめています。セルフホスト側については open-source scraper pillar が全体像で、もし本当に欲しいのがプレーンテキストではなく Markdown なら、Python で HTML を Markdown に変換する方法 が、失われる部分を最もよく示しています。
結論
勝者はいません。1位を1つだけ書く表は、実際のトレードオフについて嘘をついてしまいます。
選ぶ前に、小さな受け入れ用コーパスを作ってください。リストだけの記事、短い段落、プロモーションの兄弟要素、ほぼ空のページ、そして「何も返さないほうがまし」なケースを含めます。記事の回収率、ボイラープレート漏れ、棄権を別々に採点してください。このフィクスチャでは、Readability は再現率重視、newspaper4k は最もバランスが良く、resiliparse はレイテンシ候補だがリスト内容に弱い、という評価でした。ただし、そのラベルは検証なしに今回の形状を超えて持ち出すべきではありません。
実際に言うべきことは、これよりずっと狭いです。選ぶ前に、あなた自身のページ形状でフィクスチャを回してください。元のテストベッドを見えなかった6つのうち2つがあり、そのうち1つは完璧な再現率の裏に完全失敗を隠していました。比較表はそのための出発点であって、代用品ではありません。
Web データ抽出に Thunderbit を試す Get Started Free
FAQ
この数値は、各ライブラリの公開ベンチマークと比較できますか? いいえ、そのようには引用しないでください。これは、人工的ですがラベル付きのユニットを持つ制御済みフィクスチャです。6つすべてが同一バイト列を見ているので、相互比較は公平です。一方、scrapinghub の article-extraction benchmark のような公開値は、現実のコーパスを使っており、別種でより難しいものを測っています。この表は、この6つ同士を比べるために使ってください。論文の数値と比べるものではありません。
Readability と trafilatura はどちらも DOM ベースなのに、なぜ Readability の漏れ率のほうが高いのですか? 境界の引き方が違うからです。Readability の4件の漏れのうち3件は、中立扱いのプロモーションブロックが記事の兄弟要素として置かれているもので、隣接する長文かつ低リンク密度の内容は物語の一部だろう、というヒューリスティックで取り込まれました。実際にはそうであることも多いです。ただ、このフィクスチャではそれがプロモーションでした。trafilatura は追加対象に対してより厳しく、同じユニットのうち1件だけ漏らしました。
goose3 と jusText の precision は信用していいですか? サンプル数と一緒に見る場合だけです。どちらも記事ユニットとボイラープレートユニットの両方を持つ11個のうち10個で採点されています。1件では何も返さなかったからです。出力なしのフィクスチャは比率のどちら側にも寄与しません。goose3 の 1.0000 の precision は、答えたページについては本当です。ただし、22個全体での 0.8243 の再現率が、その事実のもう半分です。
jusText の長さしきい値は実ページでも重要ですか?
段落の長さ次第です。300文字程度の段落があるニュース記事なら、最初の段落で length_high を超えるので普通に動きます。だから既定値でも、実ページで 1,190 文字のきれいな本文を返したわけです。一方で、短い段落、箇条書き、商品説明のページでは一度も超えないことがあり、その場合 jusText は部分回答ではなく空文字列を返します。本番で気づくのではなく、先に明示的に設定してください。
ここでテストしていないものは何ですか? 現実世界のページそのものは対象外です。jusText の最大の売りである stoplist を持っていても、多言語抽出は未検証です。負荷時のメモリ使用量も見ていません。記事ではないページ――商品一覧、検索結果、ダッシュボード――も未対象です。エンコーディングの境界条件も未対象です。また、Node と Python の2つのエコシステムはライブラリの挙動比較として見ており、ランタイム性能比較ではありません。そのため、境界をまたぐミリ秒の数字は、厳密な比率ではなく桁感として読むべきです。


