Help us understand the problem. What is going on with this article?

LINEBotで、「バス時刻」を知らせてくれるアプリを作りました

(1)アプリ作成の背景

・引っ越してから、通勤にあたりバスを使うようになった。
・バスの時刻表をiphoneで撮影していちいち”写真”から確認している。
・通勤ルートは、【自宅 ↔︎ (バス)↔︎ 最寄駅 ↔︎ (電車)↔︎ 会社の駅】で、特に帰りは最寄駅に着いたらすぐにバスに乗りたい。
・バスの時刻を確認するたびに行う次の動作が面倒。(iphoneで写真アプリ起動)→ (写真アプリから撮影したバス時刻を探す)
・もっと効率良くバスの時刻を知りたい。
・勉強中のpython、LINEBot等を使って、生活の役に立つものを作りたい。
・勉強と割り切ってはいるものの、人のマネをしたアプリ作成はそろそろ飽きてきた。
・以上が、今回のアプリ作成の背景です。
(注意)ソースは初心者なので汚いです(ご容赦ください)。
(注意)csvファイルから時刻表を取り込んで、ローカル環境でテストした後にHerokuにデプロイしています(もっと効率的なやり方があるかもしれません)

↓↓↓↓↓↓↓(完成イメージ)↓↓↓↓↓↓↓↓
”行き”とLINEBotに発信すると、バスのイメージと、自宅最寄りのバス停から駅までのバス時刻、加えてその次のバス時刻を返します。”帰り”と発信すると、最寄り駅から自宅最寄りのバス停へのバス時刻を返します。
スクリーンショット 2020-03-29 14.10.08.png

(2)この記事で書かないことと、記事の構成

①この記事で書かないこと

・LINEBotのチャンネル作成方法
・Herokuへのデプロイ方法詳細

②記事の構成

(1)アプリ作成の背景
(2)この記事で書かないことと、記事の構成
(3)環境構築
(4)ローカル環境で動作確認
(5)Herokuを使いLINEBotに組み込む

(3)環境構築

・Mac
・python3
・sqlite3
・Postgresql
・Heroku
・Flask

まず、デスクトップにディレクトリlinebot_jikokuhyouを作成して、ディレクトリ内に仮想環境を構築して起動します。

python3 -m venv .
source bin/activate

(4)ローカル環境で動作確認

まずは、ローカル環境でテストします。
データベースはsqlを使います。
あらかじめ用意したcsvファイルをsqlに取り込み、Flaskでローカル上で正常にプログラムが動作するか検証します。

①作業ディレクトリと、作業ファイルを準備する

以下のようにディレクトリとファイルを用意します。
iki.csvと、kaeri.csvは自分で用意したファイルを使います(後述)。
上記以外のファイルは空ファイルとして作成します。

linebot_jikokuhyou
├csv_kakou.py
├csv_to_sql.py
├local_main.py
├jikoku_main.py
├assets
│  ├database.py
│  ├models.py
│  ├__ini__.py
│  ├iki.csv(自分で用意したcsvファイル)
│  └kaeri.csv(自分で用意したcsvファイル)
│ 
└templates
   ├test.html
   └test2.html

(補足説明)

・以下の時刻表をcsvファイルで用意しました(サンプルです)。
(↓↓↓↓↓↓自宅近くの停留所から最寄駅までのバス時刻表)
スクリーンショット 2020-03-28 23.24.12.png
(↓↓↓↓↓↓最寄駅から自宅近くの停留所までのバス時刻表)
スクリーンショット 2020-03-28 23.24.34.png

②jikoku.csvファイルを作成して、実行する

・自宅から最寄駅までの時刻表iki.csvと、最寄駅から自宅までの時刻表kaeri.csvを加工して、jikoku.csvを作成します。
・jikoku.csvを作成するためのファイルcsv_kakou.pyを作成します。
・以下を実行すると、iki.csvとkaeri.csvを加工してまとめたファイルjikoku.csvがassetsディレクトリ内にできます。
・まずは、csvの加工処理が終わりました。

csv_kakou.py
#iki.csvを加工する処理
list = []
with open('assets/iki.csv',encoding='utf-8')as f:
    #行ごとに読み込む処理
    for i in f:
        columns = i.rstrip()
        list.append(columns)
list2 = []
for i in list:
    columns2 = i.split(',')
    for ii in range(len(columns2)):
        if ii != 0:
            list2.append(columns2[0]+'時'+columns2[ii]+'分')
