Qiita Advent Calendarのストック数ランキングページを作ってみた


本記事は、[グロービス Advent calendar 2017](https://qiita.com/advent-calendar/2017/globis)の8日めの記事になります。(ちなみに現在既に12月12日です）

## 前置き
そういえばQiitaの記事のストック数って表示されなくなりましたよね。

[Advent Calendarのランキング](https://qiita.com/advent-calendar/2017/ranking/subscriptions)を眺めていてふと思いました。

####いいね数、サブスクリプション数によるランキングはあるけど、ストック数だと順位変わったりするよね？たぶん
個人的には、ちゃんと読みたい記事にはいまだにストック機能を使うので、自分の記事がどれだけStockされたかを知りたいし、見れることで書いた人のモチベーションにも繋がるかと思いました。


### APIはある
https://qiita.com/api/v2/docs#ユーザ
記事をストックしている一覧が取得できるので、総数をカウントします。

## 【本題】ストック数ランキングページ
さくっと作ってみました。今のところデイリー更新です。

:mag: [ストック数順だとこうなる！ Qiita Advent Calendar Stockers Ranking](https://hihats.pro/advent_calendar_ranking)

いわゆる裏ランキングですが、こうやってみると、さすがに上位はあまり変わりませんね。5、6位くらいから変動があるようです。Academicや関数型言語が若干上がってくるのは、一般的に難易度が高い記事のほうがストックされやすいからでしょう。
（GoやLaravel,Jupyterのジャンプアップ具合がすごい流石）


## 開発ポイント概要解説
特に目新しい技術は使ってません

- データ取得側はPython(Beatiful SoupとQiita API、 boto3)
- Web側はRuby (Sinatra)
- ローカルDockerで開発したので、インフラはそのままHerokuのContainer registry  
  :arrow_right: ローカルで動いているコンテナを、 `heroku container:push` するだけ！
- alpine-rubyイメージ + sinatraで100MB未満という驚きの軽さを実現しました（）

## ソースコード
### 取得側Python
Lambdaで動かしたら楽っぽいかなーと思い、その想定で関数組んでます
関数の引数が参照渡しというのも要は使いどころ

#### カレンダーのURLリストスクレイピング
こちらは一日一回
- `config.***`系のイミュータブル変数は別途 `config.py`に書いてimportしている（一応秘匿）
- 環境変数Docker起動時に渡している
- 最後、各カレンダーの記事リストをJSONでS3に置く

```advent_calendar_crawler.py
#!/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に置く


```qiita_stockers.py
#!/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` としてやると動いた。

```Dockerfile
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でうまく動かず結局 :arrow_down: のようにeachで回す形になったので、原因わかるかたいれば教えてほしい


```not_worked.rb
    # これはundefined method `+' for #<Hash:0x005566e50fc098>エラーになる
    calendar['entries'].inject {|stock_count, entry| stock_count + entry['stock_count']}
```


```app.rb
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くらいアクセスいきますごめんなさい :bow: 


## 参考
- Alpineで時刻あわせ困った（ntpとかないし）
[aws sdk でアップロードしようとしたら、RequestTimeTooSkewedというエラーが出た場合の対処方法](https://qiita.com/colorrabbit/items/f695af8b03e0c25c55b7)
ひとまずコンテナ内でコマンド叩いてしのぐ

	```
	$ hwclock -s
	```
[Docker for MacでAWSにアクセスするコンテナがエラーになるときの対処方法](https://dev.classmethod.jp/tool/docker/docker-for-mac-error/)

- S3アップロード時にJSONをいちいちFileStreamに書き出しせずに済む
[Python(boto3)でS3にデータをファイル保存せず直接アップロードする方法](https://dev.classmethod.jp/cloud/aws/upload-json-directry-to-s3-with-python-boto3/)

- aws-sdk for rubyが色々変わってて半端にググって得れる古い情報では動かない
[AWS SDK for Ruby V2 への移行（主にS3）](https://qiita.com/ya_ma23/items/0d310f37fe23378c2260)

- Heroku Container RegistryへDockerアプリケーションのデプロイ
  [Container Registry & Runtime (Docker Deploys)](https://devcenter.heroku.com/articles/container-registry-and-runtime)