読者です 読者をやめる 読者になる 読者になる

Iruca Log

東京で暮らすWeb系エンジニアが日々感じたこと

SNSでフォローする!

他人のはてなブログの読者数を取得するプログラムをpythonで書いておいた

こんにちは、イルカです。
ふと「はてなブログの読者数と読者一覧をプログラムで取得したいな…」と思ったのでpythonで書いてみました。

仕組み

原理としては、まずブログのaboutページに読者と読者リスト(の一部)が載っていることがあるので、それを抜き出します。
iruca21.hateblo.jp
こういうページにある読者の部分ですね。

f:id:iruca21:20170411073711j:plain


ポイントは、aboutページをカスタマイズしている人はこのページに読者数、読者リストを載せていない場合があるということです。
しかしここで諦めない。ブログのトップページなどに、必ずその人の読者数だけは載っているはず。

f:id:iruca21:20170411073925j:plain

色々と調べてみると、読者数を取得できるAPIを発見しました。
これは使える。

curl -H "X-Requested-With: XMLHttpRequest" \
"http://blog.hatena.ne.jp/api/init?blog=http%3A%2F%2Firuca21.hateblo.jp"

{"blog_name":"Iruca Log",
"subscribe":false,
"private":{},
"blog":"http://iruca21.hateblo.jp",
"cookie_received":false,
"is_public":true,
"can_open_editor":false,
"subscribe_url":"http://blog.hatena.ne.jp/iruca21/iruca21.hateblo.jp/subscribe",
"quote":
  {"should_navigate_to_login":true,
  "star_addable":true,
  "stockable":true,
  "supported":true
  },
"editable":false,
"subscribes":"15",
"commentable":true,
"blog_url":"http://iruca21.hateblo.jp/"}

blog=http%3A%2F%2Firuca21.hateblo.jp の部分をそれぞれのブログのURL(をURLエンコードしたもの)に変えてください。
"subscribes":"15" が読者数みたいですね。

以上の原理を使って、読者数(と読者リストの一部)を取得するコードを書きました。

必要モジュール

pipでrequestsというpythonモジュールを入れておいてください。

yum -y install python-setuptools
easy_install pip
pip install requests

subscription_util.py

はてなブログの読者を取得するためのライブラリです。


subscription_util.py

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

import requests
import re
import json

"""
はてなIDから、その人のはてなブログの読者数、読者を抜き出すライブラリ
"""

def fetch_subscriptions( hatena_id ):
    """はてなIDを入力すると、ユーザのブログのaboutページやトップページのHTMLを取得・解析して
    ブログの読者数と読者の一部のはてなIDを返却する。
    HTMLの解析では読者数が得られなかった場合は、はてなのAPIを利用する。
    
    Args:
        hatena_id: ユーザのはてなID
    
    Returns:
        ユーザのはてなブログの読者数と、読者(の一部)のはてなIDの配列のタプル。
        例: (15, ["hoge", "fuga", "piyo"])
        はてなブログをやっていない人の場合は(-1, [])が返却される。
    Raises:
    """
    # ブログのURLを取得する
    try:
        blog_url = fetch_blog_url( hatena_id )
    except KeyError:
        return (-1, [])

    # ブログのURLから、ブログのaboutページをとってくる
    about_page_url = get_about_page_url( blog_url )

    # ブログのaboutページに記載してある内容から、
    # 読者数と読者リストのタプルを取得
    try:
        subscriptions = get_subscriptions_from_about_page( about_page_url )
    except KeyError:
        # aboutページに読者数を載せていないユーザでも、APIを使えば読者数が取れる
        subscription_count = get_subscription_count_from_api( blog_url )
        subscriptions = ( subscription_count, [] )

    return subscriptions

def fetch_blog_url( hatena_id ):
    """はてなIDを入力すると、そのユーザのブログのURLを取得する。
    http://blog.hatena.ne.jp/${hatena_id}/ にアクセスすると、
    301 Moved Permanently というステータスコードとともに
    LocationヘッダにブログのURLを入れて返してくれる機能を利用している

    Args:
        hatena_id: ユーザのはてなID文字列
    
    Returns:
        ユーザのメインブログのURL文字列
    
    Raises:
        KeyError: hatena_idが無効、ブログをやっていないユーザだったなどの理由でブログURLが取得できなかった
    """
    forward_page_url = "http://blog.hatena.ne.jp/"+ str(hatena_id) +"/" 
    response = requests.get( forward_page_url, allow_redirects=False)

    if response.status_code != 301:
        raise KeyError("cannot fetch any urls from "+ forward_page_url )

    # LocationヘッダにブログのURLが入っている
    url = response.headers["Location"]

    # はてなブログをやっていない人だった場合、ブログのURLではなくプロフィールのURLに飛ばされる
    if "profile.hatena.ne.jp" in url:
        raise KeyError("this user is not using the blog service. hatena_id="+ str(hatena_id) +", forwarded_page_url="+ str(forward_page_url) )

    return url

def get_about_page_url( blog_page_url ):
    """はてなブログのURLから、そのブログのaboutページのURLを取得する。
    """
    return blog_page_url +"/about"