list2.pop(0)
num = 1
with open('assets/jikoku.csv','w',encoding='utf-8')as f:
    go_or_come = '行き'
    for time in list2:
        f.write(str(num) +','+time+','+str(go_or_come)+'\n')
        num+=1

#kaeri.csvを加工する処理
list = []
with open('assets/kaeri.csv',encoding='utf-8')as f:
    #行ごとに読み込む処理
    for i in f:
        columns = i.rstrip()
        list.append(columns)
list2 = []
for i in list:
    columns2 = i.split(',')
    for ii in range(len(columns2)):
        if ii != 0:
            list2.append(columns2[0]+'時'+columns2[ii]+'分')
list2.pop(0)
with open('assets/jikoku.csv','a',encoding='utf-8')as f:
    go_or_come = '帰り'
    for time in list2:
        f.write(str(num) +','+time+','+str(go_or_come)+'\n')
        num+=1

・↓↓↓↓↓↓↓ assetsディレクトリに作成されたjikoku.csvは以下の通りとなります(一部抜粋)。全部で64レコードとなりました。
スクリーンショット 2020-03-28 23.58.19.png
スクリーンショット 2020-03-29 0.06.19.png

③assetsディレクトリにdatabase.pyと、models.pyのファイルを作成する

assetsディレクトリにそれぞれを作成します。

database.py
#coding: utf-8


from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session,sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import datetime
import os

database_file = os.path.join(os.path.abspath(os.path.dirname(__file__)),'data.db')

engine = create_engine('sqlite:///' + database_file,convert_unicode=True,echo=True)
db_session = scoped_session(
                sessionmaker(
                    autocommit = False,
                    autoflush = False,
                    bind = engine
                )
            )
Base = declarative_base()
Base.query = db_session.query_property()

def init_db():
    #assetsフォルダのmodelsをインポート
    import assets.models
    Base.metadata.create_all(bind=engine)
models.py
#coding: utf-8

from sqlalchemy import Column,Integer,String,Boolean,DateTime,Date,Text
from assets.database import Base
from datetime import datetime as dt

#データベースのテーブル情報
class Data(Base):
    #テーブルnameの設定,dataというnameに設定
    __tablename__ = "data"
    #Column情報を設定、uniqueはFalseとする(同じ値でも認めるという意味)
    #主キーは行を検索する時に必要、通常は設定しておく
    id = Column(Integer,primary_key=True)
    #行き、帰り、どちらかなのかを判定
    go_or_come = Column(Text,unique=False)
    #主キーとは別にナンバリング
    num = Column(Integer,unique=False)
    #時刻表の時刻
    time = Column(Text,unique=False)
    #timestamp
    timestamp = Column(DateTime,unique=False)

    #初期化する
    def __init__(self,go_or_come=None,num=0,time=None,timestamp=None):
        self.go_or_come = go_or_come
        self.num = num
        self.time = time
        self.timestamp = timestamp

④csv_to_sql.pyのファイルを作成する

・csvデータを読み込み、sqlデータに書き込むファイルcsv_to_sql.pyを作成します。

csv_to_sql.py
from assets.database import db_session
from assets.models import Data

#初期化処理
from assets.database import init_db
init_db()

#csvからsqlに書き込む処理
with open('assets/jikoku.csv',encoding='utf-8')as f:
    for i in f:
        columns = i.rstrip().split(',')
        num = int(columns[0])#numはmodels.pyでint型として定義しているのでint型にした
        time = columns[1]
        go_or_come = columns[2]
        row = Data(num=num,time=time,go_or_come=go_or_come)
        db_session.add(row)
        db_session.commit()

(補足説明)

・init_db()で、sqlを初期化します。
・de_session.addした後に、db_session.commitすることで、sqlに書き込みます。
・sqlにちゃんと書き込まれたかを確認します。assetsディレクトリに移動して以下を入力し、sqliteモードとします。

sqlite3 data.db

sqliteで以下を入力すると、

select * from data;

以下が出力され、sqlにデータが書き込まれていることを確認できました。
スクリーンショット 2020-03-29 1.49.22.png

⑤jikoku.pyファイルを作成する

・sqlデータベースに保存されている時刻から、指定した時刻を取得するファイルです。
・引数に’行き'、または'帰り'が代入されると、現在時刻から直近のバス時刻と、次のバス時刻をsqlから抽出して返り値として返します。

jikoku.py
from assets.database import db_session
from assets.models import Data
import datetime


