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

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

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

考察

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

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

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


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



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

ではでは!

SQLiteを使ってローカルにDB, テーブルを作ってデータを出し入れする

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

webから収集してきた情報なんかをとりあえず手軽にローカルのDBにでも保存すっか…じゃあsqlite使おう、と思うことが多いのですが、
いつもやり方を忘れるので備忘録的にここに書いておきます。

テーブル定義

まずテーブル定義の仕様を決めます。
僕の好きなものを保存するテーブルにします。

column_name data_type index memo
user_id text primary key ユーザを識別するID文字列
name text primary key 食べ物などの名前文字列
extent integer どれくらい好きかの程度を表す整数値


SQLiteで使えるデータ型は以下を参照。
SQLiteのデータ型 | SQLite


DBとテーブル作成

DBとテーブルをpythonのsqlite3モジュールを使って作ってみます。
SQLiteのDBのファイルはpythonスクリプトを実行したディレクトリにできるようです。

create_db_and_table.py

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


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

db_name = "iruca_sample.db"
table_name = "favorite"
sql = "CREATE TABLE %s(user_id text, name text, extent integer, PRIMARY KEY(user_id, name))" % ( table_name )

print sql

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

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

#SQL実行
cursor.execute( sql )

#コミット
connection.commit()

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

実行してテーブルを作っておきましょう。

[root@hoge hatena]# python create_db_and_table.py
CREATE TABLE favorite(user_id text, name text, extent integer, PRIMARY KEY(user_id, name))

これでDBとテーブルができました。


データの挿入と確認

本当にテーブルができたのか、テーブルからデータを出し入れして確認してみましょう。

check_table.py

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


import sqlite3

db_name = "iruca_sample.db"
table_name = "favorite"
insert_sql = """
INSERT INTO %s
VALUES
("iruca21", "oyakodon", 100),
("iruca21", "kafun", -1000000),
("kujira16", "serori", -10)
""" % table_name

select_sql = """
SELECT user_id, name, extent FROM %s
WHERE user_id="iruca21"
""" % table_name

print insert_sql
print select_sql

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

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

#データ挿入SQL実行
cursor.execute( insert_sql )

#コミット
connection.commit()

#データ取得SQL実行
cursor.execute( select_sql )

#結果を表示
result = cursor.fetchall()
print "------"
for row in result:
        print row[0], row[1], row[2]

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


では、上記のスクリプトを実行して確認しましょう。

[root@hoge hatena]# python check_table.py

INSERT INTO favorite
VALUES
("iruca21", "oyakodon", 100),
("iruca21", "kafun", -1000000),
("kujira16", "serori", -10)


SELECT user_id, name, extent FROM favorite
WHERE user_id="iruca21"

------
iruca21 kafun -1000000
iruca21 oyakodon 100


うん、満足満足。

他人のはてなブログの読者数を取得するプログラムを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日現在)