最終更新日:2021/01/29 原本2021-01-29

作って試そう! ディープラーニング工作室:[文章生成]スクレイピングで青空文庫からデータを取得してみよう

機械学習を使って文章の自動生成を行う準備として、青空文庫から小説のデータを取得して、本文のテキストを1文ずつリストに格納してみましょう。


「作って試そう! ディープラーニング工作室」のインデックス

連載目次

目次


今回の目的

 前回までは画像処理についていろいろと試してきました。今回からは数回に分けて、自然言語処理(Natural Language Processing)について学んでいく予定です。ここ数年、機械学習の世界においてはTransformerやGPT-x、BERTなどなど、さまざまな技術が生み出されて、自然言語処理の分野が活況を呈しています。その適用領域も翻訳、文章の要約、感情分析、チャットボットなどなど、幅広いものです。

 そうした中で、取りあえず今回からは青空文庫から著作権の切れた作品を学習データとして、文章生成を行うことを目的として、自然言語処理にまつわるさまざまな要素を学んでいくつもりです。

青空文庫のトップページ
青空文庫のトップページ

 今回は青空文庫から小説のデータを取得する処理を順を追って見て、それらを関数にまとめることにします。なお、今回のコードはこのノートブックで公開しています。

小説のデータを1つだけ抽出するコード

 データの取得にはPython入門でも扱ったBeautiful Soup 4を使います。詳しいことは前掲のリンクを参照していただくとして、まずは梶井基次郎の小説を幾つか取得するコードを完成させていきましょう。

 以下は「梶井 基次郎」の著作一覧です。

梶井基次郎の著作一覧
梶井基次郎の著作一覧

 ここでは例として少し短めの作品である『桜の樹の下には』(新字新仮名)のデータを取り出してみることにしましょう。このリンクをクリックすると、次のようなページが表示されます。

作品ページ
作品ページ

 取得可能なファイルの種類には3つありますが、ここでは[ファイル種別]が[XHTMLファイル]となっているファイルを取得することにしました。このリンク(427_19793.html)をクリックすると以下のように作品が表示されます(ZIPファイルをダウンロードした場合にはルビを含んだプレーンテキストを手に入れられますが、筆者の趣味でXHTMLファイルをここでは使うことにしました)。

小説の本文
小説の本文

 ここでは作品名や著者名なども表示されていますし、何よりルビが丁寧に振ってあります。が、これらの情報については後ほど取り除いてしまいます。必要なのは、小説の本文テキストだけです。

 というわけで、urllib.requestモジュールが提供するurlopen関数を使って、このページの内容を取得して、これをBeautiful Soup 4に入力すれば、さまざまな操作が可能なオブジェクトが手に入ります。

 ここまでの処理を実際に行うのが、以下のコードです。

from bs4 import BeautifulSoup
from urllib import request

url = 'https://www.aozora.gr.jp/cards/000074/files/427_19793.html'
response = request.urlopen(url)
soup = BeautifulSoup(response)
response.close()

print(soup)

『桜の樹の下には』のWebページの内容を取得するコード

 最後に「print(soup)」という行があるので、このコードを実行すると、取得したWebページの内容(XHTML)が以下のように表示されます。

実行結果
実行結果

 ご覧の通り、本文テキストだけではなく、さまざまなタグが含まれています。そこで、まずは<div>タグ(class属性が"main_text")となっている部分だけを取り出しましょう。

main_text = soup.find('div', class_='main_text')
print(main_text)

小説の本文だけを取り出す

 これを実行すると、次のようになります。

実行結果
実行結果

 これで小説の本文テキストだけが得られましたが、気になるのは山ほど入っているルビ関連のタグです。手作業でこれらを削除していくのは大変ですが、実はfindメソッドで取得した本文テキスト(main_textオブジェクト)はBeautiful Soup 4のTagオブジェクトとなっていて、このオブジェクトにはdecomposeメソッドが用意されています。このメソッドは特定のタグとその内容を削除するのに使えます。そこで、上で取り出したmain_textでルビ関連のタグ(の一部)を削除してみましょう。

tags_to_delete = main_text.find_all(['rp', 'rt'])
for tag in tags_to_delete:
    tag.decompose()
print(main_text)