def jikoku_search(route):
    #sqlを読み込む
    data = db_session.query(Data.num,Data.time,Data.go_or_come,).all()

    #現在日時を取得(datetime型)
    date_todaytime = datetime.datetime.today()
    #上記をstr型に変換
    str_todaytime = date_todaytime.strftime('%Y年%m月%d日%H時%M分')
    #現在日時のうち、●年●月●日のみ取得(date型)
    date = datetime.date.today()
    #上記をstr型に変換
    str_date = date.strftime('%Y年%m月%d日')

    #変数を設定
    bustime = ''
    next_bustime = ''

    #routeは行きと帰りを分類
    route = route
    #sqlから直近のバスの出発時刻と、次のバスの出発時刻を抽出
    for i in data:
        str_sql = i[1]
        #sqrの時刻に、現在日時の●年●月●日を付け加えて”日時”にする
        str_sql_rr = str_date + str_sql
        #上記をdatetime型に変換
        date_sql_rr = datetime.datetime.strptime(str_sql_rr,'%Y年%m月%d日%H時%M分')
        #現在日時と比較して直近で出発するバス日時を取得
        if date_sql_rr > date_todaytime and i[2]== route:#go_or_comeがrouteと合致するなら以下を実行
            #直近のバスの出発日時と現在日時の差分を取得
            date_sabun = date_sql_rr-date_todaytime
            #datetimeの差分はtimedelta型となる。timedelta型はstrftimeでstr型にできないため、str()でstr型とした
            #timedelta型は、0:00:00となっており、かつ差分は時刻表から1時間以内のため、スライスで”分”を抽出
            if str(date_sabun)[0:1] == "0":
                bustime = '次のバスは、'+str_sql_rr+'に出発します。' + 'あと' + str(date_sabun)[2:4] + '分です。'
            else:
                bustime = 'その次のバスは、'+str_sql_rr+'に出発します。' + 'あと'+ str(date_sabun)[0:1] + '時間' + str(date_sabun)[2:4] + '分です。'

            #次のバスの出発日時のnumを取得
            next_num = i[0]
            #次のバスの出発時刻を取得(直近バスはあるが、次のバスが終電を超えてる場合の処理)
            try:
                _next_bustime = db_session.query(Data.num,Data.time,Data.go_or_come).all()[next_num].time
                #次のバスの出発時刻に現在日時の●年●月●日を付け加えて”日時”にする
                next_bustime = str_date + _next_bustime+'に出発します。'
            except:
                next_bustime="終電を超えています。"

            #バスの時刻を取得したらfor文を抜ける処理
            break

        #直近のバス、次のバスともに終電が終わっている場合の処理
        else:
            bustime="次のバスは終電を超えています。"
            next_bustime="その次のバスも終電を超えています。"


    return bustime,next_bustime

⑥local_main.pyを作成する

・ローカル環境でjikoku.pyが正常に作動するか検証するために、local_main.pyを作成します。
・まず、test.htmlを表示するとともに、sqlデータベースを読み込みます。
・test.htmlでは’行き’と’帰り’を指定するので、’行き’の場合と、’帰り’の場合の引数をそれぞれjikoku.pyのjikoku_searchメソッドに代入し、返り値をtest2.htmlに返します。

local_main.py
from flask import Flask,request,render_template
from assets.database import db_session
from assets.models import Data
import jikoku_main as jm

app = Flask(__name__)

@app.route('/')
def test():
    #sqlから読み込む
    data = db_session.query(Data.num,Data.time,Data.go_or_come,).all()
    return render_template('test.html',data=data)

@app.route('/iki')
def test2():
    result1,result2 = jm.jikoku_search('行き')
    return render_template('test2.html',bustime=result1,next_bustime=result2)

@app.route('/kaeri')
def test3():
    result1,result2 = jm.jikoku_search('帰り')
    return render_template('test2.html',bustime=result1,next_bustime=result2)

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

⑦templatesディレクトリにtest.htmlと、test2.htmlを作成する

templatesディレクトリに、以下の通り、それぞれを作成する。

test.html
<!DOCTYPE html>
<html lang='ja'>
  <head>
      <meta charset='utf-8'>
      <title>Jikokuhyou</title>
      <style>body{padding:10px;}</style>
  </head>


  <body>
    <form action='/iki' method='get'>
      <button type='submit'>行き</button>
    </form>
    <form action='/kaeri' method='get'>
      <button type='submit'>帰り</button>
    </form>
  </body>

