【2020年版】青空文庫から本文をスクレイピングして加工する

# 概要
青空文庫に掲載されている作品の本文を Python でスクレイピングしていい感じに加工しました。その際、ところどころハマったのでその覚書。

# 環境
- macOS Catalina
- Anaconda 3系
    - Python 3系
    - Jupyter Notebook
    - BeautifulSoup4

# 本文の取得
まずは青空文庫から作品の本文を取得します。
やることは基本的にこの記事(https://qiita.com/icy_mountain/items/011c9f56151b9832b54d) に書いてある通りで、[青空文庫 API(https://qiita.com/ksato9700/items/626cc82c007ba8337034) を叩いて本文の HTML をフェッチします。その際、URLの作品ID部分は `book_id` と変数を置くことにします。
しかし自分の環境だとこの通りにはできませんでした。まず、ターミナル上ではなく Jupyter Notebook 上で操作していたので、`!wget` コマンドは使えません。
そこで Python で API に GET リクエストを送るコマンドを調べてみると `urllib2.urlopen()` と `reqests.req()` の二つが出てくるんですが、このうち `urllib2` は Python2 用のライブラリなので使えません。Python3 では `urllib3` ないし `urllib` という名前に変わっているようですが、よくわからなかったので `requests` ライブラリを使うことにしました。つまり GET メソッドによるAPIのフェッチは次のようになります。

```python
import requests
res = requests.get('http://pubserver2.herokuapp.com/api/v0.1/books/{book_id}/content?format=html'.format(book_id))
```

# 本文の抽出
次に、フェッチしてきた HTML データを BeautifulSoup4 で使える形式に変換します。

```python
from bs4 import BeautifulSoup
soup = BeautifulSoup(res.text, 'html.parser')
```

ここから `title` タグの本文を取得するには以下のように書きます。

```python
soup.find('title').get_text()
# -> 夢野久作 青水仙、赤水仙
```

タイトルはいい感じに半角スペースで区切られているので、ここについて `split()` をすれば著者名とタイトルに分けられます。
外国人著者の場合など、うまくいっていない場合もあるので、いちおう残りの情報の有無も変数に置いておきます。

```python
title_list = title.split(' ')
        book_author = title_list[0] # 著者名
        book_title = title_list[1] # タイトル
        book_title_info = len(title_list) > 2 # タイトルが途切れているか
```

一方、（本来の意味での）本文は `main_text` クラスの `div` タグなので、以下のようになるでしょう。

```python
soup.find('div', {'class': 'main_text'}).get_text()
# -> \n\n\n\r\n\u3000うた子さんは友達に教わって、水仙の根を切り割って、赤い絵の具と青い絵の具を入れて、お庭の隅に埋めておきました。[...]
```

今回は句点ごとに文章を区切りたいので、以下のように一文ごとのリスト形式にしておきます。
冒頭の一文が欲しいのであれば、 0 番目の要素として取得できます。

```python
text_list = soup.find('div', {'class': 'main_text'}).get_text().split('。')
text_first = text_list[0] + "。" # 冒頭の一文
```

## 本文の精製
このままだと本文が汚いので、不要な要素を削除して本文を精製していきます。
まず、改行コード `\n` や半角スペース `\u3000` に当たるコードが混じってしまっているので、 `get_text()` 後に `strip()` で落とします。

```python
text_list = soup.find('div', {'class': 'main_text'}).get_text().strip('\r''\n''\u3000').split('。')
```

本文中に含まれるカッコで囲まれたルビの部分を削除するために、以下のコードを変換直後に追加してルビを削除します。

```python
    for tag in soup.find_all(["rt", "rp"]):
        tag.decompose() # タグとその内容の削除
```

たまに句点で区切られず、冒頭の一文が延々と続く場合があるので、あまりに長ければ別の文字列に置き換えることにします。長さの基準 100 は適当です。

```python
text_first = text_list[0] + "。" if (len(text_list[0]) < 100) else "too long" # 冒頭
```

最後に、以上の工程を関数化するにあたり、相当する作品 ID が存在せずフェッチに失敗していると抽出の際に NoneType エラーとなるので、タグとクラスの有無でこれを除外しておきます（もうちょっとマシな書き方があると思うのですが）。

```python
if (soup.find('title') is None) or (soup.find('div') is None) or (soup.find('div', {'class': 'main_text'}) is None):
        return [book_id, '', '', '', '', '' ]
else:
        title =  soup.find('title').get_text()
        [...]
```

```python:bookInfo.py
def bookInfo(book_id):
    import requests
    from bs4 import BeautifulSoup

    res = requests.get(f'http://pubserver2.herokuapp.com/api/v0.1/books/{book_id}/content?format=html')
    
    soup = BeautifulSoup(res.text, 'html.parser')
    for tag in soup.find_all(["rt", "rp"]):
        tag.decompose() # タグとその内容の削除
    
    if (soup.find('title') is None) or (soup.find('div') is None) or (soup.find('div', {'class': 'main_text'}) is None):
        return [book_id, '', '', '', '']
    else:
        title =  soup.find('title').get_text()
        title_list = title.split(' ')
        book_author = title_list[0] # 著者名
        book_title = title_list[1] # タイトル
        book_title_info = len(title_list) > 2 # タイトルが途切れているか
        
        print(soup.find('div', {'class': 'main_text'}))
        text_list = soup.find('div', {'class': 'main_text'}).get_text().strip('\r''\n''\u3000').split('。')
        text_first = text_list[0] + "。" if (len(text_list[0]) < 100) else "too long" #　冒頭
        else:
            text_last = ""
    
        list = [book_id, book_author, book_title, book_title_info, text_first]
        print(list)
        return list
```

```python
bookInfo(930)
# -> [930,
# '夢野久作',
# '押絵の奇蹟',
# False,
# '看護婦さんの眠っております隙を見ましては、拙ない女文字を走らせるので御座いますから、さぞかしお読みづらい、おわかりにくい事ばかりと存じますが、取り急ぎますままに幾重にもおゆるし下さいませ。']
```

# 精製データの CSV 出力
上記の関数を使って得られたリストを順に追加して二次元リストを作り、それを CSV ファイルとして出力したいと思います。
まず `csv` をインポートし、ファイルをオープンします。
ちなみにファイルがすでに存在し、全文上書きではなく後ろに書き足すときは `'w'` を `'a'` に変えます。

```python
import csv
f = open('output.csv', 'w')
writer = csv.writer(f, lineterminator='\n')
```

空のリストを作成し、 for ループを回して関数を実行した返り値をリストに追加していきます。
ちなみに青空文庫 API では一つの作品からの取得に数秒かかりました。あんまり大量にリクエストしすぎると利用不能になってしまう気がするので10とか100とか小さな単位ずつ実行するのがいいと思います。

```python
csvlist = []
for i in range(930, 940):
    csvlist.append(bookInfo(i))
```

最後にファイルを閉じて完了です。

```python
writer.writerows(csvlist)
f.close()
```

CSV ファイルが出力されました。 Google スプレッドシートなどに読み込ませると便利です。

```csv
930,夢野久作,押絵の奇蹟,False,看護婦さんの眠っております隙を見ましては、拙ない女文字を走らせるので御座いますから、さぞかしお読みづらい、おわかりにくい事ばかりと存じますが、取り急ぎますままに幾重にもおゆるし下さいませ。
```

# 余談
- 元々最初から Google スプレッドシートに GAS の関数で取得した値を直接書き込みたかったのですが、青空文庫 API で取得した HTML は meta タグが壊れているようで XML 構文解析に失敗するので、いろいろ試したんですがうまくいきませんでした。それで Python で一旦 CSV に変換するという手間を取ること。
- データがうまく取得できたとき「いいダシが取れたな〜」とつい思ったんですが、 BeautiflSoup ってつまり日本語で言う「ダシ」であることに気づいてしまった…。