仕事でQiita:Teamを使っていて、年末だし今年の投稿データを集計しようと思い立ちました。Qiitaには公式なAPIあるし、それをたたいておけばデータをいい感じに集められるのかと思ったのですが、調べてみると正直かなり不十分・不完全でした。
APIへの不満
- 投稿(items)取得のデータで取れないものが多い
- DBの設計がすけてみえるようなAPIの設計ですが、取得する方としては以下のようなものは一気に取りたいですね
- 閲覧数
- コメント数
- いいね数
- DBの設計がすけてみえるようなAPIの設計ですが、取得する方としては以下のようなものは一気に取りたいですね
- 1000リクエスト/時間しかたたけない
- 上記のように一気に取れないデータがあって、ループの中で個別にリクエストしないといけないのにこのリクエスト制限はきついと思いました
- これ普通のqiitaのほうと一緒のAPIだからそういう制限だと思うんですが、1人あたり700円近く支払っている有料サービスとしては少なくなくないですかね
- クオータの制限は有料のほうは上げてほしいですね
- いいねのAPIについてはサービス側の仕様変更についていっておらずちゃんとしたデータを返さない
- 単純ないいねだけじゃなくて、 とかいろんなパターンのリアクションができるようになったのですが、それによってデータがちゃんと返ってこなくなった
API + スクレイピング
仕方がないので、APIでとれる部分は取りつつ、スクレイピングも組み合わせてデータを取ります。
コードは実際にやったものからいろいろ省いてますので、動くかわからないです。参考程度です。
APIへの問い合わせ
まずは、teamのほうじゃなくて、qiita.comの設定画面のほうにいってアクセストークンを取得します。
サンプルコード
いろいろ省いてますが、pythonでのサンプルコードです。ひとまず、記事一覧はこれでとれます。page=1 が固定で入ってますが、ここをループの中で適宜増やしていけばいいです。
import csv
import requests
DOMAIN = "xxxx.qiita.com"
ACCESS_TOKEN = "xxxxx"
url = 'https://{}/api/v2/items?page=1&per_page=100'.format(DOMAIN)
result = requests.get(url,headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)})
csv_data = []
for d in result.json():
comments_url = 'https://{}/api/v2/items/{}/comments'.format(DOMAIN, d['id'])
comments_result = requests.get(comments_url,
headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)})
csv_data.append(
[d['id'], d['user']['id'], d['user']['profile_image_url'], d['coediting'], d['title'], d['url'],
len(d['body']), len(comments_result.json()), d['created_at']])
with open('result_temp.csv', 'w') as f:
writer = csv.writer(f, lineterminator='\n')
writer.writerows(csv_data)
スクレイピング
いいね数、コメント数の取得
コメント数はAPIでとれるのはとれるのですが、いいね数は上述の通りAPI経由で正しい値が取れません。なので、記事一覧ページから取得することにします。
スクレイピングにはSeleniumとPhantomJSを使いました。ちなみに、通常は2段階認証をかけているので、ログインの突破が難しいです。2段階認証かけてるところへのスクレイピングってやり方あるのでしょうか?仕方ないので、一時的に専用ユーザー作ってそれだけID/Passで通過できるようにしました。
import csv
from selenium import webdriver
DOMAIN = "xxxx.qiita.com"
USER_ID = "xxx"
USER_PASS = "XXX"
csv_data = []
driver = webdriver.PhantomJS()
login_url = 'https://{}/'.format(DOMAIN)
driver.get(login_url)
user_id = driver.find_element_by_id("identity")
user_id.clear()
user_id.send_keys(USER_ID)
password = driver.find_element_by_id("password")
password.clear()
password.send_keys(USER_PASS)
driver.find_element_by_css_selector('input.loginSessionsForm_submit').click()
list_url = 'https://{}/?page={}'.format(DOMAIN, 1)
driver.get(list_url)
for el in driver.find_elements_by_css_selector('div.teamItems_element_body'):
url = el.find_element_by_css_selector('h1 a').get_attribute("href")
comments = int(el.find_elements_by_css_selector('div.teamItems_element_count')[0].text)
likes = int(el.find_elements_by_css_selector('div.teamItems_element_count')[1].text)
csv_data.append([url, comments, likes])
driver.quit()
with open('result_like.csv', 'w') as f:
writer = csv.writer(f, lineterminator='\n')
writer.writerows(csv_data)
閲覧数の取得
記事の閲覧数は個別記事のページにしか情報がありません。先に保存しておいたCSVファイルからURLのリストを取り出して、ひたすら記事を開いて取得していきます。
import csv
from selenium import webdriver
DOMAIN = "xxxx.qiita.com"
USER_ID = "xxx"
USER_PASS = "XXX"
csv_data = []
driver = webdriver.PhantomJS()
login_url = 'https://{}/'.format(DOMAIN)
driver.get(login_url)
user_id = driver.find_element_by_id("identity")
user_id.clear()
user_id.send_keys(USER_ID)
password = driver.find_element_by_id("password")
password.clear()
password.send_keys(USER_PASS)
driver.find_element_by_css_selector('input.loginSessionsForm_submit').click()
# いいね数のデータをCSVから取得しておく
like = {}
with open('result_like.csv', 'r') as f:
reader = csv.reader(f)
for row in reader:
like[row[0]] = row[2]
with open('result_temp.csv', 'r') as f:
reader = csv.reader(f)
for row in reader:
url = row[5]
driver.get(url)
for el in driver.find_elements_by_css_selector('ul.dropdown-menu li'):
if el.get_attribute('class') == 'teamArticle_header_dropdown-view':
pv = int(el.get_attribute('innerText').replace('views', '').strip())
csv_data.append([row[0], row[1], row[2], row[3], row[4], url, row[6], row[7], like[url], pv, row[8]])
with open('result_pv.csv', 'w') as f:
writer = csv.writer(f, lineterminator='\n')
writer.writerows(csv_data)
これで、最終的にいいね数、閲覧数が含まれたCSVデータができました。
集計をかける
集計には、pandas+jupyterを使いました。pandas初めて使ったのですが、CSVの集計する上ですごい便利ですね。今までは、エクセルかもしくは何かSQLが使えるものに入れて集計やってましたが、次回以降は断然これでいいと思いました。
ちなみにガチでやる人たちは、ローカルもしくはどこかのサーバーにjupyterのサーバー立ててというのが一般的だと思いますが、ゆるふわにやる場合は、Macのスタンドアロンアプリとして動くバージョンのPineappleもおすすめです。ちょっと中に入っているライブラリ群が古いのが気になるので、PR出してアップデートしたいところ。
手元でpythonコードの動きをさっと確認したいときとか、APIの問い合わせを生データで確認したいときとかにもよく使ってます。
CSVの読み込み
import pandas as pd
names = ['id', 'user.id', 'user.image', 'coediting', 'title', 'url', 'body.length', 'comments','likes','views', 'created_at']
table = pd.read_csv('/path/to/result_pv.csv', header=None, names=names)
並び替え
並び替えはエクセルとかでも簡単ですが、こんな感じで、降順ソートができます。
ranking = table.sort(['views'], ascending=False)
ranking
グループ集計
ユーザーごとのレコード件数での集計と並び替え
count_by_user = table.groupby(['user.id','user.image'])['id'].count().reset_index()
count_by_user_sorted = count_by_user.sort(['id'], ascending=False)
count_by_user_sorted
ユーザーごとの閲覧数、いいね数、コメント数、本文文字数の集計
sum_by_user = table.groupby(['user.id'])['body.length','comments','likes','views'].sum().reset_index()
sum_by_user_sorted = sum_by_user.sort(['views'], ascending=False)
sum_by_user_sorted
レコード件数での集計と各合計の集計をマージ。簡単ですね。
merged = pd.merge(count_by_user_sorted, sum_by_user_sorted, how='inner', on='user.id')
merged
markdownとして出力
ここはあんまりいいやり方見つからなかったので、力業でmarkdownのテーブルの行を出力して、別途用意しておいたmarkdownのヘッダ部と手作業で合体させました。pandasの0.17以降だと.to_htmlというのが用意されているのでそれが使えそう。
for i,r in enumerate(ranking.iterrows()):
print("|{0}|<a href='{4}'><img src='{2}' width=48 /></a>|[{3}]({4})|{5}|{6}|{7}|".format(i+1, r[1][1], r[1][2], r[1][4], r[1][5],r[1][7],r[1][8], r[1][9]))