def get_subscriptions_from_about_page( about_page_url ):
    """ はてなブログのaboutページのURLから、該当ブログの読者数と読者のhatena_idを分かる限り抜きだす。
    
    Args:
        about_page_url: はてなブログのaboutページのURL文字列
    Returns:
        ブログの読者数と、読者のはてなIDの配列からなるタプル。
        (読者数の数値, [読者のはてなID文字列の配列]) 
        例. (15, []
        読者リストは読者の一部しか表しておらず、読者全員ではないことに注意。

    Raises:
        IOError: はてなブログのaboutページがHTTPで取得できなかった
        KeyError: はてなブログのaboutページから、読者数や読者のIDをパースできなかった
    """

    response = requests.get( about_page_url )

    if response.status_code != 200:
        raise IOError("cannot get the html of the about page. url="+ str(about_page_url) )

    # splitで強引に読者数のspanタグ要素を抜き出す
    try:
        subscription_count = int( response.content.split('<span class="about-subscription-count">')[1].split("</span>")[0].strip().split(" 人")[0] )
    except:
        raise KeyError("cannot get the number of subscribers from the about page. url="+ str(about_page_url) )

    # splitで強引に読者一覧が含まれるHTMLの部分を抜き出す
    try:
        html_containing_subscribers = response.content.split("<div class=\"info\">")[1].split("</div>")[0]
    except:
        raise KeyError("cannot get the hatena ids of subscribers from the about page. url="+ str(about_page_url) )

    # 上記HTMLには
    # <a href="http://blog.hatena.ne.jp/rico_note/" class="subscriber" rel="nofollow"><img src="https://cdn1.www.st-hatena.com/users/ri/rico_note/profile.gif" width="16" height="16" alt="rico_note" title="rico_note" class="profile-icon"></a>
    # のようなHTMLが含まれるはずなので、その中から "rico_note" などのはてなIDを正規表現で抜きだす。
    
    pattern = "http:\/\/blog.hatena.ne.jp\/([a-zA-Z][0-9a-zA-Z_\-]{2,31})\/"
    r = re.compile(pattern)
    matched_objs = r.findall( html_containing_subscribers )

    subscribers_list = []
    # 見つかった読者のはてなID文字列をリストに詰め込んで返却
    for matched_obj in matched_objs:
        subscribers_list.append( matched_obj )

    return ( subscription_count, subscribers_list )

def get_subscription_count_from_api( blog_url ):
    """ 与えられたページのurlをはてなのAPIに与えることで読者数を得る。
        curl -v -H "X-Requested-With: XMLHttpRequest" "http://blog.hatena.ne.jp/api/init?name=&blog=http%3A%2F%2Fgwgw.hatenablog.com%2Fabout"

	Args:
		url: はてなブログ内のページ(ブログのトップページなど)
        Raises:
            KeyError: 与えられたページのURLから、読者数を取得できなかった
    """
    target_url = "http://blog.hatena.ne.jp/api/init?name=&blog="+ blog_url
    response = requests.get(target_url, headers={"X-Requested-With": "XMLHttpRequest"} )

    # 読者数をjson responseから抜き出す
    try:
        subscription_count = int( json.loads( response.content )["subscribes"] )
    except:
        raise KeyError("cannot get the number of subscribers from the page. url="+ str( target_url ) )

    return subscription_count

実行してみる

上記のsubscription_util.pyと同じディレクトリから、実行してみます。

実行するためのスクリプト

[root hatena]# python
Python 2.7.12 (default, Sep  1 2016, 22:14:00)
[GCC 4.8.3 20140911 (Red Hat 4.8.3-9)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import subscription_util
>>> print subscription_util.fetch_subscriptions("p_shirokuma")
(2047, ['neojin', 'spencer07', 'uza_momo', 'hdtyamada', 'nomatterxxx', 'ak1aims', 'suchi', 'wataru45', 'cat-whisker', 'beta-opinion', 'tyunnomidesu', 'kuripton', 'boku0', 'esu08052', 'haretokidoki86596355', 'botan0915', 'tofudot', 'ao-re', 'fummy', 'animisum', 'Znvn3j', 'akyska', 'ozabun', 'maisonnaoko', 'authenticlife', 'family-labo-fukuyama', 'taketack', 'high_grade_works', 'kojimat', 'mitaniya77', 'miunenearu', 'aqua935', 'hemispherwhistle', 'saachan0000', 'l_grgr_l', 'oshaberiitboy', 'katohaya2125', 'rankattt', 'nanapi2016', 't-konishi4976', 'azumami', 'glovetoss', 'realize10', 'yamakokun', 'fm315', 'talbotbuy', 'yoshirn75k', 'min117', 'morinonak', 'shizuokershugo', 'zunkororin', 'mogumoguhina', 'shin038', 'vilar5275', 'afugoro', 'mikachanko4281196', 'non-ishi566', 'motosaaaan', 'AobadaiAkira', 'KONOYUBITOMARE', 'krkctisi', 'doll_satomiii', 'sun_lee_rui_key', 'mugendai53', 'katsu-shin', 'yuma_sun', 'lifeofdij', 'IOQO', 'sakuranorihiko', 'inazuma2073', 'namellow', 'karatte', 'Pompomy', 'temiage', 'hiranoglyph', 'shinth1', 'nepiasan', 'siosiotaro', 'schunk', 'goodbey2012', 'mysweetr', 'hiroyuxxx', 'thresholdjp', 'velminton', 'for-happy-life', 'hanaekiryuin', 'naniwasetuyakudou', 'tu-ku-si', 'chihuahua-works', 'minamanami', 'dialmmm', 'umakosan', 'showr7', 'rerittu', 'mashimoc', 'poti1974', 'pfassistant', 'OgasawaraMakoto', 'keira-p', 'mkyk1616'])


うん、動いた動いた。
p_shirokumaさんスゲー、読者2047人。(2017年4月9日現在)