Flaskの基本

3.FlaskにPythonプログラムを組み込みWebアプリとして公開する

前の記事のおさらいと本記事でやること

前の記事では、 Flaskの入門を解説し、PythonプログラムをFlaskに組み込む前段階としてindex.htmlとmain.pyを作成しました。

この記事では、最初の記事で作成したTwitte APIからツイート情報を取得するプログラムを、前の記事で作成したFlaskのWebサイトに組み込むことにより、スクリーンネームを入力するとツイート情報をCSVとしてダウンロードできるWebアプリを作成します。

これまでのプログラムを修正する

これまで作ったプログラムを組み合わせてWebアプリとして完成させるために、最初の記事で作った「fetchtweets.py」、前の記事で作った「main.py」、「index.html」を修正していきます。

index.html

index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>twitterとflaskの連携アプリテスト</title>
    <style type="text/css">
    .red {color:red;}
    </style>
</head>
<body>
    <h1>twitterからツイート・お気に入り数・リツイート数を取得するWebアプリ</h1>
    <p>検索したいユーザーのスクリーンネームを入力してください。</p>
        <form action="" method="POST">
        <input name="screen_name"/>
            <input type="submit" value="送信する"/>
        </form>
    {% if error %}
    <p class="red">{{error}}</p>
    {% endif %}
</body>
</html>

index.htmlで修正するところは基本的にはありません。

ただし、前の記事では、テストのために

{% if screen_name %}
<p>スクリーンネーム:<b>{{screen_name}}</b></p>
{% endif %}

という部分を20行目に挿入していたため、これを削除しておきます。

完成形のプログラムではCSVをダウンロードさせたいので、index.html上のそのほかの表示をいじる必要はありません。

fetchtweets.py

fetchtweets.py
from requests_oauthlib import OAuth1Session #pip install requests-oauthlib
import pandas
from datetime import datetime, timezone, timedelta
import io

CK = '取得したキーを入れる' # Consumer API key
CKS = '取得したキーを入れる' # Consumer API Secret key
AT = '取得したキーを入れる' # Access token
ATS = '取得したキーを入れる' # Access token secret

def search_tweets(screen_name):
    twitter = OAuth1Session(CK, CKS, AT, ATS)

    screen_name = screen_name.replace(" ","") #screen nameに空白が含まれていると自分(開発者)のツイートが表示される
    url = "https://api.twitter.com/1.1/statuses/user_timeline.json"
    params = {'screen_name': screen_name, 'count': '200', 'include_rts': 'false'}

    req = twitter.get(url, params=params, timeout=5)

    if req.status_code != 200:
        return "エラーが発生しました。ステータスコード: {}".format(req.status_code)

    timeline = req.json()

    columns = ["text", "favorite", "retweet", "created_at"]
    df = pandas.DataFrame(columns=columns)

    for tweet in timeline:
        text = tweet['text']
        favorite = tweet['favorite_count']
        retweet = tweet['retweet_count']

        created_at = datetime.strptime(tweet['created_at'], '%a %b %d %H:%M:%S %z %Y')
        created_at = created_at.astimezone(timezone(timedelta(hours=+9)))
        created_at = datetime.strftime(created_at, '%Y-%m-%d %H:%M:%S')
        se = pandas.Series([text, favorite, retweet, created_at], columns)
        df = df.append(se, ignore_index=True)

    buffer = io.StringIO()
    df.to_csv(buffer, index=False)
    return buffer

if __name__ == '__main__':
    screen_name = "twitterapi"
    search_tweets(screen_name)

次に最初の記事で作成したfetchtweets.pyを修正します。上記は修正後のコードです。

修正前はデータフレームをCSVファイルとして保存するコードでしたが、修正後はCSVをダウンロードさせるために、バッファ(データを一時的に格納するためのメモリ領域)としてCSVを書き出すコードに変更しています。

CSVをファイルとしてサーバー上に保存して、それをユーザーにダウンロードさせる、という方法でも可能です。しかしその場合は、ひと手間余分に工程がかかり無駄なファイルをサーバーに置かなければならないことになります。

