roombaの日記

読書・非線形科学・プログラミング・アート・etc...

「読書メーター」に無い機能を実現し、データを分析してみる(Python)

はじめに

最近読書にはまっていて、過去記事が「2015年X月に読んだ本まとめ*1」ばかりになっています。が、今回は読んだ本のまとめではなく、読書に関するデータの分析を記事にしてみることにしました。

読書メーターとは?

本を読み終えると「読書メーター」に感想等を記すことにしています。「読書メーター」には感想の記録以外にもソーシャル的な機能があって、読書傾向の似たユーザーを「お気に入り」に追加することによってその人の新着感想をホーム画面に表示することができるのです。自分は現時点で94人をお気に入りに登録しているため、毎日いくつもの感想を新たによむことができ、読みたい本が増える一方です。
http://bookmeter.com/

読書メーターに無い機能

そんな読書メーターですが、「お気に入りユーザーに最も読まれている本」を表示する機能があればいいなと常々考えていました。「X月に読まれた本ランキング」というのは公式に見ることができるのですが、これは全ユーザーの集計であって、個人的には興味の無いベストセラー本ばかりが並んでいます。「お気に入りユーザーに最も読まれている本」であれば、自分と似た読書傾向を持つ人がどのような本を読んでいるのかを知ることができ、新たな本選びの参考になるものと考えられます。

自力でスクレイピング

というわけで、自力で読書メーターからデータを集めるプログラムを作成し、集計を行いました。スクレイピングっていうんですかね?

おまけ:読書データを分析し、べき分布を見出す

記事の最後には、ついでに得られたデータを分析してみて、そこに「べき分布」が見出せたということを記しています。

「お気に入りユーザーに最も読まれている本」を調べる

概要

おおまかな流れとしては以下のようになります。

  1. 自分のマイページのHTMLを取得
  2. 上記HTMLを解析し、お気に入りユーザーのリスト(正確にはユーザーIDのリスト)を作成
  3. それぞれのお気に入りユーザーについてマイページのHTMLを取得し、その人が読んだ本のリスト(正確には本のIDのリスト)を作成
  4. 上記のユーザーごとの読んだ本リストを結合・集計し、目的の「お気に入りユーザーに最も読まれている本」ランキングを作成

結果

具体的なプログラムは後回しにして、とりあえず結果を貼ります。
「自分のお気に入りユーザーに最も読まれている本」ランキング、1位から30位です。
f:id:roomba:20150704200201p:plain
こうしてみると、ベスト10は新潮文庫の小説ばかりで、「夏の100冊」に含まれていそうな名作が多いですね。10ー30位は村上春樹だらけです。これは、個人的な傾向として「小説(特に名作とよばれるもの)を主に読んでいるユーザー」をお気に入りに登録していることによるのだと思われます。
なお、自分自身はこのランキング中の1位から9位はすべて読んだことがあり、1ー30位のなかでは16冊が既読でした。未読のものも結構あるので、「みんな読んでいることだし、その中からどれか読んでみようかなー」という気になります。

ちなみに、お気に入りユーザー94人でおよそ5万冊程度の本が読まれており、その種類は3万冊程度に及びました。

実装上のポイント

■ マイページURLとユーザーIDの関係
マイページのURLは以下のようになっており、読書メーターにログインしなくても表示することができます。

http://bookmeter.com/u/【ユーザーID】

また、そのユーザーがお気に入りに登録しているユーザーは

http://bookmeter.com/u/【ユーザーID】/favorite_user

に表示されます。
したがって、自分のIDがXXXXXXであれば

http://bookmeter.com/u/XXXXXX/favorite_user

のHTMLを解析することでお気に入りのリストを得ることができるわけです。

■ お気に入りリストのHTMLの解析

http://bookmeter.com/u/XXXXXX/favorite_user

のHTMLをよく見てみると、XXXXXXさんのお気に入りユーザーが

<a href="/u/【お気に入りユーザーID】" title="お気に入りユーザー名">

のような形で記されていることがわかります。したがって、Pythonの場合はHTMLParserというものを使って上記の形式に合致する部分を調べればお気に入りユーザーのIDを取得することができます。

ここで注意しないといけないのは、お気に入りユーザー数が多い場合には複数ページに分割してお気に入りユーザーが表示されるという点です。
その場合、

http://bookmeter.com/u/【ユーザーID】/favorite_user&p=2
http://bookmeter.com/u/【ユーザーID】/favorite_user&p=3
.....

なども全て同様に解析する必要があります。

