Iruca Log

Iruca Log

東京に住むWeb系エンジニアによる技術&雑記ブログ

SNSでフォローする!

はてなブログの読者をクロールしてブログ読者のデータを集めるクローラを書いた[python]

こんにちは、イルカです。

今回は、はてなブログ読者をクロール(巡回)して、各ブログの読者数を調べたりはてなユーザのソーシャルネットワークを調べたりするプログラムを書いてみました。

はじめに

こちらの記事で、はてなブログのページから読者数を抜き出すプログラムについて触れています。
iruca21.hateblo.jp

こちらで作ったsubscription_util.pyというプログラムを利用して、今回のクローラを書いています。


また、取得したブログの読者数はローカルのSQLiteに保存します。

データベースの作成

さて、まずはweb上から取得してきたデータを保存するSQLiteのテーブルを定義します。
こんな感じにしてみました。

データベース名: hateblo_subscription.db
テーブル名: subscription

列名 データ型 インデックス メモ
hatena_id text 主キー ユーザのはてなID文字列
subscription_count integer ユーザのメインのはてなブログの読者数
update_date text クローラがデータを更新した最終日時


下記のpythonスクリプトを使って、テーブルを生成しておきます。

create_hatena_db_and_table.py

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


# python2.7標準で入ってるはず
import sqlite3

db_name = "hateblo_subscription.db"
table_name = "subscription"
sql = """CREATE TABLE %s(
hatena_id text,
subscription_count integer,
update_date text,
PRIMARY KEY(hatena_id))
""" % ( table_name )

print sql

#ローカルのDBへの接続取得
connection = sqlite3.connect(db_name)

#カーソル取得
cursor = connection.cursor()

#SQL実行
cursor.execute( sql )

#コミット
connection.commit()

#接続を閉じて終了
connection.close()


SQLiteの基本の使い方についてはこちらの記事もどうぞ。
iruca21.hateblo.jp


データベースへのアクセッサの作成

データベースの仕様を決めたので、次は作ったテーブルにアクセスするモジュールを書く必要があります。
SubscriptionDataAccessorという名前に決めて、データを保存する役目を担うクラスを作ります。


subscription_data_accessor.py

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


class SubscripitonDataAccessor:
    """ ローカルにSQLiteに保存しているSubscription(購読者数)
    データへの操作を行うクラス
    """
    
    DB_NAME = "hateblo_subscription.db"
    TABLE_NAME = "subscription"

    # データの存在確認のためのSQL
    EXISTENCE_CHECK_SQL = """
SELECT hatena_id
FROM   %s
WHERE  hatena_id = "%s"
"""
    # 特定の日付以降に更新されたデータ存在確認のためのSQL
    EXISTENCE_CHECK_WITH_DATE_SQL = """
SELECT hatena_id
FROM   %s
WHERE  hatena_id = "%s"
AND update_date >= "%s"
"""
    # 購読者数データ上書き更新用SQL
    REPLACE_SQL = """
REPLACE INTO %s 
VALUES( "%s", %d, "%s" )
"""
    

    def __init__(self):
        """クラスの初期化と同時に、
        SQLiteのDBへの接続も取得する
        """
        import sqlite3
        self.connection = sqlite3.connect( self.DB_NAME )

    def data_exists( self, hatena_id, hour=0 ):
        """既にDBの中に該当ユーザの情報が入っているかどうかをチェックする。
        既に入っていたらtrueを返す。
        Args:
            connection: ローカルのsqliteのDBへのコネクションオブジェクト
            hatena_id: ユーザのはてなID文字列
            hour: データの更新日時が何時間以内のデータを存在するとみなすか。
                0だった場合は更新日時のチェックはしない。

        Returns:
            もし既に入っていたらtrue, そうでなければfalse
        """
        cursor = self.connection.cursor()
        if int(hour) == 0:
            sql = self.EXISTENCE_CHECK_SQL % ( self.TABLE_NAME, hatena_id )
        else:
            # hour引数を考慮
            import datetime
            update_date_begin = datetime.datetime.now() - datetime.timedelta(hours=int(hour))
            sql = self.EXISTENCE_CHECK_WITH_DATE_SQL % ( self.TABLE_NAME, hatena_id, update_date_begin.strftime( '%Y-%m-%d %H:%M:%S') )
        
        #print sql
        # SQL実行
        cursor.execute( sql )
        result = cursor.fetchall()

        # 1行でもデータが入ってればtrue
        for row in result:
            return True
        # そうでなければfalse
        return False

    def replace( self, hatena_id, subscription_count ):
        """ 購読者数のデータを現在時刻で上書き更新する。
        
        Args:
            hatena_id: データを更新するユーザのはてなID文字列
            subscription_count: ブログ購読者数の数値

        Returns:
            なし
        """
        import datetime
        sql = self.REPLACE_SQL % (self.TABLE_NAME, hatena_id, subscription_count, datetime.datetime.now().strftime( '%Y-%m-%d %H:%M:%S') )
        cursor = self.connection.cursor()
        
        #print sql
        # SQL実行
        cursor.execute( sql )

    def commit( self ):
        """ ローカルのSQLiteデータベースにcommitする"""
        self.connection.commit()

    def close( self ):
        """ ローカルのSQLiteデータベースへの接続を閉じる"""
        self.connection.close()