そこで今回は、CSVをメモリ領域に一時保存し、それを出力しユーザーにダウンロードさせる方法をとっています。

実用的には、ファイルをキャッシュとして保存しておき、短い時間内でアクセスがあったときにAPIへの通信を行わず自サーバーに保存されているファイルをダウンロードさせるほうが、APIへの負荷や制限を考えて好ましいこともあります。

実際の開発の状況に合わせてどの方法がよいか選択してみてください。

import io

まず4行目で、Pythonの標準ライブラリで、バッファ関連を扱うためのIOモジュールをimportします。

buffer = io.StringIO()

39行目でio.StringIO()というメソッドにより、テキストバッファを生成、変数bufferに代入します。

ioモジュールで扱うバッファには、テキストI/O(テキストバッファ)、バイナリI/Oの主に二種類があります(正確に言えばRaw I/Oという種類もありますが、使われることはめったにありません)。

テキストI/Oは、

io.StringIO()

によって生成し、テキストデータを扱うときに用います。

バイナリI/Oは、

io.BytesIO()

によって生成し、 テキスト以外の画像や動画等のバイナリデータを扱うときに用います。

ここでいうテキストとバイナリは、一般的なテキスト(ファイル)とバイナリ(ファイル)の違いと同じです。

よくわからない方は、テキストはメモ帳で開いて人間が読んでも意味を理解できるデータで、バイナリはそれ以外のデータと理解するのが楽でしょう。

※上記のようにテキストとバイナリは対比されて用いられるのが通例ですが、厳密にいえばバイナリはテキストも含めたすべてのデータのことを指します。

また、プログラミング上は、テキストはstr型オブジェクト、バイナリはbytes型オブジェクトのことを指すので、テキストデータをバイナリデータとして変換して扱うようなこともあり得ます。

df.to_csv(buffer, index=False)

40行目でPandasのto_csv()メソッドにより、データフレームを先ほど生成したバッファにCSVとして書き込みます。to_csv()では、書き込み先にファイルだけでなくバッファを指定することができます。

return buffer

41行目のreturnでバッファを関数search_tweets()の返り値として返すところまでが、今回修正した部分です。

これによってスクリーンネームを入力すると、 CSV形式で保存したツイート情報をバッファとして返す関数search_tweets()を書いたプログラムfetchtweets.pyが完成しました。

main.py

main.py
from flask import Flask, render_template, request, make_response
import fetchtweets

app = Flask(__name__)

@app.route('/', methods=["GET", "POST"])
def index():
    if request.method == "GET":
        return render_template("index.html")

    else:
        screen_name = request.form["screen_name"]

        if len(screen_name) == 0:
            return render_template("index.html", error="検索ユーザー名が未入力です。")

        result = fetchtweets.search_tweets(screen_name)

        if isinstance(result, str):
            return render_template("index.html", error=result)

        output = make_response()
        output.data = result.getvalue().encode("utf_8_sig")
        output.headers["Content-Disposition"] = "attachment; filename={}.csv".format(screen_name)
        output.headers["Content-type"] = "text/csv"
        return output

if __name__ == '__main__':
    app.run()

最後はFlaskの処理が書かれたmain.pyの修正です。上記は修正後のコードです。

まず1行目に、CSVをファイルとしてダウンロードさせるために、レスポンスオブジェクト(ユーザーに返すHTTPレスポンスの情報が格納されている)を作成できるmake_response()というFlaskのメソッドを新たにimportしています。

2行目では、最初の記事で作成し、先ほど修正したfetchtweets.pyをimportしています。

Pythonのライブラリと同様のimport文によって、 自分で作った他の.pyファイルにある関数を使えます。

仮にmy_python_file.pyという名前のPythonファイルにmy_function()という関数がある場合は、

import my_python_file

my_python_file.my_function()

と書いて自作の関数を呼び出すことができます。

17行目から26行目が新たに追記した部分です。

        result = fetchtweets.search_tweets(screen_name)

17行目で先ほどのfetchtweets.pyのsearch_tweets()に変数screen_name、つまりユーザーがフォームに入力した値を引数として渡し、返り値を変数resultに代入しています。

        if isinstance(result, str):
            return render_template("index.html", error=result)