■ お気に入りユーザーが読んだ本のURLと、HTMLの解析
お気に入りユーザー(仮にIDをYYYYYYとしましょう)が読んだ本のリストを取得する際には、

http://bookmeter.com/u/YYYYYY/booklist

のHTMLを取得・解析します。この際、先ほどと同様

http://bookmeter.com/u/YYYYYY/booklist&p=2
http://bookmeter.com/u/YYYYYY/booklist&p=3
......

もあわせて調べる必要があります。
このHTMLをみてみると、読んだ本のIDが

<a href="/b/【本のID】">

のような形で記されていることがわかります。したがって、HTMLParserを使って上記の形式に合致する部分を調べればYYYYYYさん読んだ本のIDリストを取得することができます。
本のIDは基本的に数字ですが、ときどきXという文字を含むことがあるので文字列として扱うのがよいでしょう。

■ 個別の本のIDとURLの関係・書名の取得方法
本のIDの取得方法は上記のとおりですが、その本に対応するページのURLは

http://bookmeter.com/b/【本のID】

となっています。ここのHTMLをみてみると、書名が

<h1 id="title" class="fn">書名</h1>

のような形で記されているため、この形式に合致する部分を調べればIDから書名を得ることができるわけです。

実装

概要・結果・ポイントを示したので、ここに詳細な方法を記すことにします。

まず、冒頭は次のようにします。

#! /usr/bin/python                                                                                                                                                       
# -*- coding: utf-8 -*-                                                                                                                                                  

from HTMLParser import HTMLParser
import urllib2

概要にしたがって、メイン関数は以下のようになります。それぞれの関数は後で紹介します。「(4)それぞれの本を読んだお気に入りユーザ数の頻度分布をファイルに出力する」で得られた情報は、本記事末尾で用います。

# Main関数                                                                                                                                                               
if __name__ == '__main__':
    my_id = ******# 自分の読書メーターID                                                                                                                                 
    # (1) お気に入りに登録しているユーザのIDリストを取得                                                                                                                 
    fav_id_list = get_ids(my_id)
    # お気に入りユーザの数を表示                                                                                                                                         
    print "Number of Favorite Users: " + str(len(fav_id_list))

    # (2) 読んでいるお気に入りユーザの数が多い順に[本のID, 読んだユーザ数]が並んだリストを作成                                                                           
    # お気に入りユーザ各々が既読の本を調べ、本のIDを全てall_booksに収める(重複あり)                                                                                      
    all_books = []
    for k in range(len(fav_id_list)):
        print "Checking books read by User No. " + str(k+1) + "..."
        all_books += get_books(fav_id_list[k])
    # all_booksから重複を排除したリストをbooks_setとする。                                                                                                               
    # このリストは、「お気に入りユーザの一人以上が読んだ本全てのIDリスト」である                                                                                         
    books_set = list(set(all_books))
    # お気に入りユーザの一人以上が読んだ本それぞれについて、何人が読んだかカウント                                                                                       
    count_list = [all_books.count(i) for i in books_set]
    # (本のID, その本を読んだお気に入りユーザ数)を対応付けた辞書を作成                                                                                                   
    book_dict = dict([(books_set[i],count_list[i]) for i in range(len(books_set))])
    # 既読数によってbook_dictをソートし、sorted_dictとする                                                                                                               
    sorted_dict = sorted(book_dict.items(), key=lambda x:x[1])
    # 大きい順になるようリバース                                                                                                                                         
    sorted_dict.reverse()
    print "Your "+str(len(fav_id_list))+" Favorite Users Have Read"+str(len(all_books)) + " Books."
    print "Your "+str(len(fav_id_list))+" Favorite Users Have Read"+str(len(books_set)) + " Different Books."

    # (3) ranking.txtに、お気に入りユーザの多くが読んだ本ランキングを出力                                                                                                
    f = open("ranking.txt", "w")
    for i in range(300):# 300位まで                                                                                                                                      
        print "Writing No. " + str(i+1) + " book."
        f.write(str(i+1) + ", ")# 順位                                                                                                                                   
        f.write(str(sorted_dict[i][1]) + ", ")# 読んだお気に入りユーザの数                                                                                               
        f.write(get_title_from_id(sorted_dict[i][0]).encode('utf-8') + "\n")# 書名                                                                                       
    f.close()

    # (4) それぞれの本を読んだお気に入りユーザ数の頻度分布をファイルに出力する                                                                                           
    num_list = [ t[1] for t in sorted_dict ]# 各本を読んだお気に入り数のリスト                                                                                           
    # (既読者数, その既読数がついた本の種類数)を並べたリストをつくる                                                                                                     
    freq_list = [ (i, num_list.count(i)) for i in range(num_list[0]+1) ]
    # freq.txtに書き出す                                                                                                                                                 
    f2 = open("freq.txt", "w")
    for k in freq_list:
        f2.write(str(k[0]) + ", ")# 既読者数                                                                                                                             
        f2.write(str(k[1]) + "\n")# その既読者数がついた本の種類数                                                                                                       
    f2.close()

