Twitter API の Search API で"next_results"パラメータが使えない！？その原因と対処法

Twitter API の [Search API](https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets) を用いてツイート収集をしていた際にハマりました！
意外なところに落とし穴が・・・

# Twitter の Search API について
今回は、ツイートを取得する Search API を利用しました。
Search API の仕様について簡単にまとめます。

Search APIには3種類のAPIがあります。
- Standard Search API （無料）
- Premium Search API （有料）
- Enterprise Search API （有料）

今回は、無料で使えるStandard Search APIを利用します。

### Standard Search API の特徴
- 無料で利用可能
- リクエスト回数に制限あり
    - user auth(OAuth1)で認証する場合 180リクエスト/15分
    - app  auth(OAuth2)で認証する場合 450リクエスト/15分
- **過去7日間**のツイートまで取得可能
    - 有料のAPIだと、さらに過去のツイートを取得可能

### リクエストパラメータ
| パラメータ      | 説明 | 備考 |
|:-----------------|:------------------|:------------------|
| q             　　| 検索クエリ（必須） | Twitterでのツイート検索と同様の検索が可能、文字列のみも可       
| geocode          |  ツイートした場所 | 緯度、経度、半径で指定 |
| lang             |  ツイートの言語指定 |  |
| locale           |  クエリの言語指定 | 現在は日本語`ja`のみ有効|
| result_type      |  取得ツイートの種類指定 | `recent`なら最新のツイート、`popular`なら人気のツイート、`mixed`なら両方 |
| count            | 取得件数指定 | デフォルトは15件、最大で100件 |
| until            | ツイート時期の指定 | YYYY-MM-DD以前のツイートを取得（7日より前は取得不可）|
| since_id         | ID値指定 | 指定したID値より大きいID値のツイートを取得 |
| max_id           | ID値指定 | 指定したID値より小さいID値のツイートを取得 |
|include_entities  | [entities](http://westplain.sakuraweb.com/translate/twitter/API-Overview/Entities-in-Objects.cgi)の有無 | `false`を指定するとentities情報を含まずにツイートを取得

### レスポンスパラメータ

| パラメータ | 説明 | 備考 |
|:-----------------|:-----------------|:-----------------|
|statuses        |  ツイートのリスト | ツイートオブジェクトがリストで格納されています |
|search_metadata | 検索のメタデータ | 検索のメタデータが格納されています
### レスポンスの例

```json:
{
  "statuses": [
    （ツイートオブジェクトのため割愛）,
    ...
  ],
  "search_metadata": {
    "max_id": 250126199840518145,
    "since_id": 24012619984051000,
    "refresh_url": "?since_id=250126199840518145&q=%23freebandnames&result_type=mixed&include_entities=1",
    "next_results": "?max_id=249279667666817023&q=%23freebandnames&count=4&include_entities=1&result_type=mixed",
    "count": 4,
    "completed_in": 0.035,
    "since_id_str": "24012619984051000",
    "query": "%23freebandnames",
    "max_id_str": "250126199840518145"
  }
}
```

# 問題の発生

## 経緯
`#Qiita`のハッシュタグが付いたツイートを大量に集めようとしていました。

しかし、Standard Search API では、1回のリクエストで最大100件のツイートしか取得できません。
そこで、リクエストパラメータ`next_results`を利用することで再帰的にAPIを呼び出すことで1000件のツイート取得を試みました。
`next_results`にはクエリが格納されており、このクエリを実行することで101件目以降のツイートを取得できるようになります。
つまり、

リクエスト→レスポンス→`next_results`をパース→次のリクエストパラメータへ→リクエスト→・・・

という流れを1000件取得するまで行います。

（参考：[Twitter API search/tweets で 100件以上のツイートを取得する（PHP）](https://blog.apar.jp/php/3007/)）

しかし、**リクエストは3回分しか実行されず**しかも200件しかツイートが取れません！
（3回目のレスポンスはツイート取得数が0となっている）
明らかにツイートが200件を超えているのにも関わらずです・・・。

## プログラム

コードはPythonで書きました。
また、各種APIキーは環境変数に登録しています。

```Python:get_tweet.py
from requests_oauthlib import OAuth1Session
import os
import json

#APIキーの設置
CONSUMER_KEY = XXXXXXXXXXXXXXXXXXXXXX #API key 
CONSUMER_SECRET = XXXXXXXXXXXXXXXXXXXXXX #API secret
ACCESS_TOKEN = XXXXXXXXXXXXXXXXXXXXXX
ACCESS_SECRET = XXXXXXXXXXXXXXXXXXXXXX

# ツイート取得用のURL
SEARCH_URL = 'https://api.twitter.com/1.1/search/tweets.json'


def search(params):
    twitter = OAuth1Session(CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, ACCESS_SECRET)
    req = twitter.get(SEARCH_URL, params = params)
    tweets = json.loads(req.text)
    return tweets

# PHPにおけるparse_str関数の代わり
def parseToParam(parse_str, parse=None):
    if parse is None:
        parse = '&'
    return_params = {}
    parsed_str = parse_str.split(parse)
    for param_string in parsed_str:
        param, value = param_string.split('=', 1)
        return_params[param] = value
    return return_params

def main():
    search_word = '#Qiita'
    tweet_data = []

    # Tweet Search
    params = {
                'q'  : search_word,
            'count'  : 100,
             }
    tweet_count = 0

    while tweet_count < 1000:
        tweets = search(params)
        for tweet in tweets['statuses']:
            tweet_data.append(tweet)
        # tweets['search_metadata']['next_results'] をパースしてparamへ
        if 'next_results' in tweets['search_metadata'].keys():
            next_results = tweets['search_metadata']['next_results']
            next_results = next_results.lstrip('?') # 先頭の?を削除
            params = parseToParam(next_results)
            tweet_count += len(tweets['statuses'])
        else:
            break

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

## 原因の調査
レスポンスパラメータ`next_results`を次回のリクエストパラメータに利用していることから、
- リクエストパラメータ
- レスポンスパラメータ`next_results`
の2点を確認します。

### リクエストパラメータとレスポンスパラメータの確認
**1回目**
リクエストパラメータ

```
{
  'q'    : '#Qiita',
  'count': 100
}
```
レスポンスパラメータ`next_results`

```
?max_id=1250763045871079425&q=%23Qiita&count=100&include_entities=1
```

**2回目**
リクエストパラメータ

```
{
　'max_id': '1250763045871079425', 
  'q'    : '%23Qiita',
  'count': 100,
  'include_entities': '1'
}
```
レスポンスパラメータ`next_results`

```
?max_id=1250673475351572480&q=%2523Qiita&count=100&include_entities=1
```

**3回目**
リクエストパラメータ

```
{
　'max_id': '1250673475351572480', 
  'q'    : '%2523Qiita',
  'count': 100,
  'include_entities': '1'
}
```
レスポンスパラメータ`next_results`

```
None
```

## 調査結果
どうやら、本来は同じクエリが引き継がれていくところを
`#Qiita` → `%23Qiita` → `%2523Qiita`
とクエリが変遷しているようです。

`#Qiita` と `%23Qiita` はURLエンコーディングによって互換が効いていますが、`%2523Qiita`に至っては完全に別のクエリとなっています。
（[こちら](https://tech-unlimited.com/urlencode.html)でエンコード・デコードを試すと確認できます。）

すなわち、
`%23Qiita` → `%2523Qiita`の過程で**％がデコードされている**ことが問題の原因なようです。

# 解決策
レスポンスパラメータ`next_results`をパースした後、リクエストパラメータ中の**%25を%に置換**します。

## プログラムの修正
while文の中に、パラメータの置換処理を追加

```Python:get_tweet.py
    while tweet_count < 1000:
        tweets = search(params)
        for tweet in tweets['statuses']:
            tweet_data.append(tweet)
        # tweets['search_metadata']['next_results'] をパースしてparamへ
        if 'next_results' in tweets['search_metadata'].keys():
            next_results = tweets['search_metadata']['next_results']
            next_results = next_results.lstrip('?') # 先頭の?を削除
            params = parseToParam(next_results)
            # %25の置換処理を追加
            params['q'] = params['q'].replace('%25', '%') 
            tweet_count += len(tweets['statuses'])
        else:
            break
```

# まとめ
レスポンスパラメータ`next_results`中に含まれるクエリ`q`にて、URLエンコーディング後の％が余分にエンコードされていました。
それにより、クエリの引き継ぎがうまくいかずにツイートの取得で問題が発生していました。

解決策として、`next_results`で余分にエンコードされてしまった％を文字列置換で復元しました。

##　参考
Twitter API公式ドキュメント
https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets
Twitter 開発者ドキュメント日本語訳
http://westplain.sakuraweb.com/translate/twitter/Documentation/REST-APIs/Public-API/GET-search-tweets.cgi
Twitter API search/tweets で 100件以上のツイートを取得する（PHP）
https://blog.apar.jp/php/3007/
URLエンコード・デコード
https://tech-unlimited.com/urlencode.html