19行目から20行目では、返り値の型が文字列だった場合、つまりエラー文だった場合は、index.htmlにパラメータerrorの値をエラー文の文字列として渡し、HTMLを動的に生成させそこで処理を終了させます。

        output = make_response()
        output.data = result.getvalue().encode("utf_8_sig")
        output.headers["Content-Disposition"] = "attachment; filename={}.csv".format(screen_name)
        output.headers["Content-type"] = "text/csv"
        return output

返り値が文字列でなかった場合、つまり正常にバッファが返された場合は、22行目でmake_response()によりHTTPレスポンスを返すためのレスポンスオブジェクトを生成、変数outputに代入します。

23行目でレスポンスボディ(レスポンスオブジェクトのdata属性、output.dataで参照可)にバッファの中身、つまりCSVデータを代入し、Pythonの組み込みメソッドであるencode()によって、日本語が文字化けしないようにBOM有りUTF-8(utf_8_sig)に文字コードを変換しています。

ファイルにファイル名や作成日時・更新日時などの情報が含まれているように、バッファはテキストデータの中身そのものと同一ではありません。

よってバッファに格納されているデータの中身を表示させたい場合は、

buffer.getvalue()

と、getvalue()メソッドにより出力する必要があります。

24~25行目で、{入力されたスクリーンネーム}.csvというファイル名でCSVとしてダウンロードさせるように、HTTPヘッダー(レスポンスオブジェクトのheaders属性、output.headersで参照可)のContent-DispositionとContent-typeを設定しています。

26行目のreturn文でレスポンスオブジェクトをHTTPレスポンスとしてユーザーに返し、CSVをユーザーにダウンロードさせるコードが完成です。

バッファに保存されたデータをユーザーにダウンロードさせるには、今回解説したようにレスポンスオブジェクトを生成し、ユーザーにHTTPレスポンスとして返さなければなりません。

バッファでなくサーバー上に保存されたファイルの場合は、Flaskのメソッドのsend_from_directory()で引数にファイルパスを指定することにより、簡単にユーザーにダウンロードさせることができます。

※send_from_directory()と同じような機能をもつ、send_file()というメソッドもありますが、セキュリティ上send_from_directory()の利用が推奨されています。

サーバー上で稼働させるためのmain.pyのコード

これでプログラムとしては完成ですが、このままだとローカル環境でしか動かないので、サーバー上で動かすためにmain.pyを改良します。

main.py
from flask import Flask, render_template, request, make_response
import fetchtweets
import os

app = Flask(__name__)

@app.route('/', methods=["GET", "POST"])
def index():
    if request.method == "GET":
        return render_template("index.html")

    else:
        screen_name = request.form["screen_name"]

        if len(screen_name) == 0:
            return render_template("index.html", error="検索ユーザー名が未入力です。")

        result = fetchtweets.search_tweets(screen_name)

        if isinstance(result, str):
            return render_template("index.html", error=result)

        output = make_response()
        output.data = result.getvalue().encode("utf_8_sig")
        output.headers["Content-Disposition"] = "attachment; filename={}.csv".format(screen_name)
        output.headers["Content-type"] = "text/csv"
        return output

if __name__ == '__main__':
    port = int(os.getenv("PORT"))
    app.run(host="0.0.0.0", port=port)

サーバーで公開するときの必須の設定は、最後の行のapp.run()の引数のhostを"0.0.0.0"と指定することです。

ポート番号自体は解放されているのであれば任意で、指定しなければデフォルトで5000が使用されます。

サーバーとしてHerokuを利用するときは、Herokuでは使用できるポート番号が動的に変わるため、3行目で標準ライブラリのOSモジュールをimportし、30行目のようにポート番号をos.getenv()によって環境変数から取得して、app.run()の引数でその番号を指定する必要があります。

これで、フォームに入力したTwitterのスクリーンネームからツイート情報を取得し、CSVとしてユーザーにダウンロードさせるWebアプリが完成しました。

タイトルとURLをコピーしました