ルビ関連のタグ(の一部)を削除

 ここでは<rp>タグと<rt>タグの2つだけを削除の対象としています。それ以外はもちろん残ってしまうのですが、<rb>タグの内容は削除してしまっては困るもの(ルビを振る文字そのもの)ですから、これはそういうものだと思いましょう。<ruby>タグも同様で、これを削除してしまうとルビだけではなく、本文テキストの一部まで削除してしまいます。

 実行結果は次のようになります。

実行結果
実行結果

 今述べたように、<ruby>タグと<rb>タグは依然として残っていますし、その他のタグもまだ残っています。これらはどうすればよいでしょう。Beautiful Soup 4のTagオブジェクトには「get_textメソッド」という便利なメソッドがあります。これは人が読めるようなテキストを抜き出すのに使えます(戻り値はBeautiful Soup 4のオブジェクトではなく、単なる文字列です)。実際に使ってみましょう。

main_text = main_text.get_text()
print(main_text)

可読性のあるテキストだけを抽出

 これを実行した結果は以下の通りです。

実行結果
実行結果

 うん。キレイになりましたね(見た目は)。ということは、<rp>タグと<rt>タグもこの方法で消してしまえばよかったように思えます。実際に(タグを削除する前のmain_textから)これらのタグもget_textメソッドで削除してみた結果を以下に示します。

タグは消えるがルビの情報である「(したい)」などが残ってしまう
タグは消えるがルビの情報である「(したい)」などが残ってしまう

 タグは消えますが、ルビの情報である「(したい)」などがテキスト中に含まれていることに注目してください。これらは全角かっこ「()」に囲まれたひらがなですから、正規表現を使って「main_text = re.sub('([\u3041-\u309F]+)', '', main_text)」のようなことをすることで削除可能です。が、本文テキストとしてこのような文字の並びが登場する可能性はゼロとはいいきれません。そこで、<rp>タグと<rt>タグという情報を手がかりとして、削除してもよいものを前もって処理しておくことにしました。

 ところで、先ほどの文章に「(見た目は)」とあるのに気が付いた方もいらっしゃるかもしれません。見た目とはどういうことでしょう。これはprint関数にmain_textを渡すのではなく、「main_text」とだけセルに入力して、Google Colab上で評価してみると分かります。

余計な文字が埋まっている
余計な文字が埋まっている

 「\r」「\n」「\u3000」などの文字がmain_textオブジェクトに埋め込まれているのが分かります(最後の「\u3000」は全角空白文字のコードポイント)。これは文字列のreplaceメソッドを使って削除してしまいましょう。

main_text = main_text.replace('\r', '').replace('\n', '').replace('\u3000', '')
main_text

余計な改行文字/空白文字を削除

 実行結果は以下の通りです。

実行結果
実行結果

 最後にエクスクラメーションマーク「!」と句点「。」の直後に改行を含めるようにします。これで1文ごとに改行されるようになります。といっても、そうしたいのではなく、最後にこれをsplitlinesメソッドで個々の文を要素とするリストを作成しておくためです(次回以降の処理で役立つような気がしたのでそうしています)。

import re

main_text = re.sub('([!。])', r'\1\n', main_text)  # 。と!で改行
text_list = main_text.splitlines()
print(text_list)

1文ずつ改行するように

関数にまとめる

 『桜の樹の下には』についてはだいたいこんな感じでスクレイピングができました。ここで行っていた処理を関数にまとめてしまいましょう。

def get_data(url):
    response = request.urlopen(url)
    soup = BeautifulSoup(response)
    response.close()

    main_text = soup.find('div', class_='main_text')
    tags_to_delete = main_text.find_all(['rp', 'rt'])
    for tag in tags_to_delete:
        tag.decompose()
    main_text = main_text.get_text()
    main_text = main_text.replace('\r', '').replace('\n', '').replace('\u3000', '')
    main_text = re.sub('([!。])', r'\1\n', main_text)
    text_list = main_text.splitlines()

    return text_list

これまでの処理をまとめた関数

 では、実際にこの関数を使ってみます。

url = 'https://www.aozora.gr.jp/cards/000074/files/427_19793.html'
text_list = get_data(url)
print(text_list)

