Project Gutenberg のテキストデータから本文を抽出する

Project Gutenberg は小説や詩、論文、教養書、演説原稿などなどなど、著作権切れあるいは著作権のないテキストの一大データベース。主にはやはり英語だが、他の諸言語のデータもそこそこある。


このテキストはまるっと自由に使えるので、文章解析などをやるには宝の山。
だけど、肝心のテキストの前後にヘッダやフッタや、とにかく様々な情報がくっついているのが、かなり難。


ある程度書式を決めといてくれれば良かったのに*1、誰もが自由気まま勝手に制作していて、めっちゃフリーダムでアナーキーなことになっている。
区切りのフォーマットがそもそもファイルによってバラバラだし。
そんな区切りもあればいい方で、なんの区切りもなく「誰がスキャンしました」だの「間違ってるかもしれないからチェックしてね!」だの書いてあったり、最悪の場合、それが本文と空行1つしかあいてないという。


しかたなくそのまま解析したりなんかすると、最もよく使われている単語として the の次に gutenberg が来る、とかいう事態に陥る。


青空文庫とかと比べると、日本人はやっぱりそういうところきっちりしているというか、守るし守らせる性格なんだなあ、としみじみ思う。


ま、それはともかく、無い物ねだりしても仕方ないので、Project Gutenberg のテキストファイルから本文(というか本体かな)を抽出するコードを書いてみた。

[1/6 またちょっぴり更新]

