青空文庫とは?


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

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


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

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

集計プロセス


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

ただし元のリポジトリは 5000 以上のコミットがあり、完全な履歴を含めて clone するとローカルで約 10GB を消費し、時間もかかります。最新版だけ分かれば十分なので、git clone --depth=1 [email protected]: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 に公開しています。