Qiita APIをPythonから使ってみる

Pythonの初心者がPythonのお勉強のため、Qiita APIを叩いてみたメモ。
目標は自分の記事のビュー数といいね数とストック数をまとめて取得すること。Qiitaではこれらをまとめて表示できるページがないので、このようなツールがあれば便利。

- [Qiita API v2の仕様](https://qiita.com/api/v2/docs)

## 参考リンク

同じようなことをやっている人はいるので参考にさせていただいた。

- [Qiita APIを利用して記事のViewsとストック数がまとめて取得できるAPIを作ってみた](https://qiita.com/kai_kou/items/3f10ce93f4aa3b57b2d0)
- [Qiita API v2 を使って自身の全投稿をエクスポートする Python スクリプトを書いた](https://qiita.com/sta/items/5074df5fcb81d890897b)
- [GASでQiita APIを使ってView・いいね・ストック数の一覧を取得する](https://qiita.com/tksnino/items/7e6a587ff6e1817490ad)

## 完成イメージ

できあがったものはこのようなもの。

```shell-session
$ QIITA_TOKEN=hogehoge
$ qiitacheck --help
usage: qiitacheck.py [-h] [-o {text,csv,json}] [-f FILENAME]
                     [--sort-by {views,likes,stocks}] [--reverse]

Qiitaのビュー数、いいね数、ストック数を取得します。

optional arguments:
  -h, --help            show this help message and exit
  -o {text,csv,json}, --output {text,csv,json}
                        出力形式を指定します
  -f FILENAME, --filename FILENAME
                        出力先のファイル名を指定します
  --sort-by {views,likes,stocks}
                        結果を指定のキーでソートします
  --reverse             ソートを降順にします

環境変数QIITA_TOKENにアクセストークンをセットしてから実行してください。
$ qiitacheck --sort-by likes --reverse
+---------------------------------------------------------------------------------------+-------+-------+--------+----------------------+
| Title                                                                                 | Views | Likes | Stocks | Id                   |
+---------------------------------------------------------------------------------------+-------+-------+--------+----------------------+
| ArduinoをBluemixに接続するチュートリアルのTips                                        |  1822 |    18 |     18 | 5f9f475a8051ed9f0f52 |
| Windows 7でlocalhost宛のパケットをキャプチャする                                      | 16136 |    16 |     10 | 4e9b0c855c620a53e6fa |
（省略）
| MacからターミナルでAIXに接続する                                                      |   959 |     0 |      4 | 050371e525f5aa6f0592 |
| JavaScriptで日付をJDBCタイムスタンプエスケープ形式に変換する                          |   977 |     0 |      1 | dc9af6532759ed633ad0 |
+---------------------------------------------------------------------------------------+-------+-------+--------+----------------------+
$
```

アクセストークンは環境変数で渡す。`-o csv`でcsv出力、`-o json`でjson形式での出力が可能。ファイルとして出力する場合は`-f <filename>`を指定する。なお、各記事について2回のAPIを実行しているため、何回も実行していると[Qiita APIの利用制限](https://qiita.com/api/v2/docs#%E5%88%A9%E7%94%A8%E5%88%B6%E9%99%90)（認証済みだと1000回/h、認証していないと60回/h）にひっかかってエラーになる。

## 準備

以下リンク先からQiita APIのアクセストークンを発行する。

https://qiita.com/settings/applications

## curlでAPIを確認

`curl`でAPIを叩いて確認してみる。

### いいね数

いいね数は以下の認証中ユーザーの記事一覧APIで取得できる。このAPIでは記事のリストが返されるので、各記事の`likes_count`フィールドをとる。

- [GET /api/v2/authenticated_user/items](https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems)

```shell:コマンド
QIITA_TOKEN=hogehoge
curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items | jq -r '.[] | .title + ": " + ( .likes_count | tostring )'
```

```shell-session:実行例
$ curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items | jq -r '.[] | .title + ": " + ( .likes_count | tostring )'
hostPathとlocalのPersistentVolumeの違い: 0
LibertyコンテナのJVM引数にPod名を渡す: 0
VagrantでkubeadmでKubernetesを起動する: 0
Certified Kubernetes Application Developer (CKAD) 受験ログ: 2
（省略）
```

このAPIでは1ページに取得できる記事数が限られていることに注意。パラメータで`?page=1&per_page=20`のように指定することができるが、1ページの件数はデフォルトで20件で最大でも100件。HTTPヘッダに次のページのリンクが含まれているので、ページネーションして全てのページを取得する必要がある。

curlでヘッダだけをみるには`curl -D - -s -o /dev/null`を使うと便利。

```shell:コマンド
curl -D - -s -o /dev/null -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items
```

`link`ヘッダに次のページへのリンクが含まれている。最後のページには`rel="next"`というリンクがない。

```shell-session:実行例
$ curl -D - -s -o /dev/null -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items
HTTP/2 200
date: Fri, 21 Dec 2018 08:41:09 GMT
content-type: application/json; charset=utf-8
（省略）
link: <https://qiita.com/api/v2/authenticated_user/items?page=1>; rel="first", <https://qiita.com/api/v2/authenticated_user/items?page=2>; rel="next", <https://qiita.com/api/v2/authenticated_user/items?page=4>; rel="last"
total-count: 62
（省略）
```

Pythonでのページネーションの処理は以下のリンク先を参考にした（というかほぼそのままコピペした）。

- [How to get data from all pages in Github API with Python?](https://stackoverflow.com/questions/33878019/how-to-get-data-from-all-pages-in-github-api-with-python)

### ビュー数

ビュー数も認証中ユーザーの記事一覧APIの各記事の`page_views_count`フィールドから取得できそうに思えるが、なぜか`null`が返ってきてしまっていてとれない。

```shell:コマンド
curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items | jq -r '.[] | .title + ": " + ( .page_views_count | tostring )'
```

```shell-session:実行例
$ curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/authenticated_user/items | jq -r '.[] | .title + ": " + ( .page_views_count | tostring )'
hostPathとlocalのPersistentVolumeの違い: null
LibertyコンテナのJVM引数にPod名を渡す: null
VagrantでkubeadmでKubernetesを起動する: null
Certified Kubernetes Application Developer (CKAD) 受験ログ: null
（省略）
```

なので、個別記事取得APIから取得する必要がある。

- [GET /api/v2/items/:item_id](https://qiita.com/api/v2/docs#get-apiv2itemsitem_id)

個別記事取得APIの`page_views_count`フィールドにはちゃんと値が含まれている。

```shell:コマンド
ITEM_ID=fugafuga
curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/items/${ITEM_ID} | jq -r '.page_views_count'
```

```shell-session:実行例
$ curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/items/${ITEM_ID} | jq -r '.page_views_count'
384
$
```

### ストック数

ストック数は認証中ユーザーの記事一覧APIや、個別記事取得APIではとれず、記事をストックしているユーザー一覧APIから取得する必要がある。

- [GET /api/v2/items/:item_id/stockers](https://qiita.com/api/v2/docs#get-apiv2itemsitem_idstockers)

このAPIではユーザーのリストが返されるが、`jq`でリストの数を数える場合は`length`を使う。

```shell:コマンド
curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/items/${ITEM_ID}/stockers | jq length
```

```shell-session:実行例
$ curl -s -H "Authorization: Bearer ${QIITA_TOKEN}" https://qiita.com/api/v2/items/${ITEM_ID}/stockers | jq length
4
$
```

## Python

以下のように作成。→[GitHub](https://github.com/sotoiwa/qiita_checker)

```python:qiitacheck.py
#!/usr/bin/env python3

import argparse
import csv
import json
import logging
import os
import sys

import prettytable
import requests


formatter = '%(asctime)s %(name)-12s %(levelname)-8s %(message)s'
logging.basicConfig(level=logging.WARNING, format=formatter)
logger = logging.getLogger(__name__)


def get_next_url(response):
    """次のページがある場合は'rel="next"'としてurlが含まれるので、urlを抽出して返す。
    ない場合はNoneを返す。

    link: <https://qiita.com/api/v2/authenticated_user/items?page=1>;
    rel="first", <https://qiita.com/api/v2/authenticated_user/items?page=2>;
    rel="next", <https://qiita.com/api/v2/authenticated_user/items?page=4>;
    rel="last"

    :param response:
    :return: 次のurl
    """
    link = response.headers['link']
    if link is None:
        return None

    links = link.split(',')

    for link in links:

        if 'rel="next"' in link:
            return link[link.find('<') + 1:link.find('>')]
    return None


def get_items(token):
    """ページネーションして全ての記事を取得し、
    ストック数とビュー数は一覧に含まれないので、それらの情報も追加して返す。

    :param token:
    :return: 記事のリスト
    """

    url = 'https://qiita.com/api/v2/authenticated_user/items'
    headers = {'Authorization': 'Bearer {}'.format(token)}

    items = []
    while True:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        items.extend(json.loads(response.text))
        logger.info('GET {}'.format(url))
        # 次のurlがあるかを確認する
        url = get_next_url(response)
        if url is None:
            break

    # 各記事についてビュー数とストック数の情報を取得して追加する
    # page_views_countは一覧APIにもフィールドはあるがnullが返ってくる
    for item in items:

        # ビュー数
        url = 'https://qiita.com/api/v2/items/{}'.format(item['id'])
        logger.info('GET {}'.format(url))
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        item['page_views_count'] = json.loads(response.text)['page_views_count']

        # ストック数
        url = 'https://qiita.com/api/v2/items/{}/stockers'.format(item['id'])
        logger.info('GET {}'.format(url))
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        users = json.loads(response.text)
        for user in users:
            logger.info({
                'id': user['id'],
                'name': user['name']
                })
        item['stocks_count'] = len(users)

    return items


def sort_items(items, sort_by, reverse):
    """リストをソートする

    :param items:
    :param sort_by:
    :param reverse:
    :return:
    """

    if sort_by == 'views':
        if reverse:
            items.sort(key=lambda x: -x['page_views_count'])
        else:
            items.sort(key=lambda x: x['page_views_count'])
    elif sort_by == 'likes':
        if reverse:
            items.sort(key=lambda x: -x['likes_count'])
        else:
            items.sort(key=lambda x: x['likes_count'])
    elif sort_by == 'stocks':
        if reverse:
            items.sort(key=lambda x: -x['stocks_count'])
        else:
            items.sort(key=lambda x: x['stocks_count'])


def output_text(items, filepath):
    """テキストで整形して標準出力に出力する。
    ファイル名が指定された場合はファイルに出力する。

    :param items:
    :param filepath:
    :return:
    """

    table = prettytable.PrettyTable()
    table.field_names = ['Title', 'Views', 'Likes', 'Stocks', 'Id']
    table.align['Title'] = 'l'
    table.align['Views'] = 'r'
    table.align['Likes'] = 'r'
    table.align['Stocks'] = 'r'
    table.align['Id'] = 'l'
    for item in items:
        table.add_row([item['title'],
                       item['page_views_count'],
                       item['likes_count'],
                       item['stocks_count'],
                       item['id']])

    if filepath:
        with open(filepath, 'w') as text_file:
            text_file.write(table.get_string())
    else:
        print(table)


def output_csv(items, filepath):
    """csvに整形して標準出力に出力する。
    ファイル名が指定された場合はファイルに出力する。

    :param items:
    :param filepath:
    :return:
    """

    def write_rows(writer, items):
        for item in items:
            writer.writerow({
                'Title': item['title'],
                'Views': item['page_views_count'],
                'Likes': item['likes_count'],
                'Stocks': item['stocks_count'],
                'Id': item['id']
            })

    fieldnames = ['Title', 'Views', 'Likes', 'Stocks', 'Id']
    if filepath:
        with open(filepath, 'w') as csv_file:
            writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
            writer.writeheader()
            write_rows(writer, items)
    else:
        writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames)
        writer.writeheader()
        write_rows(writer, items)


def output_json(items, filepath):
    """jsonに整形して標準出力に出力する。
    ファイル名が指定された場合はファイルに出力する。

    :param items:
    :param filepath:
    :return:
    """

    my_list = []
    for item in items:
        my_list.append({
            'Title': item['title'],
            'Views': item['page_views_count'],
            'Likes': item['likes_count'],
            'Stocks': item['stocks_count'],
            'Id': item['id']
        })

    if filepath:
        with open(filepath, 'w') as json_file:
            json.dump(my_list, json_file, ensure_ascii=False, indent=4)
    else:
        print(json.dumps(my_list, ensure_ascii=False, indent=4))


def main():

    # コマンド引数の処理
    parser = argparse.ArgumentParser(description='Qiitaのビュー数、いいね数、ストック数を取得します。',
                                     epilog='環境変数QIITA_TOKENにアクセストークンをセットしてから実行してください。')
    parser.add_argument('-o', '--output',
                        default='text',
                        action='store',
                        type=str,
                        choices=['text', 'csv', 'json'],
                        help='出力形式を指定します')
    parser.add_argument('-f', '--filename',
                        action='store',
                        type=str,
                        help='出力先のファイル名を指定します')
    parser.add_argument('--sort-by',
                        action='store',
                        type=str,
                        choices=['views', 'likes', 'stocks'],
                        help='結果を指定のキーでソートします')
    parser.add_argument('--reverse',
                        action='store_true',
                        help='ソートを降順にします')
    args = parser.parse_args()

    # APIからデータを取得
    token = os.environ['QIITA_TOKEN']
    items = get_items(token)
    # items = [
    #     {'title': 'aaa',
    #      'page_views_count': 11,
    #      'likes_count': 22,
    #      'stocks_count': 33,
    #      'id': 'hogehoge'},
    #     {'title': 'bbb',
    #      'page_views_count': 44,
    #      'likes_count': 55,
    #      'stocks_count': 66,
    #      'id': 'fugafuga'}
    # ]

    # リストをソートする
    sort_items(items, args.sort_by, args.reverse)

    # ファイル出力先のパスを決める
    if args.filename:
        # dockerで実行している場合はファイルの出力先を/tmpにする
        try:
            os.environ['IS_DOCKER']
            # フルパスが与えられた場合はファイル名だけにする
            filename = os.path.basename(args.filename)
            filepath = os.path.join('/tmp', filename)
        except KeyError:
            filepath = args.filename
    else:
        filepath = None

    # 結果を出力する
    if args.output == 'csv':
        output_csv(items, filepath)
    elif args.output == 'json':
        output_json(items, filepath)
    else:
        output_text(items, filepath)


if __name__ == '__main__':
    main()
```

- テキスト出力は`prettytable`モジュールで整形
- コマンド引数は`argparse`モジュールで処理
- 記事数が20件以上の場合もページネーションして全ての記事を取得
- 結果はソート可能

実行には`requests`と`prettytable`モジュールが必要。

```shell
pip3 install requests prettytable
export QIITA_TOKEN=hogehoge
./qiitacheck.py --help
```

## Dockerで動かす

pipモジュールのインストールなどを気にせず、どこでも使いやすいようにDockerイメージにしてCLIっぽく使ってみる。

### イメージ作成

[DockerHubのpythonイメージの説明](https://hub.docker.com/_/python/)を参考にして`requirements.txt`と`Dockerfile`を作成する。

```:requirements.txt
requests
prettytable
```

```dockerfile:Dockerfile
FROM python:3-alpine

WORKDIR /usr/src/app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY qiitacheck.py ./

ENV IS_DOCKER TRUE
ENTRYPOINT [ "python", "./qiitacheck.py" ]
```

- CLIっぽく使いたいので、pythonの実行コマンドは`CMD`ではなく`ENTORYPOINT`を指定している
- Dockerで実行する場合はファイル出力先のパスをマウント先のディレクトリに変更する必要があるので、Dockerで実行されているかを判定するための`IS_DOCKER`環境変数を設定している

イメージをビルドする。

```shell
docker build -t sotoiwa540/qiita-checker:1.0 .
docker push sotoiwa540/qiita-checker:1.0
```

### 実行

エイリアスを作成する。

```shell
alias qiitacheck='docker run --rm -it -e QIITA_TOKEN=${QIITA_TOKEN} -v ${PWD}:/tmp sotoiwa540/qiita-checker:1.0'
```

- トークンの環境変数をコンテナの環境変数にも設定している
- ファイル出力する場合のために、カレントディレクトリをマウントしている

トークンを環境変数としてエクスポートする。

```
export QIITA_TOKEN=hogehoge
```

実行する。csv出力の場合の例。

```shell-session:実行例
$ qiitacheck -o csv -f test.csv --sort-by likes --reverse
$ cat test.csv
Title,Views,Likes,Stocks,Id
ArduinoをBluemixに接続するチュートリアルのTips,1822,18,18,5f9f475a8051ed9f0f52
Windows 7でlocalhost宛のパケットをキャプチャする,16136,16,10,4e9b0c855c620a53e6fa
SwiftでHTTPリクエストを再帰的に複数並列で行い、全てのリクエストが完了してから次の処理,2445,14,17,8dc15a224d2cf6c6be91
Certified Kubernetes Administrator (CKA) 受験ログ,386,10,4,3509ca3d18d1bed00dee
（省略）
```