#(1)アプリ作成の背景
・引っ越してから、通勤にあたりバスを使うようになった。
・バスの時刻表をiphoneで撮影していちいち”写真”から確認している。
・通勤ルートは、【自宅 ↔︎ (バス)↔︎ 最寄駅 ↔︎ (電車)↔︎ 会社の駅】で、特に帰りは最寄駅に着いたらすぐにバスに乗りたい。
・バスの時刻を確認するたびに行う次の動作が面倒。(iphoneで写真アプリ起動)→ (写真アプリから撮影したバス時刻を探す)
・もっと効率良くバスの時刻を知りたい。
・勉強中のpython、LINEBot等を使って、生活の役に立つものを作りたい。
・勉強と割り切ってはいるものの、人のマネをしたアプリ作成はそろそろ飽きてきた。
・以上が、今回のアプリ作成の背景です。
(注意)ソースは初心者なので汚いです(ご容赦ください)。
(注意)csvファイルから時刻表を取り込んで、ローカル環境でテストした後にHerokuにデプロイしています(もっと効率的なやり方があるかもしれません)
↓↓↓↓↓↓↓(完成イメージ)↓↓↓↓↓↓↓↓
”行き”とLINEBotに発信すると、バスのイメージと、自宅最寄りのバス停から駅までのバス時刻、加えてその次のバス時刻を返します。”帰り”と発信すると、最寄り駅から自宅最寄りのバス停へのバス時刻を返します。
#(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ファイルで用意しました(サンプルです)。
(↓↓↓↓↓↓自宅近くの停留所から最寄駅までのバス時刻表)
(↓↓↓↓↓↓最寄駅から自宅近くの停留所までのバス時刻表)
###②jikoku.csvファイルを作成して、実行する
・自宅から最寄駅までの時刻表iki.csvと、最寄駅から自宅までの時刻表kaeri.csvを加工して、jikoku.csvを作成します。
・jikoku.csvを作成するためのファイルcsv_kakou.pyを作成します。
・以下を実行すると、iki.csvとkaeri.csvを加工してまとめたファイルjikoku.csvがassetsディレクトリ内にできます。
・まずは、csvの加工処理が終わりました。
#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レコードとなりました。
###③assetsディレクトリにdatabase.pyと、models.pyのファイルを作成する
assetsディレクトリにそれぞれを作成します。
#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)
#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を作成します。
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にデータが書き込まれていることを確認できました。
###⑤jikoku.pyファイルを作成する
・sqlデータベースに保存されている時刻から、指定した時刻を取得するファイルです。
・引数に’行き'、または'帰り'が代入されると、現在時刻から直近のバス時刻と、次のバス時刻をsqlから抽出して返り値として返します。
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に返します。
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ディレクトリに、以下の通り、それぞれを作成する。
<!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>
<!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をモジュールとして読み込むために必要なファイル。中身は何も記述しません。
###⑨ローカル環境で検証する
・最初に、csv_to_sql.pyを実行して、データベースの初期化と、csvからsqlへの読み込み、data.dbを作成します。
・次に、local_main.pyを実行してFlaskを起動し、ブラウザで確認します。
・↓↓↓↓↓↓↓(ブラウザ確認)”行き”と”帰り”のいずれかのボタンを押します。
・↓↓↓↓↓↓↓ (ブラウザ確認)ちゃんと表示されました(現在時刻は2時13分)
###ここまでで、ローカル環境でバス時刻表をちゃんと抽出できるようになりました。
#(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メソッドを使います。
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を参照に行くとなります。
#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のバージョンを確認の上、作成します。
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!"が表示されれば、無事デプロイ完了。
###⑥postgresqlにデータベースを書き込む
・Herokuのデータベースpostgresqlを設定し、データベースにcsvデータを書き込みます。
・Herokuアプリのresourceから、postgresqlを設定します。
・bashコマンドを実行し、Heroku環境でコマンドが打てるようにします。
・その後、csv_to_sql.pyを実行します。
・こうすることで、postgresqlを初期化し、csvのデータをpostgresqlに書き込みます。
heroku run bash
python3 csv_to_sql.py
ちゃんと、書き込まれているか確認します。
以下のコマンドを入力します。
heroku pg:psql
テーブル内のデータを一覧するコマンド
select * from (テーブル名);
以下が出力され、ちゃんとpostgresqlに書き込まれているのを確認できました。
###⑦LINE DevelopersのWebhookを設定
LINE DevelopersのWebhookにURLを設定し、Webhookの利用をオンにします(詳細は割愛します)。
友達登録して、LINEBotを立ち上げれば完成です。