Pythonで青空文庫データを自然言語処理向けにさくっと一括テキスト整形＋前処理

# はじめに
[青空文庫](https://www.aozora.gr.jp/)は言わずと知れた、著作権切れの文学作品を有志でデジタル化し公開しているウェブサイトです。現在は青空文庫のデータを利用する技術者のために、**全データのテキスト形式やHTML形式が[青空文庫のGithub](https://github.com/aozorabunko/aozorabunko)に日次でアップロードされており、一括ダウンロードも可能**になっています。

自然言語処理で青空文庫のデータを利用したい場合、テキストデータ内に定型の書式があったりして、特定作家のみのデータ収集から前処理まで少し手間がかかります。そこで**一括で特定作家分のダウンロード＋簡単に前処理するPythonスクリプト**を書きましたので、Qiitaに残しておくことにしました。

# 目標
- 青空文庫にある特定作家分のデータのみを一括ダウンロードする
- ダウンロードしたテキストファイルに、前処理をしてUTF-8のTSVファイルとして保存する
    - 1カラム目にテキスト本文、2カラム目に作品名があるTSVとして保存
    - テキスト整形は以下
    
|項目名|整形前（例）|整形後（例）|
|:-:|:--|:--|
|注意書き・書誌情報|「テキスト中に～」「底本：～」「※［＃「勹＜夕」、第3水準1-14-76］」|（削除）|
|節区切り記号|「―――」「×××」「＊＊＊」|（削除）|
|文学上での記号|ダッシュ「―」、三点リーダ「…」、コメ「※」|（削除）|
|1文字以下の行、空白行|「三」（節区切りの数字など）|（削除）|
|ルビ表記|「東京｜行《ゆき》の急行列車」|「東京行きの急行列車」|
|字下げ（行頭全角スペース）|「　書斎の中には、」|「書斎の中には、」|


# 特定作家分のデータのみ一括ダウンロード
特定の作家のデータを一括でダウンロードするには、**svnコマンドを用いるのが最も簡単**です。
`https://github.com/aozorabunko/aozorabunko/trunk/cards/{作者ID}`というURLをしていします。`{作家ID}`は、青空文庫のウェブサイトで特定の作家のページにアクセスした際にURL上に現れる6桁の数字です。
例えば芥川龍之介の場合は、「000879」です。
![qiita_01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/928728/1e183994-6653-dcdf-0548-e205ab947544.jpeg)

以下のように`svn export`でURLを指定することで特定の作家（例えば芥川龍之介）の全ファイルをダウンロードすることができます。
ちなみに`svn`はLinux, Macで標準的に動作するはずです。Windowsの場合はわかりませんが、Widowsユーザーだったとしても**WSLでUbuntuを起動してコマンドを打つと楽**です。

```:芥川龍之介の全データをダウンロード
svn export https://github.com/aozorabunko/aozorabunko/trunk/cards/000879/
```

そうするとローカルに`./000879/`ディレクトリが作られます。

# 前処理（テキスト整形＋保存）

以下のPythonスクリプトで、ダウンロードしたZIPファイルのまま一括でテキスト整形＋前処理をしてTSVで保存します。
処理の概要は以下の通りです。各処理に関してはコメントに記載した通りです。クラス化してもよかったですが、とりあえず関数のみで動かします。

 - 特定ディレクトリ配下のzipファイルを全て探索し、リストに格納
 - 出力ディレクトリ作成
 - リスト順に1ファイルごとにループ処理（for）
 - ZIP圧縮されたtxtをPandas DataFrameとして読込：`save_cleanse_text()`
 - 元データをUTF-8に変換してテキストファイル保存
 - テキスト整形：`text_cleanse_df()`
 - 2カラム目に作品名を付けて保存

```python:aozora_preprocess.py
import pandas as pd
from pathlib import Path

author_id = '000879'  # 青空文庫の作家番号
author_name = '芥川龍之介'  # 青空文庫の表記での作家名

write_title = True  # 2カラム目に作品名を入れるか
write_header = True  # 1行目をカラム名にするか（カラム名「text」「title」）
save_utf8_org = True  # 元データをUTF-8にしたテキストファイルを保存するか

out_dir = Path(f'./out_{author_id}/')  # ファイル出力先
tx_org_dir = Path(out_dir / './org/')  # 元テキストのUTF-8変換ファイルの保存先
tx_edit_dir = Path(out_dir / './edit/')  # テキスト整形後のファイル保存先


def text_cleanse_df(df):
    # 本文の先頭を探す（'---…'区切りの直後から本文が始まる前提）
    head_tx = list(df[df['text'].str.contains(
        '-------------------------------------------------------')].index)
    # 本文の末尾を探す（'底本：'の直前に本文が終わる前提）
    atx = list(df[df['text'].str.contains('底本：')].index)
    if head_tx == []:
        # もし'---…'区切りが無い場合は、作家名の直後に本文が始まる前提
        head_tx = list(df[df['text'].str.contains(author_name)].index)
        head_tx_num = head_tx[0]+1
    else:
        # 2個目の'---…'区切り直後から本文が始まる
        head_tx_num = head_tx[1]+1
    df_e = df[head_tx_num:atx[0]]

    # 青空文庫の書式削除
    df_e = df_e.replace({'text': {'《.*?》': ''}}, regex=True)
    df_e = df_e.replace({'text': {'［.*?］': ''}}, regex=True)
    df_e = df_e.replace({'text': {'｜': ''}}, regex=True)

    # 字下げ（行頭の全角スペース）を削除
    df_e = df_e.replace({'text': {'　': ''}}, regex=True)

    # 節区切りを削除
    df_e = df_e.replace({'text': {'^.$': ''}}, regex=True)
    df_e = df_e.replace({'text': {'^―――.*$': ''}}, regex=True)
    df_e = df_e.replace({'text': {'^＊＊＊.*$': ''}}, regex=True)
    df_e = df_e.replace({'text': {'^×××.*$': ''}}, regex=True)

    # 記号、および記号削除によって残ったカッコを削除
    df_e = df_e.replace({'text': {'―': ''}}, regex=True)
    df_e = df_e.replace({'text': {'…': ''}}, regex=True)
    df_e = df_e.replace({'text': {'※': ''}}, regex=True)
    df_e = df_e.replace({'text': {'「」': ''}}, regex=True)

    # 一文字以下で構成されている行を削除
    df_e['length'] = df_e['text'].map(lambda x: len(x))
    df_e = df_e[df_e['length'] > 1]

    # インデックスがずれるので振りなおす
    df_e = df_e.reset_index().drop(['index'], axis=1)

    # 空白行を削除する（念のため）
    df_e = df_e[~(df_e['text'] == '')]

    # インデックスがずれるので振り直し、文字の長さの列を削除する
    df_e = df_e.reset_index().drop(['index', 'length'], axis=1)
    return df_e


def save_cleanse_text(target_file):
    try:
        # ファイルの読み込み
        print(target_file)
        # Pandas DataFrameとして読み込む（cp932で読み込まないと異体字が読めない）
        df_tmp = pd.read_csv(target_file, encoding='cp932', names=['text'])
        # 元データをUTF-8に変換してテキストファイルを保存
        if save_utf8_org:
            out_org_file_nm = Path(target_file.stem + '_org_utf-8.tsv')
            df_tmp.to_csv(Path(tx_org_dir / out_org_file_nm), sep='\t',
                          encoding='utf-8', index=None)
        # テキスト整形
        df_tmp_e = text_cleanse_df(df_tmp)
        if write_title:
            # タイトル列を作る
            df_tmp_e['title'] = df_tmp['text'][0]
        out_edit_file_nm = Path(target_file.stem + '_clns_utf-8.txt')
        df_tmp_e.to_csv(Path(tx_edit_dir / out_edit_file_nm), sep='\t',
                        encoding='utf-8', index=None, header=write_header)
    except:
        print(f'ERROR: {target_file}')


def main():
    tx_dir = Path(author_id + './files/')
    # zipファイルのリストを作成
    zip_list = list(tx_dir.glob('*.zip'))
    # 保存ディレクトリを作成しておく
    tx_edit_dir.mkdir(exist_ok=True, parents=True)
    if save_utf8_org:
        tx_org_dir.mkdir(exist_ok=True, parents=True)

    for target_file in zip_list:
        save_cleanse_text(target_file)


if __name__ == '__main__':
    main()
```
# 実行結果

```:100_ruby_1154_org_utf-8.txt（元データ）
桃太郎
芥川龍之介
-------------------------------------------------------
【テキスト中に現れる記号について】
《》：ルビ
（例）桃《もも》
｜：ルビの付く文字列の始まりを特定する記号
（例）天地｜開闢《かいびゃく》の頃《ころ》おい
［＃］：入力者注　主に外字の説明や、傍点の位置の指定
　　　（数字は、JIS X 0213の面区点番号またはUnicode、底本のページと行数）
（例）※［＃「言＋墟のつくり」、第4水準2-88-74］
-------------------------------------------------------
［＃８字下げ］一［＃「一」は中見出し］
　むかし、むかし、大むかし、ある深い山の奥に大きい桃《もも》の木が一本あった。（…）
（…）
　この赤児《あかご》を孕《はら》んだ実は深い山の奥を離れた後《のち》、どういう人の手に拾われたか？――それはいまさら話すまでもあるまい。谷川の末にはお婆《ばあ》さんが一人、日本中《にほんじゅう》の子供の知っている通り、柴刈《しばか》りに行ったお爺《じい》さんの着物か何かを洗っていたのである。……
（…）
```

```:100_ruby_1154_clns_utf-8.tsv（前処理済みデータ）
text	title
むかし、むかし、大むかし、ある深い山の奥に大きい桃の木が一本あった。（…）	桃太郎
（…）
この赤児を孕んだ実は深い山の奥を離れた後、どういう人の手に拾われたか？それはいまさら話すまでもあるまい。谷川の末にはお婆さんが一人、日本中の子供の知っている通り、柴刈りに行ったお爺さんの着物か何かを洗っていたのである。	桃太郎
（…）
```

自然言語として処理するのに不要な部分が、無事削除されました。
クラス化するなり、他の処理したい項目を追加するなりしてお使いいただければと思います。