def extract_gp_body(st)
  text = st.gsub(/[ \r]+$/, "") + "\n\n"
  text.gsub!(/<-- .+? -->/m, "")
  text.gsub!(/<HTML>.+?<\/HTML>/mi, "")

  r = /http|internet|project gutenberg|mail|ocr/i
  while text =~ /^(?:.+?END\*{1,2} ?|\*{3} START OF THE PROJECT GUTENBERG E(?:BOOK|TEXT).*? \*{3}|\*{9}END OF .+?|\*{3} END OF THE PROJECT GUTENBERG E(?:BOOK|TEXT).+?|\*{3}START\*.+\*START\*{3}|\**\s*This file should be named .+|\*{5}These [eE](?:Books|texts) (?:Are|Were) Prepared By .+\*{5})$/
    pre, post = $`, $'
    text = if pre.length > post.length*3 then
      pre
    elsif post.length > pre.length*3 then
      post
    elsif pre.scan(r).length < post.scan(r).length
      pre
    else
      post
    end
  end

  text.gsub!(/^(?:Executive Director's Notes:|\[?Transcriber's Note|PREPARER'S NOTE|\[Redactor's note|\{This e-text has been prepared|As you may be aware, Project Gutenberg has been involved with|[\[\*]Portions of this header are|A note from the digitizer|ETEXT EDITOR'S BOOKMARKS|\[NOTE:|\[Project Gutenberg is|INFORMATION ABOUT THIS E-TEXT EDITION\n+|If you find any errors|This electronic edition was|Notes about this etext:|A request to all readers:|Comments on the preparation of the E-Text:|The base text for this edition has been provided by).+?\n(?:[\-\*]+)?\n\n/mi, "")
  text.gsub!(/^[\[\n](?:[^\[\]\n]+\n)*[^\n]*(?:Project\sGutenberg|\setext\s|\s[A-Za-z0-9]+@[a-z\-]+\.(?:com|net))[^\n]*(?:\n[^\[\]\n]+)*[\]\n]$/i, "")
  text.gsub!(/\{The end of etext of .+?\}/, "")
  text = text.strip + "\n\n"

  text.gsub!(/^(?:(?:End )?(?:of ?)?(?:by |This |The )?Project Gutenberg(?:'s )?(?:Etext)?|This (?:Gutenberg )?Etext).+?\n\n/mi, "")
  text.gsub!(/^(?:\(?E?-?(?:text )?(?:prepared|Processed|scanned|Typed|Produced|Edited|Entered|Transcribed|Converted) by|Transcribed from|Scanning and first proofing by|Scanned and proofed by|This e-text|This EBook of|Scanned with|This Etext created by|This eBook was (?:produced|updated) by|Image files scanned in by|\[[^\n]*mostly scanned by).+?\n\n/mi, "")

  return text
end

爆発している正規表現の固まりを眺めてみるだけでも、フリーダム万歳っぷりが伝わるだろうか。
って、1行で書いてるからスクロールしないと見えないか。適当に改行を入れるとこんな感じ(下のコードは動きません)。

def extract_gp_body(st)
  text = st.gsub(/ +$/, "") + "\n\n"
  text.gsub!(/<-- .+? -->/m, "")
  text.gsub!(/<HTML>.+?<\/HTML>/mi, "")

  r = /http|internet|project gutenberg|mail|ocr/i
  while text =~ /^(?:.+?END\*{1,2} ?
      |\*{3} START OF THE PROJECT GUTENBERG E(?:BOOK|TEXT).*? \*{3}
      |\*{9}END OF .+?|\*{3} END OF THE PROJECT GUTENBERG E(?:BOOK|TEXT).+?
      |\*{3}START\*.+\*START\*{3}|\**This file should be named .+
      |\*{5}These [eE](?:Books|texts) (?:Are|Were) Prepared By .+\*{5})$/
    pre, post = $`, $'
    text = if pre.length > post.length*3 then
      pre
    elsif post.length > pre.length*3 then
      post
    elsif pre.scan(r).length < post.scan(r).length
      pre
    else
      post
    end
  end

  text.gsub!(/^(?:Executive Director's Notes:|\[?Transcriber's Note|PREPARER'S NOTE
      |\[Redactor's note|\{This e-text has been prepared
      |As you may be aware, Project Gutenberg has been involved with
      |[\[\*]Portions of this header are|A note from the digitizer
      |ETEXT EDITOR'S BOOKMARKS|\[NOTE:|\[Project Gutenberg is
      |INFORMATION ABOUT THIS E-TEXT EDITION\n+|If you find any errors
      |This electronic edition was|Notes about this etext:
      |A request to all readers:|Comments on the preparation of the E-Text:
      |The base text for this edition has been provided by).+?\n(?:[\-\*]+)?\n\n/mi, "")
  text.gsub!(/^[\[\n](?:[^\[\]\n]+\n)*[^\n]*(?:Project\sGutenberg|\setext\s
      |\s[A-Za-z0-9]+@[a-z\-]+\.(?:com|net))[^\n]*(?:\n[^\[\]\n]+)*[\]\n]$/i, "")
  text.gsub!(/\{The end of etext of .+?\}/, "")
  text = text.strip + "\n\n"

  text.gsub!(/^(?:(?:End )?(?:of ?)?(?:by |This |The )?Project Gutenberg(?:'s )?(?:Etext)?
      |This (?:Gutenberg )?Etext).+?\n\n/mi, "")
  text.gsub!(/^(?:\(?E?-?(?:text )?(?:prepared|Processed|scanned|Typed|Produced|Edited|Entered|Transcribed|Converted) by
      |Transcribed from|Scanning and first proofing by|Scanned and proofed by|This e-text
      |This EBook of|Scanned with|This Etext created by|This eBook was (?:produced|updated) by
      |Image files scanned in by|\[[^\n]*mostly scanned by).+?\n\n/mi, "")

  return text
end

当初、1つの正規表現にまとめようとしていたが、とても無理!
というわけで Ruby コードだが、そんなにトリッキーなことはしていないので他の言語に移植するのは難しくないだろう。


これでも 100% にはまだまだ遠いのだが、Project Gutenberg で配布されている August 2003 CD (2003/8 時点での収録作品から主に名作を中心に厳選された約 600 作品をまとめた ISO イメージ)に収められているテキストについては 98% くらいの精度で抽出できた。


Newsweek から 500 記事とってきて解析したりとか、なんか最近そんなことばかりやってるねー、というあたりは遠からずお見せできるかも?


#本文抽出ってどこまでもヒューリスティックできりがないんだけど、文章解析して遊ぶには本文抽出の精度が鍵を握るから手を抜けないんだよねえ。やれやれ。

*1:誰も知らないだけでもしかしたら決まっているのかもしれないが