# 簡単のため、このクラスを単体で実行したときに
# 動作確認を行う
if __name__ == "__main__":
    
    subs_accessor = SubscripitonDataAccessor()
    subs_accessor.replace("iruca21", 15)
    subs_accessor.commit()
    print subs_accessor.data_exists("iruca21")
    print subs_accessor.data_exists("iruca21", 24)
    subs_accessor.close()


これでクローラを書く準備が整いました。

クローラ(巡回モジュール)の作成

さて、やっとメインのクローラを作ります。
仕組みは単純。こちらの記事で紹介しているモジュールが、

  • 特定ユーザのはてなブログの読者数
  • 特定ユーザのはてなブログの読者のリスト(一部)

を返してくれるので、この読者数をDBに保存しながら、読者のそれぞれをまたさらに調べていきます。

Aさんのブログの読者のBさんのブログの読者のそのまた読者の…

と調べていくということですね。
スタックオーバーフローを避けるために、最初にクロールを開始したユーザから数えて10人以上読者をまたいだユーザは調べないとしています。

また、はてなさんのサーバに迷惑をかけないように5秒に1回しかデータを取りに行かないようにクロール速度を調整しています。

subscription_crawler.py

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

import time
from subscription_data_accessor import SubscripitonDataAccessor
import subscription_util

class SubscriptionCrawler:
    """はてなブログの読者を再帰的に巡回して、
    読者数を集めて回るクローラー。
    サーバへのDoS攻撃にならないように、クロール速度に注意している。
    """

    # 1ユーザを巡回するたびにスリープする時間
    CRAWL_INTERVAL = 5
    # 何時間前のデータまで最新のデータとみなすか
    NEED_UPDATE_HOUR = 24
    # 最初のユーザから何層ブログ読者をネストしたところまで最大で調べるか
    MAX_NEST_DEPTH = 10

    # すでに巡回したユーザ数
    user_count = 0

    def __init__(self, initial_hatena_id):
        """コンストラクタ。
        Args:
            initial_hatena_id: 巡回を始める最初のユーザ
        """
        self.initial_hatena_id = initial_hatena_id
        self.data_accessor = SubscripitonDataAccessor()

    def crawl(self, hatena_id="", nest_count=0):
        """再帰的に巡回してブログ読者数を集める。
        Args:
            hatena_id: 巡回するユーザのはてなID文字列.
                指定されなかった場合はinitial_userを巡回する
            nest_count: initial_userから数えて、読者を何層ネストしたユーザを調べているかを表わす数値
        Returns:
            なし
        """
        if hatena_id == "":
            hatena_id = self.initial_hatena_id
        
        # 最新のデータが入っていれば改めて巡回はしない
        if self.data_accessor.data_exists( hatena_id, hour=self.NEED_UPDATE_HOUR ):
            return

        print "fetching %s 's subscription... (user_count=%d, nest_count=%d)" % ( str(hatena_id), self.user_count, nest_count )
        time.sleep(self.CRAWL_INTERVAL) # DoSにならないようにSleepを入れる
        
        try:
            # スクレイピングしてくる
            subscription = subscription_util.fetch_subscriptions( hatena_id )
            subscription_count = subscription[0]
            subscribers = subscription[1]

        except:
            # 読者数が取得できない人は読者数-1人としてデータ挿入
            print "couldn't get %s 's subscription" % str(hatena_id)
            subscription_count = -1
            subscribers = []
            
        # データ挿入
        self.data_accessor.replace( hatena_id, subscription_count )
        self.user_count += 1

        # 10人分データを保存するごとにcommitを行う
        if self.user_count % 10 == 0:
            print "commit!"
            self.data_accessor.commit()

        # そのユーザのブログ読者に対して再帰的に巡回を行う.
        # ただし、スタックオーバーフローを避けるために最大のネスト数を設けておく
        if nest_count < self.MAX_NEST_DEPTH :
            for subscriber_id in subscribers:
                self.crawl( subscriber_id, nest_count + 1 )
        
        return