メイン関数の「(1) お気に入りに登録しているユーザのIDリストを取得」ではget_ids関数を呼び出しています。get_ids関数で用いるparse_ids関数とあわせて以下に示します。
手順は前述の「実装上のポイント」を参考にしてください。

# 個人ページのHTMLを受け取って、「お気に入り」のIDリストを返す関数                                                                                                       
# 引数:HTMLのstring, 返り値:お気に入りユーザのIDリスト                                                                                                                 
def parse_ids(html_str):
    id_list = []# ここにIDを収める                                                                                                                                       
    class WordParser(HTMLParser):# HTMLパーサ                                                                                                                            
        def __init__(self):
            HTMLParser.__init__(self)
        def handle_starttag(self, tag, attrs):
            if tag == 'a' and len(attrs) > 1:
                if attrs[0][0] == "href" and attrs[0][1].find("/u/") == 0:
                    id_list.append(int(attrs[0][1][3:]))
    wp = WordParser()
    wp.feed(html_str)
    wp.close()
    return list(set(id_list))# setにより重複を消し、それをlist型に戻しておく                                                                                             

# あるユーザがお気に入りに登録しているユーザのIDリストを取得する関数                                                                                                     
# 引数:お気に入りを調べたいユーザのID(int), 返り値:お気に入りユーザのIDリスト                                                                                          
def get_ids(user_id):
    full_id_list = []# ここにIDを収める                                                                                                                                  
    count = 1# お気に入りユーザを表示するページが複数に渡る場合、その数                                                                                                  

    # HTMLを取得                                                                                                                                                         
    url = "http://bookmeter.com/u/" + str(user_id) + "/favorite_user"
    response = urllib2.urlopen(url)
    html_data = unicode(response.read(), 'utf-8')

    full_id_list +=  parse_ids(html_data)# parse_ids関数によってIDリストを取得                                                                                           
    count += 1

    # お気に入りユーザが多数の場合、複数のページに渡ってHTMLを取得・解析する                                                                                             
    while "next" in html_data:
        url = "http://bookmeter.com/u/"+str(user_id)+"/favorite_user&p="+str(count)
        response = urllib2.urlopen(url)
        html_data = unicode(response.read(), 'utf-8')
        full_id_list += parse_ids(html_data) # full_id_listに追加                                                                                                        
        count += 1
    return full_id_list

メイン関数の「(2) 読んでいるお気に入りユーザの数が多い順に[本のID, 読んだユーザ数]が並んだリストを作成」では、get_books関数を呼び出しています。get_books関数で用いるparse_books関数とあわせて以下に示します。
なお、このままでは一人のユーザーが同じ本を複数回再読した場合、その回数だけ重複してカウントされるようになっています。これを回避するためには、get_books関数の下から3行目の#を除いてください。id:karaage さん、ありがとうございました。)

# あるユーザの既読本リストのHTMLを受け取って、既読本のIDリストを返す関数                                                                                                 
# 引数:HTMLのstring, 返り値:記読本のIDリスト                                                                                                                           
def parse_books(book_html):
    book_list = []# ここにIDを収める                                                                                                                                     
    class WordParser(HTMLParser):# HTMLパーサ                                                                                                                            
        def __init__(self):
            HTMLParser.__init__(self)
#            self.flag = False                                                                                                                                           
        def handle_starttag(self, tag, attrs):
            if tag == 'a':
                if attrs[0][0] == "href" and attrs[0][1].find("/b/") == 0:
                    book_list.append(attrs[0][1][3:])
    wp = WordParser()
    wp.feed(book_html)
    wp.close()
    return list(set(book_list))# setにより重複を消し、それをlist型に戻しておく                                                                                           