get_data関数を使ってみる

 実行結果を以下に示します。

実行結果
実行結果

 どうやらうまくいっているようです。そこで、今度は少し長めの作品のデータを取ってくることにしましょう。梶井基次郎といえば誰も知っている(かもしれない)『檸檬』でもいいなと思ったのですが、『桜の樹の下には』とは少し表示が異なる(上では使っていなかったHTML要素を含んだ)作品の例として、ここでは『城のある町にて』のデータを得てみます。

url = 'https://www.aozora.gr.jp/cards/000074/files/429_19794.html'
text_list = get_data(url)
print(text_list)

『城のある町にて』のデータを取得してみる

 実行結果を以下に示します。

実行結果
実行結果

 一見よさそうですが、実はリストの先頭要素がおかしいことに注意してください。「ある午後「高いとこの眺めは……」となっています。この「ある午後」というのは、実は見出しです。

「ある午後」は見出し
「ある午後」は見出し

 ソースを確認したところ、これは<h4>タグで表現されていたので、これもdecomposeメソッドでの削除の対象としてしまうことにします。また、会話を表すカギかっこも不要と判断しました。ここでは、開きカギかっこは削除して、閉じカギかっこは改行文字に変換することにしましょう。これらの修正を反映したget_data関数の定義は次のようになります。

def get_data(url):
    response = request.urlopen(url)
    soup = BeautifulSoup(response)
    response.close()

    main_text = soup.find('div', class_='main_text')
    tags_to_delete = main_text.find_all(['rp', 'rt', 'h4'])
    for tag in tags_to_delete:
        tag.decompose()
    main_text = main_text.get_text()
    main_text = main_text.replace('\r', '').replace('\n', '').replace('\u3000', '')
    main_text = main_text.replace('「', '').replace('」', '\n')
    main_text = re.sub('([!。])', r'\1\n', main_text)
    main_text.replace('\n\n', '\n')
    text_list = main_text.splitlines()

    return text_list

text_list = get_data(url)
print(text_list)

修正後のget_data関数定義

 実行結果を以下に示します。

実行結果
実行結果

 リストの各要素を筆者がざっくりと確認したところでは、それほどおかしいところもなさそうだったので、取りあえず、青空文庫からデータを取得する関数はこれで完成としておきます。

複数の作品データを取得する

 get_data関数はURLを指定すると、そのページが提供している単一の作品のデータを取得するものです。しかし、実際には青空文庫からデータを取得する際には、幾つかの作品のデータを得たいのではないでしょうか。もちろん、上の関数を使ってこれは可能です。以下はその例です。

url_list = ['https://www.aozora.gr.jp/cards/000074/files/427_19793.html',
    'https://www.aozora.gr.jp/cards/000074/files/429_19794.html']
text_list = []

for url in url_list:
    text_list.extend(get_data(url))

複数の作品のデータをまとめて取得

 しかし、このurl_listに格納しているURLを手作業でコピー&ペーストするのはバカらしいですよね(特に多数の作品のデータを取得したいとすれば)。ある作者の著作を全て取得できるような方法があるとうれしいのではないでしょうか。例えば、以下は梶井基次郎の著作一覧です。

梶井基次郎の著作一覧
梶井基次郎の著作一覧

 このような一覧があれば自動化も可能な気がします。そこで以下のコードで著作一覧のHTMLソースがどうなっているかを確認してみましょう。

base_url = 'https://www.aozora.gr.jp/index_pages/'
author = 'person74.html'  # 梶井基次郎
response = request.urlopen(base_url + author)
soup = BeautifulSoup(response)
print(soup)

著作一覧ページのHTMLソースを取得

 ここで注意してほしいのは、梶井基次郎の著作一覧ページのURLをbase_urlとauthorの2つに変数に分けている点です。base_urlはこの後にページ遷移をする際の基本となるURLです。

 実行結果の中で、関係ありそうな部分を以下に示します。

実行結果
実行結果

 <ol>タグ配下の<li>タグの中に作品ページへのリンク(<a>タグ)が含まれているのが分かります。この情報を使えば、作品ページへ遷移をして、 そこからHTMLファイルへのパスを取得できるはずです。

 例えば、次のようにすれば作品ページへのURLを取得して、そのページの内容を得ることができるでしょう(ここでは、話を単純にするために先頭のURLだけを見ています)。

