青空文庫とは?
青空文庫 は、著作権の切れた文学作品を集めたオンライン図書館です。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 ファイルも多数ありますが、今回は対象外です。
ここから単語集計のコードを書き始めるにあたり、まずはざっくりした方針を整理します。
- すべての HTML ファイルを見つける
- HTML から文字列データを抽出する(本文とページ内の補足テキストを厳密に分けていません。割合はわずかなのでまとめて扱います)
- 各ページの文字列を形態素解析して単語情報を得る
- ノイズ(文字化けなど)を排除し、重複を取り除く
ここから 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 に公開しています。