</html>
test2.html
<!DOCTYPE html>
<html lang='ja'>
  <head>
      <meta charset='utf-8'>
      <title>Jikokuhyou</title>
      <style>body{padding:10px;}</style>
  </head>


  <body>
    {{'直近のバスは、'+bustime}} <br>{{'次のバスは、'+next_bustime}}
    <form action='/' method='get'>
      <button type='submit'>戻る</button>
    </form>
  </body>
</html>

⑧assetsディレクトリ内に_init_ファイルを作成する

・_init_は、app.pyからdatabase.pyやmodels.pyをモジュールとして読み込むために必要なファイル。中身は何も記述しません。

__init__.py

⑨ローカル環境で検証する

・最初に、csv_to_sql.pyを実行して、データベースの初期化と、csvからsqlへの読み込み、data.dbを作成します。
・次に、local_main.pyを実行してFlaskを起動し、ブラウザで確認します。
・↓↓↓↓↓↓↓(ブラウザ確認)”行き”と”帰り”のいずれかのボタンを押します。
スクリーンショット 2020-03-29 0.46.40.png
・↓↓↓↓↓↓↓ (ブラウザ確認)ちゃんと表示されました(現在時刻は2時13分)
スクリーンショット 2020-03-29 2.11.45.png

ここまでで、ローカル環境でバス時刻表をちゃんと抽出できるようになりました。

(5)Herokuを使いLINEBotに組み込む

・ローカル環境で検証できたので、いよいよHerokuを使いLINEBot化します。

①作業ディレクトリと、作業ファイルを準備する

・以下のようにディレクトリとファイルを追加します。
・参考までに上記までで作成されたdata.dbと、jikoku.csvも追記しておきます。

linebot_jikokuhyou
├csv_kakou.py
├csv_to_sql.py
├local_main.py
├jikoku_main.py
├main.py(追加)
├requirments.txt(追加)
├runtime.txt(追加)
├Procfile(追加)
├assets
│  ├database.py
│  ├models.py
│  ├data.db(これまでで作成されたファイル)
│  ├__ini__.py
│  ├jikoku.csv(これまでで作成されたファイル)
│  ├iki.csv
│  └kaeri.csv
│ 
└templates
   ├test.html
   └test2.html

②main.pyを作成する

・まず、line-sdkを使って、バス時刻を通知するファイルmain.pyを作成します。
・jikoku_mainモジュールをインポートして、jikoku_mainのjikoku_searchメソッドを使います。

main.py
from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,LocationMessage,ImageSendMessage
)
import os
import jikoku_main as jm


app = Flask(__name__)

YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]
line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)

@app.route("/")
def hello_world():
    return "hello world!"


@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return 'OK'



@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if '行き' in event.message.text:
        result1,result2 = jm.jikoku_search('行き')
        line_bot_api.reply_message(
            event.reply_token,
            [
            ImageSendMessage(original_content_url='https://www.photolibrary.jp/mhd5/img237/450-2012011014412960119.jpg',
            preview_image_url='https://www.photolibrary.jp/mhd5/img237/450-2012011014412960119.jpg'),
            TextSendMessage(text=result1),
            TextSendMessage(text=result2)
            ]
        )


    if '帰り' in event.message.text:
        result1,result2 = jm.jikoku_search('帰り')
        line_bot_api.reply_message(
            event.reply_token,
            [
            ImageSendMessage
(original_content_url='https://www.photolibrary.jp/mhd5/img237/450-2012011014412960119.jpg',
            preview_image_url='https://www.photolibrary.jp/mhd5/img237/450-2012011014412960119.jpg'),
            TextSendMessage(text=result1),
            TextSendMessage(text=result2)
            ]
        )



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

(補足説明)

・LINEBotの全体的な構造はLINE BOTを使ってみよう(CallBackプログラム:受信編)を参考にさせていただきました。
・LINEBotでテキストを複数送信する場合は、リストを使います。
・LINEBotで画像を送信する場合は、https型で、かつjpegに限定されます。以下のサイトを参考にさせていただきました。
【Python入門】LineAPIを使って公式アカウントから画像と文章を送信する
python line bot imagemap 画像送信
line-bot-sdk-pythonを使ってみた
・バスのイラストは、こちらを使わせていただきました。

③database.pyを修正する