url_list = [item['href'] for item in soup.find('ol').find_all('a')]
title_page_url = base_url + url_list[0]
print(title_page_url)

作品一覧に含まれている最初の作品のページへ遷移するためのURLを取得

 ここでは、<ol>タグに含まれている<a>タグを見つけて、そのhref属性の値を、リスト内包表記を使ってリストにまとめています。次に、base_urlとurl_listの先頭要素の値とを連結することで、作品ページへのURLとします(このために、先ほどは梶井基次郎の著作一覧ページのURLをbase_urlとauthorの2つの変数に分割しました)。これを実行すると次のようになります。URLを格納しているtitle_page_urlはこの後でも使用します。

実行結果
実行結果

 こうして取得したURLを使って、作品ページの内容を読み込むコードが以下です。

response = request.urlopen(title_page_url)
title_page = BeautifulSoup(response)
print(title_page)

作品ページの内容を読み込むコード

 実行結果は次のようになります。

実行結果
実行結果

 この画像の中程にある「いますぐXHTML版で読む」という箇所に注目してください。ここにXHTMLファイルへのリンクが含まれています(上では[ファイルのダウンロード]セクションの[ファイル種別]が[XHTMLファイル]となっているリンクを見ていましたが、これらは同じものなので、ここではこちらからURLを取得することにしました)。

 これを取得して、実際に作品データを取得するのが以下のコードです。

read_xhtml_div = title_page.find_all('div', align='right')[1]
html_path = read_xhtml_div.find_all('a')[1]['href']
title_page_url = re.sub('card\d+\.html', '', title_page_url)
print(title_page_url)
get_data(title_page_url + html_path)

作品データを取得するコード

 ここからはHTMLソースに依存した汚いコードになっていますが、最初の行では「<div align="right">」タグをHTMLから検索しています。そのインデックス1の位置にあるのが「いますぐXHTML版を読む」というリンクを含んだ部分です。さらに、その<div>タグの中で<a>タグを検索して、やはりインデックス1となるタグのURL(href属性)を得るようにしました。3行目は、上で作品ページのURLを格納しているtitle_page_urlから末尾の'cardXXX.html'という部分を削除して、上で取得したURLと結合することで、作品データのURLを作成するコードです。このようにして、作成したURLをget_data関数に渡せば、その内容が得られます。

 実行結果を以下に示します。

実行結果
実行結果

 というわけで、ここで行った処理をやはり関数にまとめておきましょう。

import time

def get_all_title_data():
    base_url = 'https://www.aozora.gr.jp/index_pages/'
    author = 'person74.html'
    response = request.urlopen(base_url + author)
    author_page = BeautifulSoup(response)

    text_list = []
    url_list = [item['href'] for item in author_page.find('ol').find_all('a')]
    for url in url_list:
        title_page_url = base_url + url
        response = request.urlopen(title_page_url)
        title_page = BeautifulSoup(response)

        read_xhtml_div = title_page.find_all('div', align='right')[1]
        html_path = read_xhtml_div.find_all('a')[1]['href']
        title_page_url = re.sub('card\d+\.html', '', title_page_url)
        result = get_data(title_page_url + html_path)
        text_list.extend(result)
        time.sleep(5)

    return text_list

著作一覧ページから作品データをスクレイピングするコード

 この関数はここまでに見てきた処理を、著作一覧ページに含まれている各作品のURLのリストに対して行うようにしてあります。また、サーバーへの負荷を考慮して、timeモジュールのsleep関数を使い、作品データを読み込むごとに5秒間スリープするようにしました。

 実際に使ってみた例を以下に示します。

text_list = get_all_title_data()

梶井基次郎の作品データを一括して取得

 引数に別の著者の作品一覧ページ('personXXXX.html’)を渡すことも考えましたが、動作は未確認です。興味のある方は試してみてください。

 というわけで、今回は青空文庫から著作権が切れた小説の本文データを取得するまでの処理を見ました。次回は、このデータを使って少し遊んでみることにしましょう。

「作って試そう! ディープラーニング工作室」のインデックス

作って試そう! ディープラーニング工作室