本記事は、グロービス Advent calendar 2017の8日めの記事になります。(ちなみに現在既に12月12日です)
前置き
そういえばQiitaの記事のストック数って表示されなくなりましたよね。
Advent Calendarのランキングを眺めていてふと思いました。
####いいね数、サブスクリプション数によるランキングはあるけど、ストック数だと順位変わったりするよね?たぶん
個人的には、ちゃんと読みたい記事にはいまだにストック機能を使うので、自分の記事がどれだけStockされたかを知りたいし、見れることで書いた人のモチベーションにも繋がるかと思いました。
APIはある
https://qiita.com/api/v2/docs#ユーザ
記事をストックしている一覧が取得できるので、総数をカウントします。
【本題】ストック数ランキングページ
さくっと作ってみました。今のところデイリー更新です。
ストック数順だとこうなる! Qiita Advent Calendar Stockers Ranking
いわゆる裏ランキングですが、こうやってみると、さすがに上位はあまり変わりませんね。5、6位くらいから変動があるようです。Academicや関数型言語が若干上がってくるのは、一般的に難易度が高い記事のほうがストックされやすいからでしょう。
(GoやLaravel,Jupyterのジャンプアップ具合がすごい流石)
開発ポイント概要解説
特に目新しい技術は使ってません
- データ取得側はPython(Beatiful SoupとQiita API、 boto3)
- Web側はRuby (Sinatra)
- ローカルDockerで開発したので、インフラはそのままHerokuのContainer registry
ローカルで動いているコンテナを、heroku container:push
するだけ! - alpine-rubyイメージ + sinatraで100MB未満という驚きの軽さを実現しました()
ソースコード
取得側Python
Lambdaで動かしたら楽っぽいかなーと思い、その想定で関数組んでます
関数の引数が参照渡しというのも要は使いどころ
カレンダーのURLリストスクレイピング
こちらは一日一回
-
config.***
系のイミュータブル変数は別途config.py
に書いてimportしている(一応秘匿) - 環境変数Docker起動時に渡している
- 最後、各カレンダーの記事リストをJSONでS3に置く
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import boto3
from boto3.session import Session
import requests
from bs4 import BeautifulSoup
from requests.exceptions import ConnectionError, ReadTimeout, SSLError
import json, datetime, time, pytz, re, sys,traceback, pprint
from collections import defaultdict
import config
def handler(event, context):
pp = pprint.PrettyPrinter(indent=4)
# Set environmental variables (needs to be set in AWS Lambda)
target_url = event['URL']
s3_bucket_name = os.environ['S3_BUCKET_NAME']
session = Session(aws_access_key_id=os.environ['ACCESS_KEY'],
aws_secret_access_key=os.environ['ACCESS_SECRET_KEY'],
region_name=os.environ['REGION'])
s3 = session.resource('s3')
# Fetch URL list from advent calendar ranking page
res = requests.get(target_url, timeout=5, headers=config.headers)
soup = BeautifulSoup(res.text, 'lxml')
calendars = []
map_item(soup, config.top_item_class, calendars)
map_item(soup, config.item_class, calendars)
calendars = [map_entries(cal) for cal in calendars]
obj = s3.Object(s3_bucket_name, 'advent_calendars.json')
r = obj.put(Body = json.dumps(calendars), ContentType='application/json; charset=utf-8')
print('json upload finished')
def map_calendars(soup, class_name, calendars):
"""
mapping html element into calendar hash by beautifulsoup
"""
for div in soup("div", class_=class_name):
link = div.find("a", class_=config.name_class)
category = div.find('div', class_=config.category_class).find('div')
calendar = {'name': link.text, 'url': link.get('href'), 'category': category.text}
calendars.append(calendar)
def map_entries(calendar):
"""
mapping html element into entry hash by beautifulsoup
"""
res = requests.get(config.qiita_domain + calendar['url'], timeout=5, headers=config.headers)
soup = BeautifulSoup(res.text, 'lxml')
entries = []
for div in soup("div", class_=config.entry_class):
link = div.find("a")
entry = {'title': link.text, 'url': link.get('href')}
entries.append(entry)
calendar['entries'] = entries
return calendar
Qiita APIで記事別ストック数取得
こちらは毎時実行
- 上でとってきた記事リストにAPIで取得したストック数要素を追加
- 再度JSONにしてS3に置く
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import boto3
from boto3.session import Session
import requests
from requests.exceptions import ConnectionError, ReadTimeout, SSLError
import json, datetime, time, pytz, re, sys,traceback, pprint
import config
def handler(event, context):
pp = pprint.PrettyPrinter(indent=4)
# sets environmental variables (needs to be set in AWS Lambda)
config.headers["Authorization"] = "Bearer " + event["TOKEN"]
s3_bucket_name = event['S3_BUCKET_NAME']
session = Session(aws_access_key_id=event['ACCESS_KEY'],
aws_secret_access_key=event['ACCESS_SECRET_KEY'],
region_name=event['REGION'])
s3 = session.resource('s3')
bucket = s3.Bucket(s3_bucket_name)
tmpfile = '/tmp/calendars.json'
bucket.download_file('advent_calendars.json', tmpfile)
print('download finished')
f = open(tmpfile)
calendars = json.load(f)
api_access_count = 0
api_rate_limit = 1000
calendar_stockers = []
for calendar in calendars:
entries = []
for entry in calendar['entries']:
match = re.match(r"https://qiita.com/.+/items/(.+)", entry['url'])
if match:
page = 0
count = 0
ret = []
while(page == 0 or len(ret)):
page += 1
url = config.qiita_domain + '/api/v2/items/' + match.group(1) + '/stockers?page=' + str(page) + '&per_page=100'
res = requests.get(url, timeout=5, headers=config.headers)
api_access_count += 1
ret = json.loads(res.text)
if isinstance(ret, dict):
pp.pprint(url)
pp.pprint(ret)
if "message" in ret and ret["message"] == "Rate limit exceeded":
ret = []
break
count += len(ret)
if len(ret) < 100:
break
if api_access_count >= api_rate_limit:
break
entry['stock_count'] = count
entries.append(entry)
if api_access_count >= api_rate_limit:
break
calendar['entries'] = entries
if api_access_count >= api_rate_limit:
break
calendar_stockers.append(calendar)
if len(calendar_stockers) >= config.calendar_count:
break
pp.pprint(calendar_stockers)
print(api_access_count)
s3 = session.resource('s3')
obj = s3.Object(s3_bucket_name, 'advent_calendar_stockers2.json')
r = obj.put(Body = json.dumps(calendar_stockers), ContentType='application/json; charset=utf-8')
print('upload finished')
がっつり手続き型で書いてるのはご容赦ください。
注意点
pythonのjson.loads()はjsonの値に合わせて、いい感じにリストとdictに振り分けてくれて便利ですが、実際解析するまでどっちかわからないので、つど判定が必要
インフラ Docker
ローカルで開発しているときと同じDockerfileをそのままHerokuで使おうとすると、SinatraのBoot時に
R10 (Boot timeout)
エラーになる。
Heroku container registryではHOSTに0.0.0.0
を固定で渡すのが駄目(動的生成しているよう)なので、無理やり環境変数にしてCMD bundle exec ruby main.rb -o $HOSTNAME -p $PORT
としてやると動いた。
FROM ruby:2.4.0-alpine
ENV LANG ja_JP.UTF-8
RUN apk update && apk upgrade && apk --update add tzdata && \
cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
rm -rf /var/cache/apk/*
ENV TZ Asia/Tokyo
ENV APP_HOME /opt/app
RUN gem install bundler && mkdir -p $APP_HOME
WORKDIR $APP_HOME
COPY Gemfile* $APP_HOME/
ENV BUNDLE_DISABLE_SHARED_GEMS 1、
RUN bundle install && bundle clean
ADD . $APP_HOME
EXPOSE 80
CMD bundle exec ruby main.rb -o $HOSTNAME -p $PORT
Web表示側 Ruby(Sinatra)
- JSONデータだけで賄っているので、DBはナシ
- 配列内ハッシュの要素で合計を取る箇所、injectでうまく動かず結局 のようにeachで回す形になったので、原因わかるかたいれば教えてほしい
# これはundefined method `+' for #<Hash:0x005566e50fc098>エラーになる
calendar['entries'].inject {|stock_count, entry| stock_count + entry['stock_count']}
require 'sinatra'
require 'aws-sdk-s3'
require 'json'
set :port, ENV['PORT']
before do
@title = 'Advent calendar stockers ranking'
end
get '/' do
Aws.config.update(
credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY'], ENV['AWS_SECRET_ACCESS_KEY'])
)
s3 = Aws::S3::Resource.new(region: ENV['AWS_REGION'])
bucket = s3.bucket(ENV['S3_BUCKET_NAME'])
obj = bucket.object("advent_calendar_stockers.json").get.body.read
calendars = JSON.parse(obj).map do |calendar|
stock_count = 0
calendar['entries'].each {|entry| stock_count += entry['stock_count']}
calendar['stock_count'] = stock_count
calendar
end
@calendars = calendars.sort_by{|calendar| calendar['stock_count']}.reverse
erb :index
end
View側は割愛
SkeltonというUltra lightweight CSSを使用
問題点
- APIが1000req/h までなので、日にちが進むに連れ、ざっくり25記事 × 上位40カレンダーまでとれるか怪しくなってくる
- いいね数はそうでもないけどストック数がすんごいAdvent Calendarを取り逃がす可能性
- Qiitaへスクレイピング1日に200くらいアクセスいきますごめんなさい
参考
-
Alpineで時刻あわせ困った(ntpとかないし)
aws sdk でアップロードしようとしたら、RequestTimeTooSkewedというエラーが出た場合の対処方法
ひとまずコンテナ内でコマンド叩いてしのぐ$ hwclock -s
Docker for MacでAWSにアクセスするコンテナがエラーになるときの対処方法
-
S3アップロード時にJSONをいちいちFileStreamに書き出しせずに済む
Python(boto3)でS3にデータをファイル保存せず直接アップロードする方法 -
aws-sdk for rubyが色々変わってて半端にググって得れる古い情報では動かない
AWS SDK for Ruby V2 への移行(主にS3) -
Heroku Container RegistryへDockerアプリケーションのデプロイ
Container Registry & Runtime (Docker Deploys)