・Herokuのpostgresqlを使用するにあたりdatabase.pyを修正します。
・具体的には、environというHeroku上の環境変数を見に行ってDATABASE_URLというデータベースを取得する処理を記述します。
・environには接続先のURLがセットされます。また、orをつけることで、ローカル環境上はsqliteをデータベースとして参照することとしました。
・herokuに接続されている場合はpostgresqlのurlを参照して、接続されていない場合はsqlを参照に行くとなります。

database.py
#coding: utf-8

#database.py/sqliteなど、どのデータベースを使うのか初期設定を扱うファイル
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session,sessionmaker
from sqlalchemy.ext.declarative import declarative_base

import datetime
import os

#data_dbという名前で、database.pyのある場所に(os.path.dirname(__file__))、絶対パスで(os.path.abspath)、data_dbを保存するpathを保存する。
database_file = os.path.join(os.path.abspath(os.path.dirname(__file__)),'data.db')

#データベースsqliteを使って(engin)、database_fileに保存されているdata_dbを使う、またechoで実行の際にsqliteを出す(echo=True)
#engine = create_engine('sqlite:///' + database_file,convert_unicode=True,echo=True)
engine = create_engine(os.environ.get('DATABASE_URL') or 'sqlite:///' + database_file,convert_unicode=True,echo=True)
db_session = scoped_session(
                sessionmaker(
                    autocommit = False,
                    autoflush = False,
                    bind = engine
                )
            )

#declarative_baseのインスタンス生成する
Base = declarative_base()
Base.query = db_session.query_property()


#データベースの初期化をする関数
def init_db():
    #assetsフォルダのmodelsをインポート
    import assets.models
    Base.metadata.create_all(bind=engine)

④Herokuに環境変数を設定する

・まず、LINE Develpersへアクセスして登録し、チャンネルを新規作成します(説明は割愛します)。
・チャンネルを作成したら、LINE Developersに記載されている「アクセストークン文字列」と「チャンネルシークレットの文字列」をコピーします。
・Herokuにアクセスしてアプリケーションを新規作成します。
・gitを初期化して、Herokuと紐つけます。
・Herokuの環境変数に先ほどの「アクセストークン文字列」と「チャンネルシークレットの文字列」を設定します。
・例えば、heroku config:set YOUR_CHANNEL_ACCESS_TOKEN="チャネルアクセストークンの文字列" -a (アプリ名)とする

heroku config:set YOUR_CHANNEL_ACCESS_TOKEN="チャネルアクセストークンの文字列" -a (アプリ名)
heroku config:set YOUR_CHANNEL_SECRET="チャネルシークレットの文字列" -a (アプリ名)

heroku上に環境変数がちゃんとセットされたか確認します。

heroku config

⑤その他必要なファイルを作成する

・Procfile、runtime.txt、requirements.txtを作成します。
・runtime.txtは、自身のpythonのバージョンを確認の上、作成します。

runtime.txt
python-3.8.2
web: python main.py

requirements.txtは以下をターミナルで入力して記述します。

pip freeze > requirements.txt

⑤Herokuへpush、デプロイする

・以下の手順でデプロイします。
・commit名は、the-firstとしました。

git add .
git commit -m'the-first'
git push heroku master
Heroku open

↓↓↓↓↓ Heroku openしてブラウザで確認すると、"hello world!"が表示されれば、無事デプロイ完了。
スクリーンショット 2020-03-29 1.34.22.png

⑥postgresqlにデータベースを書き込む

・Herokuのデータベースpostgresqlを設定し、データベースにcsvデータを書き込みます。
・Herokuアプリのresourceから、postgresqlを設定します。
スクリーンショット 2020-03-29 2.49.59.png

・bashコマンドを実行し、Heroku環境でコマンドが打てるようにします。
・その後、csv_to_sql.pyを実行します。
・こうすることで、postgresqlを初期化し、csvのデータをpostgresqlに書き込みます。

heroku run bash
python3 csv_to_sql.py

ちゃんと、書き込まれているか確認します。
以下のコマンドを入力します。

heroku pg:psql
テーブル内のデータを一覧するコマンド
select * from (テーブル名);

以下が出力され、ちゃんとpostgresqlに書き込まれているのを確認できました。
スクリーンショット 2020-03-29 1.45.06.png

⑦LINE DevelopersのWebhookを設定

LINE DevelopersのWebhookにURLを設定し、Webhookの利用をオンにします(詳細は割愛します)。
友達登録して、LINEBotを立ち上げれば完成です。
スクリーンショット 2020-03-29 14.10.08.png

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away