Qiita APIでの投稿記事取得のページネーション実装2種(Python)

# はじめに
Qiita APIを利用した投稿記事取得の際、取得結果が複数ページになる場合にページネーションを行う方法のメモです。

# Qiita APIでのページネーションに関する仕様
[Qiita APIのドキュメント](https://qiita.com/api/v2/docs#%E3%83%9A%E3%83%BC%E3%82%B8%E3%83%8D%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)には、ページネーションに関連して、以下の2つの記載があります。

- ページを指定できるAPIでは、Linkヘッダ を含んだレスポンスを返す
- ページを指定できるAPIでは、要素の合計数が Total-Count レスポンスヘッダに含まれる

## Curlコマンドで確認
上記を実際に、curlコマンドで確認してみます。

```console
$ curl -D - -s  -o /dev/null -H 'Authorization: Bearer [アクセストークン]' 'https://qiita.com/api/v2/items' | grep -e 'link' -e 'total-count'
link: <https://qiita.com/api/v2/items?page=1>; rel="first", <https://qiita.com/api/v2/items?page=2>; rel="next", <https://qiita.com/api/v2/items?page=34025>; rel="last"
total-count: 680484
```

- 参考：[cURLコマンドでレスポンスヘッダのみを取得する](https://qiita.com/yousan/items/fcc15e1046939c465ab7)

# 実装
APIのレスポンス仕様を参考にして、以下の2種類の方法でページネーションを実装します。

1. Total-Countヘッダを利用した実装
1. Linkヘッダを利用した実装

## (その1)Total-Countヘッダを利用した実装

```python
def pagenation_by_total_count(token = None, query = None):
    df_ret = pd.DataFrame()
    # クエリパラメータの準備
    params = prepare_parameter(query)
    # アクセストークンが指定された場合に付与
    req_headers = prepare_headers(token)

    for page_num in range(1, 101):
        params['page'] = str(page_num)
        url = "https://qiita.com/api/v2/items?" + urllib.parse.urlencode(params)

        req = urllib.request.Request(url, headers=req_headers)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            # DataFrameに記事情報を格納
            df = pd.json_normalize(body)
            df_ret = pd.concat([df_ret, df])
            print("Page: " + str(page_num))
            # Total-Countヘッダの値から最後のページまで取得したかを判断
            total_count = int(res.headers['Total-Count'])
            if page_num >= (total_count + 99) // 100:
                print('# of articles: ', total_count)
                break

    return df_ret
```

- `prepare_parameter(query)`と`prepare_headers(token)`は、それぞれ引数に与えられた検索クエリを含めたクエリパラメータの用意と、アクセストークンが与えられた場合のヘッダの用意をしています。中身は、[コード全体の節](#コード全体)を参照してください。
- PandasのDataFrameに取得した記事情報を格納します。
  - 参考：[pandasのjson_normalizeで辞書のリストをDataFrameに変換](https://note.nkmk.me/python-pandas-json-normalize/)
- Total-Countヘッダから記事数の合計を取得し、1ページ100件として計算したページ数と比較し、最後のページまで取得したかを判断します。
  - 参考：[urllib による HTTP 通信 (6) レスポンスヘッダを取得する](https://maku77.github.io/python/web/http-request-response-header.html)

## (その2)Linkヘッダを利用した実装

```python
def pagenation_by_link_header(token = None, query = None):
    df_ret = pd.DataFrame()
    # クエリパラメータの準備
    params = prepare_parameter(query)
    # アクセストークンが指定された場合に付与
    req_headers = prepare_headers(token)
    
    # 初期化
    page_num = 0
    next_url = "https://qiita.com/api/v2/items?" + urllib.parse.urlencode(params)
    
    while page_num < 100 and next_url is not None:
        page_num += 1

        req = urllib.request.Request(next_url, headers=req_headers)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            # DataFrameに記事情報を格納
            df = pd.json_normalize(body)
            df_ret = pd.concat([df_ret, df])
            print("Page: " + str(page_num))
            # Linkヘッダに次ページのURLがある場合は「next_url」にセットする
            next_url = None
            link = res.headers['link']
            if link is not None:
                m = re.match(r'.*<(https://qiita.com/api/v2/.*?)>;\s*rel="next"', link)
                if m is not None:
                    next_url = m.group(1)

    return df_ret
```

- その1と同様に、PandasのDataFrameに取得した記事情報を格納します。
- 正規表現を用いて、Linkヘッダに次ページのURLがある場合は取得します。
- `next_url`という変数に次に取得するページがある場合は、そのURLを格納します。

# コード全体
最後にコード全体を掲載します。

```python:pagenation.py
import urllib.request
import json
import re
import csv
import pandas as pd

ACCESS_TOKEN = "YOUR ACCESS TOKEN"

# クエリパラメータを組み立てる(1ページ100件固定)
def prepare_parameter(query):
    params = {
        'per_page': '100'
    }
    if (query is not None):
        params['query'] = query
    
    return params

# 認証トークンが指定された場合にヘッダに付与する
def prepare_headers(token):
    req_headers = {}
    if (token is not None):
        req_headers = {
            'Authorization': 'Bearer ' + token
        }
    
    return req_headers

def pagenation_by_total_count(token = None, query = None):
    df_ret = pd.DataFrame()
    # クエリパラメータの準備
    params = prepare_parameter(query)
    # アクセストークンが指定された場合に付与
    req_headers = prepare_headers(token)

    for page_num in range(1, 101):
        params['page'] = str(page_num)
        url = "https://qiita.com/api/v2/items?" + urllib.parse.urlencode(params)

        req = urllib.request.Request(url, headers=req_headers)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            # DataFrameに記事情報を格納
            df = pd.json_normalize(body)
            df_ret = pd.concat([df_ret, df])
            print("Page: " + str(page_num))
            # Total-Countヘッダの値から最後のページまで取得したかを判断
            total_count = int(res.headers['Total-Count'])
            if page_num >= (total_count + 99) // 100:
                print('# of articles: ', total_count)
                break

    return df_ret

def pagenation_by_link_header(token = None, query = None):
    df_ret = pd.DataFrame()
    # クエリパラメータの準備
    params = prepare_parameter(query)
    # アクセストークンが指定された場合に付与
    req_headers = prepare_headers(token)
    
    # 初期化
    page_num = 0
    next_url = "https://qiita.com/api/v2/items?" + urllib.parse.urlencode(params)
    
    while page_num < 100 and next_url is not None:
        page_num += 1

        req = urllib.request.Request(next_url, headers=req_headers)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            # DataFrameに記事情報を格納
            df = pd.json_normalize(body)
            df_ret = pd.concat([df_ret, df])
            print("Page: " + str(page_num))
            # Linkヘッダに次ページのURLがある場合は「next_url」にセットする
            next_url = None
            link = res.headers['link']
            if link is not None:
                m = re.match(r'.*<(https://qiita.com/api/v2/.*?)>;\s*rel="next"', link)
                if m is not None:
                    next_url = m.group(1)

    return df_ret

def main():
    # Total-Countヘッダを利用して、「QiitaAPI」タグの記事を取得
    df_total_count = pagenation_by_total_count(token = ACCESS_TOKEN, query = 'tag:QiitaAPI')
    df_total_count.to_csv('pagenation_by_total_count.csv', index=False, columns=['title', 'likes_count', 'created_at', 'url'])
    # Linkヘッダを利用して、「Python」タグの2020年に作成された記事を取得
    df_link = pagenation_by_link_header(token = ACCESS_TOKEN, query = 'tag:Python created:2020')
    df_link.to_csv('pagenation_by_link.csv', index=False, columns=['title', 'likes_count', 'created_at', 'url'])

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

- 2種類の実装で、それぞれ記事を取得し、記事の「タイトル」、「LGTMの数」、「作成日」、「URL」をCSVに書き出します。
  - Total-Countヘッダを利用して、「QiitaAPI」タグの記事を取得
  - Linkヘッダを利用して、「Python」タグの2020年に作成された記事を取得

# 参考
- [Qiita API v2](https://qiita.com/api/v2/docs)
- [Qiita APIをPythonから使ってみる](https://qiita.com/sotoiwa/items/e2254893f5b0b92bb06e)
- [Qiita API を使って、記事データを取得する方法](https://qiita.com/wakudar/items/8c594c8cc7bda9b93b4e)
- [Python の HTTP クライアントは urllib.request で十分](https://qiita.com/hoto17296/items/8fcf55cc6cd823a18217)