# あるユーザのIDを受け取って、そのユーザの既読本のIDリストを返す関数                                                                                                     
# 引数:ユーザのID(int), 返り値:記読本のIDリスト                                                                                                                        
def get_books(user_id):
    full_book_list = []# ここにIDを収める                                                                                                                                
    count = 1# 既読本リストを表示するページが複数に渡る場合、その数                                                                                                      

    # HTMLを取得                                                                                                                                                         
    booklist_url = "http://bookmeter.com/u/" + str(user_id) + "/booklist"
    response = urllib2.urlopen(booklist_url)
    booklist_html = unicode(response.read(), 'utf-8')

    full_book_list +=  parse_books(booklist_html)# parse_books関数でIDリストを取得                                                                                       
    count += 1

    # 既読本が多数の場合、複数のページに渡ってHTMLを取得・解析する                                                                                                       
    while "booklist&p="+str(count) in booklist_html:
        booklist_url = "http://bookmeter.com/u/" + str(user_id) + "/booklist&p=" + str(count)
        response = urllib2.urlopen(booklist_url)
        booklist_html = unicode(response.read(), 'utf-8')

        full_book_list += parse_books(booklist_html)# full_book_listに追加                                                                                               
        count += 1
    
    # 再読をカウントしない場合、以下の行を追加
    # full_book_list = list(set(full_book_list))
    print "User " + str(user_id) + ": " + str(len(full_book_list)) + " books."
    return full_book_list

メイン関数の「(3) ranking.txtに、お気に入りユーザの多くが読んだ本ランキングを出力」では、get_title_from_id関数を呼び出しています。get_title_from_id関数で用いるparse_title関数とあわせて以下に示します。

# ある本のページのHTMLを受け取って、その本のタイトルを返す関数                                                                                                           
# 引数:HTMLのstring, 返り値:タイトル(str)                                                                                                                              
def parse_title(book_html):
    title = []
    # HTMLパーサ                                                                                                                                                         
    class WordParser(HTMLParser):
        def __init__(self):
            HTMLParser.__init__(self)
            self.flag = False
        def handle_starttag(self, tag, attrs):
            if tag == 'h1':
                self.flag = True
        def handle_data(self, data):
            if self.flag:
                title.append(data)
        def handle_endtag(self, tag):
            if tag == 'h1' and self.flag:
                self.flag = False
    wp = WordParser()
    wp.feed(book_html)
    wp.close()
    return title[0]

# ある本のIDを受け取って、その本のタイトルを返す関数                                                                                                                     
# 引数:本のID(str), 返り値:記読本のIDリスト                                                                                                                            
def get_title_from_id(book_id):
    book_url = "http://bookmeter.com/b/" + book_id
    response = urllib2.urlopen(book_url)
    book_html = unicode(response.read(), 'utf-8')
    return parse_title(book_html)

おまけ:読書データを分析し、べき分布を見出す

プログラムのメイン関数の手順(4)では、以下のようなデータが得られます。

  • お気に入りユーザー1人のみによって読まれた本:27877種類
  • お気に入りユーザー2人のみによって読まれた本:4152種類
  • お気に入りユーザー3人のみによって読まれた本:1371種類

etc...

横軸を読んだお気に入りユーザーの数X・縦軸をお気に入りユーザーX人に読まれた本の種類数としてグラフにすると、以下のように急激に減少した形をとることがわかります。グラフで右側に位置しているほど私のまわりで人気な本ということになり、人気はごく一部の本に集中しているということがわかります。
f:id:roomba:20150704224713p:plain
これを「両対数グラフ」*2にしてみると、以下のようにほぼ直線に乗っていますね。右下の方はデータ数が少ないのでバラついていますが。
f:id:roomba:20150704224712p:plain
対数グラフで直線に乗るということは、これがべき分布であるということを示しています。
一般的にいって「強いものほどより強くなる」という傾向を持つ現象には「べき分布」があらわれると言われています(たぶん)。たとえば、「お金持ちほどお金を稼ぎやすい」ことから「所得税の8割は、課税対象者の2割が担っている」といった現象が生じるわけです。今回の場合、「みんなに読まれている本は新たな読者を獲得しやすい」ことから「一部の人気本に読者が集中している」という冪乗則が確認されたのだと考えることができます。

おわりに

一応利用規約を確認してみましたが、スクレイピングは禁止されていませんよね。
本記事内のプログラムは自由に利用してもらって構いませんが、「サーバ又はネットワークへ著しく負荷をかける行為」までいかないよう、節度を守りましょう!

追記

id:karaageさんが、本記事をもとに「自分をフォローしている人がどんな本を読んでいるのか」を調べてくださいました。あわせてご覧ください。karaage.hatenadiary.jp
うれしいお言葉も…! ありがとうございました。

*1:roomba.hatenablog.com

*2:両対数グラフ - Wikipedia