Flask+gunicornを使って作成したTwitter連携アプリをHerokuにデプロイした話

初投稿です。
本記事は**Flask+gunicorn**を使ってTwitterに自動投稿を行うPythonアプリをHerokuにデプロイした際の備忘録的な内容になっています。
ほぼ知識ゼロから始めたこともあり色々とハマったので、同じ境遇の方の助けになればと思い、本記事を書こうと思った次第です。

本稿は下記構成となっています。

+ [前提条件](#前提条件)
+ [Twitter API keyの取得](#twitter-api-keyの取得)
+ [各種パッケージのインストール](#各種パッケージのインストール)
+ [ファイル構成](#ファイル構成)
+ [アプリケーションのデプロイ](#アプリケーションのデプロイ)
+ [おまけその１：画像付きでツイートする](#おまけその１画像付きでツイートする)
+ [おまけその２：リプライツイートに返信する](#おまけその２リプライツイートに返信する)

#前提条件
以下がインストール済みである前提で話を進めます。

+ Python3.7系
+ git

#Twitter API keyの取得
下記ページを参考に`APIkey & secret`, `Access token & secret`を取得してください。
[TwitterAPIを申請して一発で承認されるまでの手順まとめ（例文あり）＋APIキー、アクセストークン取得方法](https://dev.classmethod.jp/articles/twitter-api-approved-way/)
自分の場合ですが、利用目的はbotの仕様を詳細に書いたところ承認されました。

#各種パッケージのインストール
+ requests          : Rest API操作を簡単に行うことのできるライブラリ
+ requests-oauthlib : PythonでOAuth認証を簡単に行うためのライブラリ
+ Flask             : PythonのWebフレームワーク
+ gunicorn          : WSGIアプリ(今回の場合Flask)を動かすサーバー

下記コマンドでインストールします。

```python:command
pip install requests
pip install requests-oauthlib
pip install Flask
pip install gunicorn
```

#ファイル構成
作業ディレクトリ以下のファイル構成は下記とします。

```python:tree
test                        # 作業ディレクトリ
├── Procfile                # Heroku上で起動するコマンドを記述するファイル
├── requirements.txt        # 依存ライブラリを記述するファイル
├── runtime.txt             # Heroku上でどのバージョンのPythonを使用するのかを記述するファイル
├── config.py               # 認証情報
├── test_app.py             # アプリ本体
├── common_func.py          # アプリ本体で使用する関数を外出し
└── json                    # JSONファイルを置くディレクトリ
    └── tweet_contents.json # ツイートする内容を記述したファイル
```

---

```json:tweet_contents.json
{
  "contents":
  {
    "img_url" : "画像URL",
    "tweet_text" : "ツイート送信のテスト"
  }
}
```

上記ファイルの`ツイート送信のテスト`にツイートの本文を記載します。
`画像URL`には投稿したい画像のURLを指定してください。（おまけその１で使用します）
ちなみに、今回はこちらの画像を用意しました。<br>
<img width="300" alt="test.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/765001/ec8d94de-960a-26b7-32b4-c675455e20ec.jpeg">

---

```python:config.py
import os
from requests_oauthlib import OAuth1Session

CK = os.environ["CONSUMER_KEY"]          # 環境変数の「CONSUMER_KEY」を設定
CS = os.environ["CONSUMER_SECRET"]       # 環境変数の「CONSUMER_SECRET」を設定
AT = os.environ["ACCESS_TOKEN"]          # 環境変数の「ACCESS_TOKEN」を設定
ATS = os.environ["ACCESS_TOKEN_SECRET"]  # 環境変数の「ACCESS_TOKEN_SECRET」を設定
twitter = OAuth1Session(CK, CS, AT, ATS) # 認証処理

#エンドポイント
update_url = "https://api.twitter.com/1.1/statuses/update.json"       #ツイート送信エンドポイント
upload_media_url = "https://upload.twitter.com/1.1/media/upload.json" #メディアアップロードエンドポイント
```

認証情報とエンドポイントをグローバル変数で定義しています。
認証処理に必要な各種APIkey,アクセストークンは、`os.environ["環境変数名"]`で環境変数から取得することができます。
こうすることで、トークンの再生成等で値が変更になった場合にソースコードを修正しないで済みます。

---

```python:test_app.py
import os, json, common_func
from flask import Flask

app = Flask(__name__)

@app.route("/")
def tweet_test():
    common_func.post_tweet()
    return "Result OK."

if __name__ == "__main__":
    port = os.environ.get("PORT", 3333)
    app.run(host='0.0.0.0', port=port)
```

アプリ本体の処理を記述しています。
アプリケーション起動後、適当なWebブラウザを用いて下記URLにアクセスしてください。
http://0.0.0.0:3333/
上記アドレスにアクセスすると、`tweet_test()`が実行されます。
正常終了した場合、ページ上に`Result OK.`と表示されます。

※上記URLのページにアクセスできない場合
ブラウザによっては、アドレスに`0.0.0.0`を使用するとエラーになる場合があるようです。
その場合、URLの`0.0.0.0`を`localhost`に置き換えてアクセスしてみてください。
http://localhost:3333/
<img width="400" alt="web_result.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/765001/e0650145-1021-fd10-804c-48b0b056703a.jpeg">

---

```python:common_func.py
import os, json, config

def get_tweet_content(): #jsonファイルからツイート本文を取得
    json_file_path = os.path.join(os.path.dirname(__file__), "json/tweet_contents.json")
    try:
        f = open(json_file_path, "r", encoding="cp932")
    except FileNotFoundError as e:
        print(e)
        exit() #ファイルが見つからなかった場合は終了
    json_data = json.load(f)
    return json_data["contents"]["tweet_text"]

def post_tweet(): 
    tweet_text = get_tweet_content()
    params = {"status": tweet_text}
    res = config.twitter.post(config.update_url, params=params)
    if res.status_code == 200: #正常投稿出来た場合
        print("Success.")
    else: #正常投稿出来なかった場合
        res_text = json.loads(res.text)
        print("Failed. : %d, %s"% (res_text["errors"][0]["code"], res_text["errors"][0]["message"]))
```

+ get_tweet_content()<br>
`get_tweet_content()`はjsonファイルから読み込んだツイート本文を戻り値として返却する関数です。<br>
`json.loads()`によってjsonファイルの内容を連想配列で取得します。
+ post_tweet()<br>
`post_tweet()`はツイートを投稿するための関数です。<br>
`params`を設定することで、ツイート内容やリプライ先を指定することができます。<br>
`twitter.post()`の第1引数にはエンドポイントのURL、第2引数には連想配列`param`を指定します。<br>
実行結果は画像のようになります。<br>
<img width="400" alt="tweet_result.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/765001/0346ae99-87b4-ec8b-eb1b-0cd69dcbbaaf.jpeg">

#アプリケーションのデプロイ

###Herokuの登録・Heroku CLIのインストール
下記ページを参考にHerokuの登録・Heroku CLIのインストールを行ってください。
[【Heroku】WindowsでHerokuを使ってPythonアプリをデプロイするメモ【Python】](https://qiita.com/osakasho/items/527421ec483052055b34)

###Herokuアプリケーションの作成

下記コマンドでHerokuにログインします。

```python:command
heroku login
```

次に、下記コマンドでアプリケーションを作成します。
今回は`test_app`という名前でアプリケーションを作成します。

```python:command
heroku create test_app
```

アプリケーションを作成後作業ディレクトリに移動し、Gitリポジトリの作成とリモート接続を行います。
(今回は作業ディレクトリをCドライブ直下に作成したと仮定します)

```python:command
cd /C/test
git init
heroku git:remote -a test_app
```

###設定ファイルの作成
下記設定ファイルを作成します。

+ runtime.txt<br>
Pythonのバージョンを記載します。<br>
Herokuがサポートしているバージョンを記載してください。

```python:runtime.txt
python-3.7.9
```

ローカル環境のPythonのバージョンは下記コマンドで確認できます。

```python:command
python --version
```

+ requirements.txt<br>
インストールするモジュールを記載します。

```python:requirements.txt
Flask==1.1.2
gunicorn==20.0.4
requests==2.25.0
requests-oauthlib==1.3.0
```

ローカル環境にインストールされたモジュール一覧は下記コマンドで確認できます。
インストールされたモジュールがすべて表示されるので、必要なモジュールのみ記載します。

```python:command
pip freeze
```

+ Procfile<br>
アプリケーションを起動するために実行するコマンドを記載します。

```python:Procfile
web: gunicorn test_app:app --log-file -
```

###デプロイ
下記コマンドを実行します。

```python:command
git add .
git commit -m "First deployment."
git push heroku master
```

コマンド`git push heroku master`の`master`はご自身の環境に合わせて適宜読み替えてください。

###トラブルシューティング
デプロイ時に発生したエラーの原因とその解決方法を以下にまとめました。

+ buildpackエラー<br>
エラーログ：`remote:  !     No default language could be detected for this app.`<br>
原因：buildpackの設定漏れ<br>
解決方法：下記コマンドを実行し、ビルドパックを設定します。<br>
参考：[herokuで悩んだところ](https://qiita.com/takuto_neko_like/items/52c6c52385386544aa62)

```python:command
heroku buildpacks:set heroku/python
```
+ `Procfile`が見つからない<br>
エラーログ：`remote:        No Procfile detected, using the default web server.`<br>
原因：ファイル名を間違えて「Profile」としていたため。<br>
解決方法：ファイル名を`Procfile`にリネームした。<br>
（補足）<br>
`Procfile`に**拡張子はありません**。<br>テキストエディタ等でファイルを作成すると、特に指定が無ければテキスト形式(.txt)で作成されるため注意が必要です。<br>
下記コマンドでファイルを作成すると無難です。

```python:command
echo "起動コマンド" > Procfile
```


###環境変数の設定
下記コマンドでアプリケーションで使用する環境変数を設定します。
設定値は"Twitter API keyの取得"で取得した値を使用してください。

```python:command
heroku config:set CONSUMER_KEY="********"        # API key
heroku config:set CONSUMER_SECRET="********"     # API key secret
heroku config:set ACCESS_TOKEN="********"        # Access token
heroku config:set ACCESS_TOKEN_SECRET="********" # Access token secret
```

###デプロイの確認
下記コマンドでHerokuアプリにアクセスし、アプリが正常に動作していることを確認します。

```python:command
heroku open
```

###ログの表示
下記コマンドでコンソール上にログを表示することができます。
`-t`はログをストリーミング状態で表示するためのオプションです。

```python:command
heroku logs -t
```

#おまけその１：画像付きでツイートする
`common_func.py`を以下のように修正します。

```python:common_func.py
import os, json, config, urllib.request

def get_tweet_content(): #jsonファイルからツイート内容を取得
    json_file_path = os.path.join(os.path.dirname(__file__), "json/tweet_contents.json")
    try:
        f = open(json_file_path, "r", encoding="cp932")
    except FileNotFoundError as e:
        print(e)
        exit() #ファイルが見つからなかった場合は終了
    return json.load(f) #ツイート内容のオブジェクトを返却

def upload_media(img_url): #画像をTwitterにアップロードし、media_idをリターン
    res = urllib.request.urlopen(img_url)
    img_data = res.read()
    img_files = {"media": img_data}
    res_media = config.twitter.post(config.upload_media_url, files=img_files)

    if res_media.status_code == 200:
        return json.loads(res_media.text)["media_id"]
    else:
        print("Image upload failed: %s", res_media.text)
        exit() #画像のアップロードに失敗した場合は終了

def post_tweet(): 
    tweet_content = get_tweet_content()
    media_id = upload_media(tweet_content["contents"]["img_url"])
    params = {"status": tweet_content["contents"]["tweet_text"], "media_ids" : media_id}
    res = config.twitter.post(config.update_url, params=params)
    if res.status_code == 200: #正常投稿出来た場合
        print("Success.")
    else: #正常投稿出来なかった場合
        res_text = json.loads(res.text)
        print("Failed. : %d, %s"% (res_text["errors"][0]["code"], res_text["errors"][0]["message"]))
```

実行結果は画像のようになります。
<img width="400" alt="image_tweet_result.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/765001/2b4c14a8-bc1b-f91e-148e-a699b333f04f.jpeg">

#おまけその２：リプライツイートに返信する
`common_func.py`を以下のように修正します。

```python:common_func.py
import os, json, config

def get_tweet_content(): #jsonファイルからツイート本文を取得
    json_file_path = os.path.join(os.path.dirname(__file__), "json/tweet_contents.json")
    try:
        f = open(json_file_path, "r", encoding="cp932")
    except FileNotFoundError as e:
        print(e)
        exit() #ファイルが見つからなかった場合は終了
    json_data = json.load(f)
    return json_data["contents"]["tweet_text"]

def post_tweet(): 
    tweet_text = get_tweet_content()
    params = {"status": tweet_text, "in_reply_to_status_id" : "XXXXXXXXXXXXXXXXXXX"}
    res = config.twitter.post(config.update_url, params=params)
    if res.status_code == 200: #正常投稿出来た場合
        print("Success.")
    else: #正常投稿出来なかった場合
        res_text = json.loads(res.text)
        print("Failed. : %d, %s"% (res_text["errors"][0]["code"], res_text["errors"][0]["message"]))
```

`in_reply_to_status_id`には、返信したいツイートのIDを入力してください。
ツイートのIDは、ブラウザ版twitterでツイートを表示した際のURLの末尾に記載されている、19桁の数字です。
`https://twitter.com/ユーザ名(スクリーンネーム)/status/XXXXXXXXXXXXXXXXXXX`

`in_reply_to_status_id `に返信したいツイートのIDを指定しただけでは、返信を行うことはできません。
ツイート本文の文頭に@付きでユーザー名(スクリーンネーム)を追加する必要があります。
そのため、`tweet_contents.json`を以下のように修正します。

```json:tweet_contents.json
{
  "contents":
  {
    "img_url" : "画像URL",
    "tweet_text" : "@XXXXXXXX 返信のテスト"
  }
}
```

`tweet_text`の文頭に`@XXXXXXXX `を追加します。
※`XXXXXXXX`には、返信したいアカウントのユーザ名（スクリーンネーム）を記載してください。

実行結果は画像のようになります。
<img width="400" alt="reply_tweet_result.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/765001/513ae128-3671-9721-2e19-6bcb1bfef06a.jpeg">