# 簡単のため、このスクリプト自体を実行したときに
# 動作確認を行う。
if __name__ == "__main__":
    initial_user = "iruca21"
    crawler = SubscriptionCrawler("iruca21")

    # 巡回開始
    crawler.crawl()

さあ、これで全てのモジュールがそろいました。

実行してみた

さっそく実行してみましょう。

[root@hoge hatena]# python subscription_crawler.py
fetching iruca21 's subscription... (user_count=0, nest_count=0)
fetching hiro-loglog 's subscription... (user_count=1, nest_count=1)
fetching hourou_world 's subscription... (user_count=2, nest_count=2)
fetching tk4876 's subscription... (user_count=3, nest_count=3)
fetching nicenuts 's subscription... (user_count=4, nest_count=4)
fetching mochi36 's subscription... (user_count=5, nest_count=5)
fetching yasaiitame07 's subscription... (user_count=6, nest_count=6)
fetching seiyablog 's subscription... (user_count=7, nest_count=7)
fetching kirarin010914 's subscription... (user_count=8, nest_count=8)
couldn't get kirarin010914 's subscription
fetching shinshiraoka1411 's subscription... (user_count=9, nest_count=8)
commit!
fetching gbh06101 's subscription... (user_count=10, nest_count=9)
(以下略)

うんうん、ちゃんとユーザを巡回してるな。
データベースの中にちゃんとデータが入ってるかも見てみましょう。

[root@hoge hatena]# sqlite3 hateblo_subscription.db
SQLite version 3.7.17 2013-05-20 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> select * from subscription;
iruca21|16|2017-04-11 20:20:19
hiro-loglog|151|2017-04-11 20:20:24
hourou_world|69|2017-04-11 20:20:29
tk4876|80|2017-04-11 20:20:35
nicenuts|70|2017-04-11 20:20:40
mochi36|39|2017-04-11 20:20:46
yasaiitame07|7|2017-04-11 20:20:51
seiyablog|41|2017-04-11 20:20:56
kirarin010914|-1|2017-04-11 20:21:01
shinshiraoka1411|56|2017-04-11 20:21:07

よしよし、データが溜まっていってるな。
満足。

考察

このデータを貯めれば、はてなブログの読者関係が作るソーシャルネットワークを分析することができます。
じっとデータを眺めていると、色々面白いことが見えてきます。

「あれ、無名なユーザばかりが読者になってるブログがある…」

みたいな不気味なユーザも居ます。


つまり、この分析によって
「不正にはてなユーザを大量作成して、読者を増やしているブロガーを検知できる」
というような可能性もあります。



実際にそういう怪しいブログを洗い出したり、他にも色々と面白いことを分析するのはまた別記事で。
(はてなさん、僕のプログラム買いとってくれたりしないかな笑)

ではでは!