青空文库是什么?


青空文库 是一个在线图书馆,收集了17751部(截止到2025年10月26日)无版权的文学作品,明治、昭和初期的作品居多。作品集合可以方便地从 Github 仓库:aozorabunko 上下载。

17751部文学作品中有多少日语单词呢?


我比较好奇,同时也是为了熟悉 Elixir 语言,因此花了点时间下载作品,用 Elixir 写脚本统计单词数量。

先把答案贴出来:114010 个

统计过程


首先需要从Github 仓库:aozorabunko 下载所有作品

但是注意,原始仓库有 5000 多个 commit,如果将完整仓库(包含所有历史记录)下载到本地的话需要大概 10G 存储空间,而且下载时间较长。我们只需要最新的记录即可,即使用 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 生态中最流行的 HTML 解析器是 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

    # 检测全角数字,并且只有一个字符
    String.match?(str, ~r/^[\x{FF10}-\x{FF19}]$/u) ->
      true

    # 检测全角英文字母,并且只有一个字符
    String.match?(str, ~r/^[\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}]$/u) ->
      true

    # 检测半角英文字母、数字,并且只有一个字符
    String.match?(str, ~r/^[0-9A-Za-z]$/) ->
      true

    # 检测(全角、半角)平片假名,并且只有一个字符
    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