2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

17751 冊の日本文学作品にはいくつの単語があるのか?

Posted at

青空文庫とは?

青空文庫 は、著作権の切れた文学作品を集めたオンライン図書館です。2025 年 10 月 26 日時点で 17751 冊を収録しており、明治から昭和初期の作品が中心です。作品データは GitHub リポジトリ aozorabunko からまとめてダウンロードできます。

17751 冊にはいくつの単語が含まれているのか?

以前から気になっていたテーマで、Elixir に慣れる目的も兼ねて、作品データを取得し単語数を集計してみました。

結論を先にお伝えすると 114010 語 でした。

集計プロセス

まずは GitHub リポジトリ aozorabunko から作品を取得します。

ただし元のリポジトリは 5000 以上のコミットがあり、完全な履歴を含めて clone するとローカルで約 10GB を消費し、時間もかかります。最新版だけ分かれば十分なので、git clone --depth=1 git@github.com:aozorabunko/aozorabunko.git で取得しました。

リポジトリを見ると、文学作品はほぼすべて HTML ファイルに格納されています。画像や JS、CSS ファイルも多数ありますが、今回は対象外です。

ここから単語集計のコードを書き始めるにあたり、まずはざっくりした方針を整理します。

  1. すべての HTML ファイルを見つける
  2. HTML から文字列データを抽出する(本文とページ内の補足テキストを厳密に分けていません。割合はわずかなのでまとめて扱います)
  3. 各ページの文字列を形態素解析して単語情報を得る
  4. ノイズ(文字化けなど)を排除し、重複を取り除く

ここから Elixir のコードを書いていきます。

Elixir の Path モジュールにはファイルを検索するための関数が揃っており、Path.wildcard/2 を使えばパターンに合致するファイルを一括で取得できます。

html_files = Path.wildcard("/Users/xxxxx/aozorabunko/**/*.html")

HTML のテキスト抽出には HTML パーサが便利です。Elixir で最もよく使われているのは Floki で、シンプルな API が揃っていて扱いやすいです。

ただしテキスト抽出では文字エンコーディングの問題に遭遇しました。Elixir は UTF-8 しか扱えないのですが、青空文庫の HTML には Shift_JIS が多く含まれています。そのため、エンコーディングを判定し、必要に応じて UTF-8 に変換する必要がありました。

エンコーディング判定には charset_detect、変換には iconv を使用しました。

html_files
|> Enum.map(fn file ->
  data = file |> File.read!()

  content =
    case CharsetDetect.guess!(data) do
      "UTF-8" ->
        IO.puts("valid data")
        data

      "Shift_JIS" ->
        IO.puts("invalid data, file: #{file}")
        :iconv.convert("SHIFT_JIS", "UTF-8", data)

      unknown_encoding ->
        IO.puts("unknown encoding, file: #{file}")
        :iconv.convert(unknown_encoding, "UTF-8", data)
    end

  content
  |> Floki.parse_document!()
  |> Floki.find("body")
  |> Floki.text()
end)

文字列の分割には形態素解析エンジン Mecab を使用し、Elixir 向けのラッパー mecab-elixir を利用しました。

Mecab は単なる分割だけでなく、品詞や読み、原形などさまざまな情報を返してくれます。実際の出力例は次の通りです。

iex> Mecab.parse("今日は晴れです")
[%{"conjugation" => "",
   "conjugation_form" => "",
   "lexical_form" => "今日",
   "part_of_speech" => "名詞",
   "part_of_speech_subcategory1" => "副詞可能",
   "part_of_speech_subcategory2" => "",
   "part_of_speech_subcategory3" => "",
   "pronunciation" => "キョー",
   "surface_form" => "今日",
   "yomi" => "キョウ"},
 %{"conjugation" => "",
   "conjugation_form" => "",
   "lexical_form" => "は",
   "part_of_speech" => "助詞",
   "part_of_speech_subcategory1" => "係助詞",
   "part_of_speech_subcategory2" => "",
   "part_of_speech_subcategory3" => "",
   "pronunciation" => "ワ",
   "surface_form" => "は",
   "yomi" => "ハ"},
 %{"conjugation" => "",
   "conjugation_form" => "",
   "lexical_form" => "晴れ",
   "part_of_speech" => "名詞",
   "part_of_speech_subcategory1" => "一般",
   "part_of_speech_subcategory2" => "",
   "part_of_speech_subcategory3" => "",
   "pronunciation" => "ハレ",
   "surface_form" => "晴れ",
   "yomi" => "ハレ"},
 %{"conjugation" => "基本形",
   "conjugation_form" => "特殊・デス",
   "lexical_form" => "です",
   "part_of_speech" => "助動詞",
   "part_of_speech_subcategory1" => "",
   "part_of_speech_subcategory2" => "",
   "part_of_speech_subcategory3" => "",
   "pronunciation" => "デス",
   "surface_form" => "です",
   "yomi" => "デス"},
 %{"conjugation" => "",
   "conjugation_form" => "",
   "lexical_form" => "",
   "part_of_speech" => "",
   "part_of_speech_subcategory1" => "",
   "part_of_speech_subcategory2" => "",
   "part_of_speech_subcategory3" => "",
   "pronunciation" => "",
   "surface_form" => "EOS",
   "yomi" => ""}]

