Python + HerokuでAPEXのマップローテーションをつぶやくTwitter botを作った話

# はじめに
Apex Legendsというゲームをご存じでしょうか？簡単に言うとバトルロワイヤル型のFPSゲームです．
バトルロワイヤルのマップは現在3種類のマップが60~120分ごとにローテーションするのですが，ゲームを始める前に今どのマップローテーションになっているのかを知りたい！と思い，色々調べていたらどうやらマップローテーションを返してくれるAPIがあるということで，このAPIを利用してTwitter botを作ろうと思い立ちました．
初投稿&経験が浅いので拙い部分も多々あると思いますが，ご了承ください．

# 使用するAPI
- [Apex Legends Status(ALS)](https://apexlegendsapi.com/#introduction)
- Twitter API

# 完成したもの
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【現在のマップローテーション】<br>🏙オリンパス(11:00~12:30)<br><br>【次のマップローテーション】<br>🏜キングスキャニオン(12:30~14:00)</p>&mdash; APEXマップローテーションbot (@ApexMapBot) <a href="https://twitter.com/ApexMapBot/status/1520954521408905216?ref_src=twsrc%5Etfw">May 2, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Heroku Scheduler(後述)を使って，10分ごとに稼働させています．(APEXプレイヤーの方は良かったらフォローしてください🙇‍♂️)

※稼働させ始めてすぐに**シャドウバン**(サーチバン)されました．似たようなツイートを繰り返しするアカウントや自動ツイートをするアカウントはシャドウバンされやすいようです(実際検索汚染になるので仕方ないかもしれませんが...)．Twitterの設定項目を眺めていたら設定>アカウント>アカウント情報>自動化という項目を発見(2022年2月に実装されたばかりのようです)．ここから自動アカウントであるということを申告するとシャドウバンを回避できるかもしれません(シャドウバンされてから申告したので現在様子見中です)．
(2022/5/8 追記：この設定をしてから4日ほどでシャドウバンが解除されました．これが直接の要因かは分かりませんが...)

# 準備
## Apex Legends StatusのAPIキーを取得
[Apex API Portal](https://portal.apexlegendsapi.com/)からAPIの用途などを記入してAPIキーを取得します．いったんローカルに控えておいてください．
## botにするTwitterアカウントを作成
すぐできると思います．
## Twitter Developersに登録&APIキーを取得
TwitterAPIを使用してbotを作るには，[Twitter Devlopers](https://developer.twitter.com/en)に登録する必要があります．登録には英語でAPIの用途などを申告しないといけませんが，適当にgoogle翻訳やdeeplを使って送れば大丈夫だと思います．ググったら申告には数日かかるという記事も目にしましたが，僕は送った瞬間APIを利用できるようになりました．
Keys and tokensのタブから**Consumer Key，Consumer Secret，Access Token，Access Token Secret**を取得して，ローカルに控えます．

## Herokuに登録
Herokuとは，Webサービスを公開することができるPaaS(Platform as a Service)です．めちゃくちゃ簡単に言うと，`python 〇〇.py`といったコマンドを私たちの代わりに実行してくれるサービスです．[ここ](https://jp.heroku.com/)から登録します．

## Gitをインストール
[ここ](https://www.curict.com/item/60/60bfe0e.html)を見てインストールしました．Heroku CLIを利用するのに必要です．
※この記事ではGitの基本的な部分については解説しません．

## Heroku CLIをインストール
[ここ](https://devcenter.heroku.com/ja/articles/heroku-cli)を見てインストール．ローカルのシェルからデプロイなどを行えるようになります．

## 必要なパッケージをインストール
ローカルで実装する方は，以下のコマンドで使用するパッケージをインストールしてください．
```sh
$ pip install python-dotenv
$ pip install requests
$ pip install tweepy
```

# 実装
ディレクトリ構成は以下のようです．
```
.
├── .env
├── .gitignore
├── app.py
├── Procfile
├── requirements.txt
├── runtime.txt
└── settings.py
```

まず，先ほど取得したAPIキーたちを環境変数として`.env`に保存します．
``` :.env
API_KEY = ***
API_SECRET_KEY = ***
ACCESS_TOKEN = ***
ACCESS_TOKEN_SECRET = ***
ALS_API_KEY = ***
```
上からTwitterAPIのConsumer Key，Consumer Secret，Access Token，Access Token Secret，Apex Legends StatusのAPIキーです．

APIキーは人に見せてはいけないものなので，`.gitignore`で`.env`をpushしないようにしておきます．
```:.gitignore
.env
```

`settings.py`で環境変数からAPIキーを取得します．
```python:settings.py
import os
from os.path import join, dirname
from dotenv import load_dotenv

dotenv_path = join(dirname(__file__), '.env')
load_dotenv(dotenv_path)

# Twitter API
API_KEY = os.environ.get("API_KEY")
API_SECRET_KEY = os.environ.get("API_SECRET_KEY")
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN")
ACCESS_TOKEN_SECRET = os.environ.get("ACCESS_TOKEN_SECRET")

# Apex Legends Status API
ALS_API_KEY = os.environ.get("ALS_API_KEY")

```

ALSからjsonデータを取得して，ツイートをする部分を実装していきます．まずはALSのAPIを叩いてみましょう．
ステータスコード200が返ってきたらリクエスト成功です．
```python:app.py
import json
import requests
import sys
import settings
from pprint import pprint

url_map = "https://api.mozambiquehe.re/maprotation?auth="
als_api_key = settings.ALS_API_KEY
res_map = requests.get(url_map + als_api_key)
json_map = json.loads(res_map.text)
if res_map.status_code == 200:
    print("(ALS)Request succeeded")
    pprint(json_map)
else:
    print("(ALS)Request failed with " + str(res_map.status_code))
    sys.exit()
```
実行結果
```
(ALS)Request succeeded
{'current': {'DurationInMinutes': 120,
             'DurationInSecs': 7200,
             'asset': 'https://apexlegendsstatus.com/assets/maps/Storm_Point.png',
             'code': 'storm_point_rotation',
             'end': 1651651200,
             'map': 'Storm Point',
             'readableDate_end': '2022-05-04 08:00:00',
             'readableDate_start': '2022-05-04 06:00:00',
             'remainingMins': 66,
             'remainingSecs': 3976,
             'remainingTimer': '01:06:16',
             'start': 1651644000},
'next': {'DurationInMinutes': 90,
         'DurationInSecs': 5400,
         'code': 'olympus_rotation',
         'end': 1651656600,
         'map': 'Olympus',
         'readableDate_end': '2022-05-04 09:30:00',
         'readableDate_start': '2022-05-04 08:00:00',
         'start': 1651651200}}
```
ここから現在とその次のマップの名前，開始時間，終了時間を取得し，時間はUTSで表されているので，一旦datetimeオブジェクトに直し，9時間足してJSTに書き換えます．
```python:app.py
from datetime import datetime, timedelta

current_map = json_map['current']['map']
current_map_start = json_map['current']['readableDate_start']
current_map_end = json_map['current']['readableDate_end']
next_map = json_map['next']['map']
next_map_start = json_map['next']['readableDate_start']
next_map_end = json_map['next']['readableDate_end']

# JSTに変換
current_map_start_jst = datetime.strptime(current_map_start, '%Y-%m-%d %H:%M:%S')
current_map_start_jst = current_map_start_jst + timedelta(hours=9)
current_map_end_jst = datetime.strptime(current_map_end, '%Y-%m-%d %H:%M:%S')
current_map_end_jst = current_map_end_jst + timedelta(hours=9)
next_map_start_jst = datetime.strptime(next_map_start, '%Y-%m-%d %H:%M:%S')
next_map_start_jst = next_map_start_jst + timedelta(hours=9)
next_map_end_jst = datetime.strptime(next_map_end, '%Y-%m-%d %H:%M:%S')
next_map_end_jst = next_map_end_jst + timedelta(hours=9)
```
ツイート内容の文字列を生成します．テキストだけだと味気ないので，Unicodeから絵文字を引っ張ってきました．
```python:app.py
map_list = {"Kings Canyon": {"name": "キングスキャニオン", "emoji": 127964}, "World's Edge": {"name": "ワールズエッジ", "emoji": 127755},
            "Olympus": {"name": "オリンパス", "emoji": 127961}, "Storm Point": {"name": "ストームポイント", "emoji": 127965}}

# ツイート内容
tweet_content = "【現在のマップローテーション】\n" + chr(map_list[current_map]['emoji']) + map_list[current_map]['name'] + "("+current_map_start_jst.strftime('%H:%M') + "~" + current_map_end_jst.strftime(
    '%H:%M') + ")\n\n" + "【次のマップローテーション】\n" + chr(map_list[next_map]['emoji']) + map_list[next_map]['name'] + "("+next_map_start_jst.strftime('%H:%M') + "~" + next_map_end_jst.strftime('%H:%M') + ")"
```
Heroku Scheduler(後述)は10分，1時間，1日ごとにしかジョブを実行できないので，10分ごとに稼働させ，今からツイートする内容がまだツイートされていないかを判定してから，まだツイートしていない場合のみツイートを実行するという方法を取ります(マップは90分で変更になることもあるので，1時間ごとだと対応できないのです)．
```python:app.py
# APIインスタンスを作成
auth = tweepy.OAuthHandler(settings.API_KEY, settings.API_SECRET_KEY)
auth.set_access_token(settings.ACCESS_TOKEN, settings.ACCESS_TOKEN_SECRET)
api = tweepy.API(auth)

# 自身の直近5ツイートを取得し，まだtweet_contentをツイートしていないことを確認
isnt_tweeted = True
screen_name = "ApexMapBot"
tweets = api.user_timeline(screen_name=screen_name, count=5)
for tweet in tweets:
    if tweet.text == tweet_content:
        isnt_tweeted = False
        print("This tweet was already sent")
        break

# ツイート送信(まだtweet_contentをツイートしていないとき)
if isnt_tweeted:
    api.update_status(tweet_content)
    print("Tweet has been sent")

```
前半部分ではtweepyでapiインスタンスを作成しています([Tweepyドキュメント](https://kurozumi.github.io/tweepy/index.html))．中盤部分では，tweepyで直近5件のツイートを取得し，取得したツイート群についてループを回して，直前に同じツイートをしていないかをチェックしています．後半部分では，同じ内容をツイートしていなかった場合のみツイートを行います．(2022/5/5追記:tweepyで過去ツイートを取得するように訂正しました．)

以上のコードをまとめた完成品は以下のようになります(プログラムがマップの変更と同時に実行されると新しいマップのデータが反映されないことがあるので，冒頭に10秒ほど待つコードを追加しました．)．
```python:app.py
import json
import requests
import sys
import time
import tweepy
import settings
from datetime import datetime, timedelta
from requests_oauthlib import OAuth1Session

# Heroku schedulerで10分ごとに実行
# 情報更新まで少し待つ
time.sleep(10)

map_list = {"Kings Canyon": {"name": "キングスキャニオン", "emoji": 127964}, "World's Edge": {"name": "ワールズエッジ", "emoji": 127755},
            "Olympus": {"name": "オリンパス", "emoji": 127961}, "Storm Point": {"name": "ストームポイント", "emoji": 127965}}

# マップローテーションの取得
url_map = "https://api.mozambiquehe.re/maprotation?auth="
als_api_key = settings.ALS_API_KEY
res_map = requests.get(url_map + als_api_key)
json_map = json.loads(res_map.text)
if res_map.status_code == 200:
    print("(ALS)Request succeeded")
else:
    print("(ALS)Request failed with " + str(res_map.status_code))
    sys.exit()

current_map = json_map['current']['map']
current_map_start = json_map['current']['readableDate_start']
current_map_end = json_map['current']['readableDate_end']
next_map = json_map['next']['map']
next_map_start = json_map['next']['readableDate_start']
next_map_end = json_map['next']['readableDate_end']

# JSTに変換
current_map_start_jst = datetime.strptime(
    current_map_start, '%Y-%m-%d %H:%M:%S')
current_map_start_jst = current_map_start_jst + timedelta(hours=9)
current_map_end_jst = datetime.strptime(
    current_map_end, '%Y-%m-%d %H:%M:%S')
current_map_end_jst = current_map_end_jst + timedelta(hours=9)
next_map_start_jst = datetime.strptime(
    next_map_start, '%Y-%m-%d %H:%M:%S')
next_map_start_jst = next_map_start_jst + timedelta(hours=9)
next_map_end_jst = datetime.strptime(
    next_map_end, '%Y-%m-%d %H:%M:%S')
next_map_end_jst = next_map_end_jst + timedelta(hours=9)

# ツイート内容
tweet_content = "【現在のマップローテーション】\n" + chr(map_list[current_map]['emoji']) + map_list[current_map]['name'] + "("+current_map_start_jst.strftime('%H:%M') + "~" + current_map_end_jst.strftime(
    '%H:%M') + ")\n\n" + "【次のマップローテーション】\n" + chr(map_list[next_map]['emoji']) + map_list[next_map]['name'] + "("+next_map_start_jst.strftime('%H:%M') + "~" + next_map_end_jst.strftime('%H:%M') + ")"

# APIインスタンスを作成
auth = tweepy.OAuthHandler(settings.API_KEY, settings.API_SECRET_KEY)
auth.set_access_token(settings.ACCESS_TOKEN, settings.ACCESS_TOKEN_SECRET)
api = tweepy.API(auth)

# 自身の直近5ツイートを取得し，まだtweet_contentをツイートしていないことを確認
isnt_tweeted = True
screen_name = "ApexMapBot"
tweets = api.user_timeline(screen_name=screen_name, count=5)
for tweet in tweets:
    if tweet.text == tweet_content:
        isnt_tweeted = False
        print("This tweet was already sent")
        break

# ツイート送信(まだtweet_contentをツイートしていないとき)
if isnt_tweeted:
    api.update_status(tweet_content)
    print("Tweet has been sent")

```

続いて，Herokuにデプロイするために必要なファイルを作成します．
```:requirements.txt
python-dotenv==0.20.0
requests==2.27.1
tweepy==4.8.0
```
`requirements.txt`には`pip`でインストールしたパッケージの名前と使用するバージョンを記載します．

```:runtime.txt
python-3.9.7
```
`runtime.txt`には使用するpythonのバージョンを記載します．
```:Procfile
web: python app.py
```
`Procfile`には，Herokuに実行してもらうコマンドを記載します．(この辺の仕組みはよくわかってないのですが，`Procfile`は無くても大丈夫かも？)

以上で必要なファイルは揃いました．

# デプロイ
シェルを開き，ファイルが格納されているディレクトリに移動し，以下の手順でコマンドを実行していきます．
#### herokuにログイン
```sh
$ heroku login
```
ブラウザが立ち上がるので，IDとパスワードを入力してHerokuにログインします．
#### アプリケーションを作成
```sh
$ heroku create {アプリ名}
```
実行結果
```
...
https://{アプリ名}.herokuapp.com/ | https://git.heroku.com/{アプリ名}.git
```
{アプリ名}のところは自身のアプリケーションの名前を入れます．私は"apex-map-bot"としました．ちなみにアプリ名には半角英数とハイフンしか使えないようです(アンダーバーなどは不可)．
#### Gitでpush
```sh
$ git init
$ git remote add origin https://git.heroku.com/{アプリ名}.git
$ git add .
$ git commit -m "initial commit"
$ git push origin master
```
#### Herokuに環境変数を設定
```sh
$ heroku plugins:install heroku-config
$ heroku config:push --app {アプリ名}
```
ちなみに
```sh
$ heroku config --app {アプリ名}
```
で設定された環境変数を確認できます．

以上でデプロイ完了です！

# Heroku Schedulerを設定
このままだとbotは定期的に動いてくれないので，Herokuのアドオン，Heroku Schedulerを使って定期的にツイートするようにしていきます．Heroku Schedulerを使うにはクレジットカードの登録が必要になので，Account settings>Billingからクレジットカードを登録しておきます(変なことをしなければ勝手に課金されたりすることはないはずなので安心して大丈夫だと思います)．クレジットカードを登録しておくと[dyno](https://jp.heroku.com/dynos)を1か月あたりに無料で使える時間も増えるので損はないです．

クレジットカードを登録したら，Resources>Add-onsからHeroku Schedulerを追加します．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/657708/cd449e7d-486c-f6a8-2667-cb9c6fa67496.png)
Heroku Schedulerを開いて，Add Jobから実行周期と実行コマンドを入力します．今回は以下のようにしました．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/657708/1af2efe1-5e4f-956c-7cea-1d3bab1118d7.png)
以上でTwitter botの完成です！

# 補足
### スケジューラーについて
Heroku Schedulerはきっちり10分ごとに動くわけではなく，毎回数分のばらつきがあるようです．きっちりと定期的に動かしたい場合や，もっと細かい間隔で動かしたい場合は，python側でAPSchedulerなどを用いて制御する手などもあるようです([参考](https://devcenter.heroku.com/ja/articles/clock-processes-python))．ただ，この方法だとHerokuのリソースをより多く使うので無料枠に収まるのかよく分からず，アドオンを使う方法を取りました．

### ALS APIについて
今回扱ったマップ情報以外にも，クラフトローテーションやプレイヤーの戦績などの情報も得られるようなので，気になる方は[ドキュメント](https://apexlegendsapi.com/#introduction)を読んでみてはいかがでしょうか．また，コミュニティのdiscordに入るとAPIアクセスの制限が緩和されたりするそうです．

# 今後
マップローテーション以外の情報もツイートするように改良する予定です．あとはシャドウバン対策として過去ツイートを自動で消すという処理も実装する予定(効果あるか分かりませんが)．

# 参考
https://qiita.com/ichi_zamurai/items/f41daac1c28bebdd73c0#git%E3%81%A8heroku-cli%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB

https://deux-hibi.hatenablog.com/entry/2019/07/28/223625