各フィールドの意味は以下の通りです。

フィールド 日本語 中国語
surface_form 表層形(文中で実際に使われている形) 表层形 / 原文形
part_of_speech 品詞 词性
part_of_speech_subcategory1 品詞細分類1 词性细分类1
part_of_speech_subcategory2 品詞細分類2 词性细分类2
part_of_speech_subcategory3 品詞細分類3 词性细分类3
conjugation_form 活用形(文中での形) 活用形态
conjugation 活用型(活用パターン) 活用类型
lexical_form 原形 / 基本形(辞書形) 原形 / 基本形
yomi 読み(かな) 读音
pronunciation 発音(実際の読み) 发音

コードは次のようになります。

parse_article = fn article ->
  Mecab.parse(article)
  |> Enum.reject(fn %{
                      "lexical_form" => lexical_form,
                      "part_of_speech" => part_of_speech,
                      "surface_form" => surface_form
                    } ->
    # 助詞や記号などの特殊な単語を除外
    case {lexical_form, part_of_speech, surface_form} do
      {"*", _, _} -> true
      {_, _, "EOS"} -> true
      {_, "助詞", _} -> true
      {_, "記号", _} -> true
      _ -> false
    end
  end)
  |> Enum.map(fn %{"lexical_form" => lexical_form} -> lexical_form end)
  |> Enum.uniq()
end

ここまでで得られた単語群を集約し、重複やノイズを取り除けば最終的な単語リストになります。

File.read!("./words.txt")
|> String.split(",")
|> Enum.reject(fn str ->
  cond do
    # ラテン文字の文字化けを検出
    String.match?(str, ~r/[\x{00C0}-\x{024F}]/u) ->
      true

    # 全角数字 1 文字
    String.match?(str, ~r/^[\x{FF10}-\x{FF19}]$/u) ->
      true

    # 全角英字 1 文字
    String.match?(str, ~r/^[\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}]$/u) ->
      true

    # 半角英数字 1 文字
    String.match?(str, ~r/^[0-9A-Za-z]$/) ->
      true

    # 全角・半角の仮名 1 文字
    String.match?(
      str,
      ~r/^[\x{3040}-\x{309F}\x{30A0}-\x{30FF}\x{31F0}-\x{31FF}\x{FF66}-\x{FF9D}]$/u
    ) ->
      true

    true ->
      false
  end
end)
|> tap(fn words ->
  File.write!("./clean_words.txt", Enum.join(words, ","))
end)

サンプルとして、処理後の単語を 100 語掲載しておきます。

立ち寄る,いただく,ありがとう,ござる,ます,はじめて,おいで,なる,方,ため,おさめる,ある,本,ファイル,形式,読み方,紹介,する,基本,フォーマット,各,登録,作品,原則,種,用意,いる,それぞれ,特徴,読む,必要,道具,以下,とおり,です,テキスト,データ,できる,最も,シンプル,軽い,ルビ,ふりがな,入力,れる,もの,ない,圧縮,リンク,除く,解凍,復元,ソフト,入手,先,フリー,ウェア,シェア,以上,付属,その,最新,版,及び,こちら,ダウンロード,窓,杜,インターネット,標準,一部,社,リリース,リーダー,表示,いま,使い,縦,組み,製品,ページ,単位,構成,電子,ほとんど,つくる,上,専用,(株),注意,マック,ユーザー,皆さん,改行,コード,多く,エディター,ワープロ,開く,行頭

完全なコードは Gist に公開しています。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?