寒月記

住みにくいところをどれほどか寛容て

【Python】近くの喫茶店・カフェを教えてくれる LINE BOT 作成の記録・解説

※Qiita からこちらにも記事を移しました

Introduction

 Pythonの勉強をしながら、折角だから何か形になるものを作ろうと思い、LINE BOTを開発していました。 そして 2018年11月末に、動くところまではひっそり完成していました。

 開発した LINE BOTの機能は、「位置情報を送ると、近くの喫茶店・カフェを教えてくれる」というシンプルなものです。 簡単に試せるので、よろしければ以下の QRコードから友達登録して、是非使ってみてください。

f:id:kan-getsu:20190502225219p:plain

 なお、本記事は、開発中無駄に遠回りしてしまったり、はまってしまったことなどを、自身の学習の記録としてアウトプットすること・知識の共有をすることを目的としています。 似たようなことに挑戦されている方のお役に立てば幸いです。

※後述の Fixieを利用している関係で、月当たりの利用可能リクエスト数に制限があるため、そんなに混むことはないと思いますがもし万が一反応しなくなっていたらすみません。
※API 更新などの関係で停止している場合はご容赦ください

目次

  1. 作成した BOT「喫茶案内所」の紹介
  2. LINE Botの構成
  3. 前提知識紹介
    1. Heroku
    2. Flask
    3. Web API
    4. Webhook
  4. LINE BOT作成手順
  5. 実装の解説
    • 5-1. サンプルスクリプトの確認
    • 5-2. 「喫茶案内所」のスクリプト解説
  6. まとめ
  7. 画像引用元サイト
  8. 参考記事

1. 作成した BOT「喫茶案内所」の紹介

 まず、今回作成した BOTを紹介します。

  1. まず友達登録してトーク画面に移動します。
  2. トーク画面で下図の手順で位置情報を送ると

f:id:kan-getsu:20190502225423p:plain f:id:kan-getsu:20190502225449p:plain

  1. 近くにカフェや喫茶店があれば、その情報とぐるなびのリンクを送ってくれます (マクドナルドはぐるなびではカフェ属性持ち判定)。

f:id:kan-getsu:20190502225523p:plain

なお、2018/12/31現在、半径 400m以内に喫茶店やカフェがない場合は、何も反応してくれません。。

詳しい実装については、5で解説を。

2. LINE BOTの構成

 初めに、今回作成した LINE BOTの構成を紹介します。 主な登場人物 (?) は、クライアントサーバーとして利用した Herokuインターフェースとして利用した LINEの 3種です。 f:id:kan-getsu:20190502225617p:plain

図の左下がクライアント、中心が LINE BOT のアカウント、右が BOT の本体となる Python コードが実際に存在し、動作するサーバーです。 今回は、サーバーに Heroku を利用しています。

3. 前提知識紹介

 次に、今回の Python での LINE BOT 作成に必要となる前提知識を紹介します。 大きく、以下の 4種があります。

  1. サーバーとしての Heroku
  2. Python Web アプリのフレームワークである Flask
  3. Web API
  4. LINE Messaging API
  5. ぐるなび API
  6. Webhook

3-1. サーバーとしての Heroku

 公式サイトの説明 を引用してみましょう。

Heroku はコンテナベースのクラウド型 PaaS(サービスとしてのプラットフォーム)です。 ...... Heroku はフルマネージドのプラットフォームであるため、開発者がサーバーやハードウェア、インフラストラクチャの管理に煩わされることなく、製品開発に没頭できます。......

Herokuは PaaS (Platform as a Service) と呼ばれるサービスです。 クラウドサービスというと、最近では AWS を連想することが多いのではないかと思いますが、AWS は IaaS (Infrastructure as a Service) です。

IaaS と PaaS の違いとしては、IaaS ではインフラまでが用意されているだけで、Web サーバーや DB などのミドルウェアは自分でインストールしなければなりません。 ただし、その分自由度はあります。

一方、PaaS では、インフラに加え、初めからミドルウェアなどが用意されており、比較的簡単にアプリをデプロイできたりします。 ただし、その分 IaaS ほどの自由度はない様子です。

Heroku は PaaS なので、コードを用意するだけで、Web アプリ、今回で言えば BOT をデプロイできます。 Heroku では、用意した Heroku サーバーに対し、git でコードなどを push することでデプロイできます。

詳しい利用法については、英語ですが 公式のチュートリアル があるので、そちらをなぞるとある程度摑めると思います。

なお、LINE の公式ドキュメントでも、Heroku を利用した BOT の作成チュートリアル があるので、こちらはかなり参考になると思います。

3-2. Python Web アプリのフレームワークである Flask

 Python を用いた LINE BOT のサンプル は、Web アプリフレームワークの Flask を利用しています。

Pythonの Web アプリフレームワークといえば Django を想像する方が多いと思いますが、Flask も人気のあるフレームワークで、Django よりもシンプルな分使い易いものとなっているようです (公式ドキュメント: http://flask.pocoo.org/docs/1.0/)

とはいえ、実は LINE BOT をデプロイするだけなら Flask の知識はほとんど要りません。 私は自分比頑張って Flaskのチュートリアル を終えてから実装してみたのですが、ルーティングにしか使いませんでした。。

 ちなみにルーティングとは、大雑把に言えば URL と関数を紐づけることです。 LINE BOT を作るだけなら、実装によってはこれすら必須でもない気もしてきましたが、最低限ドキュメントの Routing の部分だけ理解できていればよいと思います。 なお、ルーティングに利用している Python の文法、デコレーター について理解が不安な方・再確認したい方は、是非前回の記事 Pythonのデコレータの基本:使い方から functools.wraps の利用まで をご覧ください。

3-3. Web API

今回利用している Web API は、以下の 2種です。

  • LINE Messaging API
  • ぐるなび API

LINE Messaging API

LINE Messaging API は、LINE の BOTに利用する API です。 普段 LINE を利用する時に、メッセージを送信したり、画像を送信したり、スタンプを送信したりします。 こうした動作を、実際に人が操作するのではなく、今回だと Python のコードから利用・制御するためのもの が、LINE Messaging API です。 これが BOT の自動動作を実現しています。

使い方については、公式ドキュメント と、LINE 公式が GitHub に上げているサンプルコード を見比べながら進めると、理解し易いと思います。

ぐるなび API

ぐるなび API は、今回喫茶店・カフェの情報を取得するために利用しました。 レストラン検索 API を利用しています。 使い方も簡単ですし、こちらのテストページ では、様々なパラメータを利用した際の結果の確認もできます。

制約として、当然ですが ぐるなびに登録されている店舗情報しか取得することはできません。 このため、実際は近くに他のお店があっても、ぐるなびに登録されていないため、ぐるなび API を利用した検索では取得できない、といったことが起きてしまいます。 ここは仕方ないですね。。

なお、喫茶店・カフェ情報取得のための API として、ホットペッパーのグルメサーチ API もあったのですが、こちらは 2018年11月現在でリクエスト URL が http のみとなっていたため、https に対応しているぐるなび API を利用しました。

3-4. Webhook

Webhook は、LINE の BOT の動作を理解するために必要な概念です。

詳細はこちらの記事 (Webhookとは?) や、英語ですが hackernoon の記事 が分かりやすかったと思います。

要約すれば、「何らかのイベントをトリガーとして、予め指定した URL に POST リクエストを投げる」 ことです。 今回は、

  • トリガーとなる「イベント」は 「クライアントからの喫茶案内所への位置情報の投稿」
  • 「指定した URL」は 「Heroku・Python コードで設定したサーバー URL」

に当たります。 後ほど、LINE のアカウント管理画面で「Webhook URL」を設定する箇所が出てきますが、ここで Heroku・Python コードで設定したサーバー URL を指定することとなります。

4. LINE BOT作成手順

 次に、LINE BOT の作成手順を簡単に紹介します。 詳しくは、やはり 公式ドキュメント を参照するのが良いです。 かいつまんで手順を紹介すると、以下の通りとなります。

  1. BOT用の チャネル を作成
  2. チャネル:BOT用の LINEアカウントのようなもの
  3. ボットをホストする サーバー を作成 (今回の例では Heroku)
  4. インターフェースであるチャネルとボット間の通信設定
  5. チャネル -> サーバー上のボット (Webhook URL)
  6. サーバー上のボット -> チャネル (Messaging API 呼び出しのための チャネルアクセストークン)
  7. ボットアプリの実装 (今回の例では Python)

5. 実装の解説

 ここまでで、Heroku サーバー、LINE BOT チャネルの準備ができました。 次はいよいよ、BOT の挙動を司る Python スクリプトの解説をします。

5-1. サンプルスクリプトの確認

まずは、公式が用意してくれている flask-echo という、オウム返しをするスクリプトの実装を見てみます。

# -*- coding: utf-8 -*-

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

from __future__ import unicode_literals

import os
import sys
from argparse import ArgumentParser

from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if channel_access_token is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
parser = WebhookParser(channel_secret)


@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # parse webhook body
    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        abort(400)

    # if event is MessageEvent and message is TextMessage, then echo text
    for event in events:
        if not isinstance(event, MessageEvent):
            continue
        if not isinstance(event.message, TextMessage):
            continue

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=event.message.text)
        )

    return 'OK'


if __name__ == "__main__":
    arg_parser = ArgumentParser(
        usage='Usage: python ' + __file__ + ' [--port <port>] [--help]'
    )
    arg_parser.add_argument('-p', '--port', type=int, default=8000, help='port')
    arg_parser.add_argument('-d', '--debug', default=False, help='debug')
    options = arg_parser.parse_args()

    app.run(debug=options.debug, port=options.port)

app.py

Apache Licence 2.0の表記

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

app.py-1

冒頭数行のコメントは、このサンプルが Apache License 2.0 に従っていることを示すものです。 各種オープンソースソフトウェアのライセンスについては、知らないと損をする6つのライセンスまとめ などで分かり易くまとめてくださっています。

Apache License 2.0はこうしたライセンスの中で最も制限が緩く、改変や商用利用も認められています。 改変などした場合も、Apache License 2.0に従っていることを明記してあればよいので、以降このサンプルを改変したスクリプト中でも、Apache Licence 2.0のコメントは残してあります。

LineBotApi, WebhookParser のインスタンス化

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if channel_access_token is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
parser = WebhookParser(channel_secret)

app.py-2

末尾2行で、LineBotApiWebhookParser のインスタンスを作成しています。 その前準備として、サーバーの環境変数 に登録した、LINE チャネルの Channel Secretアクセストークン を取得しています。 もしこれらをソースコードにべた書きしていると、GitHub などにコードを上げた際に自分の BOT 用の LINE アカウントが他の人からも利用できるようなリスクがある ので、サーバーの環境変数に設定して、os.getenv() 等で取得するようにしましょう。

リクエスト URLと処理関数の紐づけ (Routing)

@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # parse webhook body
    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        abort(400)

    # if event is MessageEvent and message is TextMessage, then echo text
    for event in events:
        if not isinstance(event, MessageEvent):
            continue
        if not isinstance(event.message, TextMessage):
            continue

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=event.message.text)
        )

    return 'OK'

app.py-3

ここで、Flask の route メソッドを使って、/callback という URL と、callback という関数を紐づけています。 なお、Webhook URL は、下図のように LINE のチャネルの設定画面で設定できます。 サンプルが callback になっているのでそのまま https://{heroku_URL}/callback にしていますが、callback でなくてもよいのだと思います。 f:id:kan-getsu:20190502230653p:plain ※私の heroku サーバーの URLが書いてあるので隠してます

ここまでで、Heroku の環境変数、LINE チャネルの設定が完了していれば、Git でサンプルコードを heroku に push することで、テキストメッセージをオウム返しする LINE BOT ができるかと思います。 次から、今回作成した「喫茶案内所」になるように、このサンプルに加えた改変を解説していきます。

5-2. 「喫茶案内所」のスクリプト解説

 喫茶案内所のスクリプトは、以下です (なお、まだまだ作成中で TODO が多く書いてあり、使われていないコードもありますがご了承ください。後ほど GitHub に上げようと思います)。

# -*- coding: utf-8 -*-

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

import json
import os
import sys
import urllib.request
import urllib.parse

from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError, LineBotApiError
)
from linebot.models import (
    CarouselColumn, CarouselTemplate, FollowEvent,
    LocationMessage, MessageEvent, TemplateSendMessage,
    TextMessage, TextSendMessage, UnfollowEvent, URITemplateAction
)

# TODO: 位置情報を送るメニューボタンの配置
# TODO: Webサーバを利用して静的ファイルを相対参照

# get api_key, channel_secret and channel_access_token from environment variable
GNAVI_API_KEY = os.getenv('GNAVI_API_KEY')
CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
BOT_SERVER_URL = os.getenv('BOT_SERVER_URL')
os.environ['http_proxy'] = os.getenv('FIXIE_URL')
os.environ['https_proxy'] = os.getenv('FIXIE_URL')

if GNAVI_API_KEY is None:
    print('Specify GNAVI_API_KEY as environment variable.')
    sys.exit(1)
if CHANNEL_SECRET is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if CHANNEL_ACCESS_TOKEN is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)
if BOT_SERVER_URL is None:
    print('Specify BOT_SERVER_URL as environment variable.')
    sys.exit(1)
if os.getenv('FIXIE_URL') is None:
    print('Specify FIXIE_URL as environment variable.')
    sys.exit(1)

# instantiation
# TODO: インスタンス生成はグローバルでなくファクトリメソッドに移したい
# TODO: グローバルに参照可能な api_callerを作成するか, 個々に作成するかどちらが良いか確認
app = Flask(__name__)
line_bot_api = LineBotApi(CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)

RESTSEARCH_URL = "https://api.gnavi.co.jp/RestSearchAPI/v3/"
DEF_ERR_MESSAGE = """
申し訳ありません、データを取得できませんでした。
少し時間を空けて、もう一度試してみてください。
"""
NO_HIT_ERR_MESSAGE = "お近くにぐるなびに登録されている喫茶店はないようです" + chr(0x100017)
LINK_TEXT = "ぐるなびで見る"
FOLLOWED_RESPONSE = "フォローありがとうございます。位置情報を送っていただくことで、お近くの喫茶店をお伝えします" + chr(0x100059)


def call_restsearch(latitude, longitude):
    query = {
        "keyid": GNAVI_API_KEY,
        "latitude": latitude,
        "longitude": longitude,
        # TODO: category_sを動的に生成
        "category_s": "RSFST18008,RSFST18009,RSFST18010,RSFST18011,RSFST18012"
        # TODO: hit_per_pageや offsetの変更に対応 (e.g., 指定可能にする, 多すぎるときは普通にブラウザに飛ばす, など)
        # TODO: rangeをユーザーアクションによって選択可能にしたい
        # "range": search_range
    }
    params = urllib.parse.urlencode(query, safe=",")
    response = urllib.request.urlopen(RESTSEARCH_URL + "?" + params).read()
    result = json.loads(response)

    if "error" in result:
        if "message" in result:
            raise Exception("{}".format(result["message"]))
        else:
            raise Exception(DEF_ERR_MESSAGE)

    total_hit_count = result.get("total_hit_count", 0)
    if total_hit_count < 1:
        raise Exception(NO_HIT_ERR_MESSAGE)

    return result


@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)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    except LineBotApiError as e:
        app.logger.exception(f'LineBotApiError: {e.status_code} {e.message}', e)
        raise e

    return 'OK'


@handler.add(MessageEvent, message=TextMessage)
def handle_text_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text)
    )


# TODO: ちゃんと例外処理
@handler.add(MessageEvent, message=LocationMessage)
def handle_location_message(event):
    user_lat = event.message.latitude
    user_longit = event.message.longitude

    cafe_search_result = call_restsearch(user_lat, user_longit)
    print("cafe_search_result is: {}".format(cafe_search_result))

    response_json_list = []

    # process result
    for (count, rest) in enumerate(cafe_search_result.get("rest")):
        # TODO: holiday, opentimeで表示を絞りたい
        access = rest.get("access", {})
        access_walk = "徒歩 {}分".format(access.get("walk", ""))
        holiday = "定休日: {}".format(rest.get("holiday", ""))
        image_url = rest.get("image_url", {})
        image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
        if image1 == "":
            image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"
        name = rest.get("name", "")
        opentime = "営業時間: {}".format(rest.get("opentime", ""))
        # pr = rest.get("pr", "")
        # pr_short = pr.get("pr_short", "")
        url = rest.get("url", "")

        result_text = opentime + "\n" + holiday + "\n" + access_walk + "\n"
        if len(result_text) > 60:
            result_text = result_text[:56] + "..."

        result_dict = {
            "thumbnail_image_url": image1,
            "title": name,
            # "text": pr_short + "\n" + opentime + "\n" + holiday + "\n"
            # + access_walk + "\n",
            "text": result_text,
            "actions": {
                "label": "ぐるなびで見る",
                "uri": url
            }
        }
        response_json_list.append(result_dict)
    print("response_json_list is: {}".format(response_json_list))
    columns = [
        CarouselColumn(
            thumbnail_image_url=column["thumbnail_image_url"],
            title=column["title"],
            text=column["text"],
            actions=[
                URITemplateAction(
                    label=column["actions"]["label"],
                    uri=column["actions"]["uri"],
                )
            ]
        )
        for column in response_json_list
    ]
    # TODO: GoogleMapへのリンク実装

    messages = TemplateSendMessage(
        alt_text="喫茶店の情報をお伝えしました",
        template=CarouselTemplate(columns=columns),
    )
    print("messages is: {}".format(messages))

    line_bot_api.reply_message(
        event.reply_token,
        messages=messages
    )


@handler.add(FollowEvent)
def handle_follow(event):
    line_bot_api.reply_message(
        event.reply_token, TextSendMessage(text=FOLLOWED_RESPONSE)
    )


@handler.add(UnfollowEvent)
def handle_unfollow():
    app.logger.info("Got Unfollow event")


if __name__ == "__main__":
    # arg_parser = ArgumentParser(
    #     usage='Usage: python ' + __file__ + ' [--port <port>] [--help]'
    # )
    # arg_parser.add_argument('-p', '--port', type=int, default=8000, help='port')
    # arg_parser.add_argument('-d', '--debug', default=False, help='debug')
    # options = arg_parser.parse_args()
    #
    # app.run(debug=options.debug, port=options.port)
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)

cafe-guide_app.py

環境変数の取得

# get api_key, channel_secret and channel_access_token from environment variable
GNAVI_API_KEY = os.getenv('GNAVI_API_KEY')
CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
BOT_SERVER_URL = os.getenv('BOT_SERVER_URL')
os.environ['http_proxy'] = os.getenv('FIXIE_URL')
os.environ['https_proxy'] = os.getenv('FIXIE_URL')

if GNAVI_API_KEY is None:
    print('Specify GNAVI_API_KEY as environment variable.')
    sys.exit(1)
if CHANNEL_SECRET is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if CHANNEL_ACCESS_TOKEN is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)
if BOT_SERVER_URL is None:
    print('Specify BOT_SERVER_URL as environment variable.')
    sys.exit(1)
if os.getenv('FIXIE_URL') is None:
    print('Specify FIXIE_URL as environment variable.')
    sys.exit(1)

cafe-guide_app.py-1

公式サンプルで環境変数に設定していた 2種に加え、ぐるなび API キー、Heroku サーバーの URL、Heroku のアドオンである、固定 IP を発行する Fixie (Python用のドキュメントはこちら) の URL を設定しています。 Fixie は、セキュリティ対策として、LINE の API を呼び出すサーバーを制限するホワイトリストに固定の IP を指定するため利用しています (ここまでする必要はないと思いますが、折角なので使ってみました)。 Fixie を使わない場合、Heroku の IP は動的に変わってしまうため、ホワイトリストに固定の IP を指定できないためです。

なお、ここでは WebhookParser ではなく、WebhookHandler を利用しています。

ぐるなび API 利用の関数定義

続いて、ぐるなび API を呼び出す関数の定義です。

def call_restsearch(latitude, longitude):
    query = {
        "keyid": GNAVI_API_KEY,
        "latitude": latitude,
        "longitude": longitude,
        # TODO: category_sを動的に生成
        "category_s": "RSFST18008,RSFST18009,RSFST18010,RSFST18011,RSFST18012"
        # TODO: hit_per_pageや offsetの変更に対応 (e.g., 指定可能にする, 多すぎるときは普通にブラウザに飛ばす, など)
        # TODO: rangeをユーザーアクションによって選択可能にしたい
        # "range": search_range
    }
    params = urllib.parse.urlencode(query, safe=",")
    response = urllib.request.urlopen(RESTSEARCH_URL + "?" + params).read()
    result = json.loads(response)

    if "error" in result:
        if "message" in result:
            raise Exception("{}".format(result["message"]))
        else:
            raise Exception(DEF_ERR_MESSAGE)

    total_hit_count = result.get("total_hit_count", 0)
    if total_hit_count < 1:
        raise Exception(NO_HIT_ERR_MESSAGE)

    return result

cafe-guide_app.py-2

位置情報に含まれている緯度、経度情報をこの関数に渡して、辞書形式で API に渡すクエリを作成し、送信して結果を受け取ります。 なお、category_s に複数のパラメータを渡すために、params = urllib.parse.urlencode(query, safe=",") と、urllibsafe オプションを使っています。 request モジュールではエンコードしたくない文字列 (今回で言うと ",") の指定が面倒だったため、あえてこちらを利用しました。

位置情報送信イベントを受け取った際の処理定義

@handler.add(MessageEvent, message=LocationMessage)
def handle_location_message(event):
    user_lat = event.message.latitude
    user_longit = event.message.longitude

    cafe_search_result = call_restsearch(user_lat, user_longit)
    print("cafe_search_result is: {}".format(cafe_search_result))

    response_json_list = []

    # process result
    for (count, rest) in enumerate(cafe_search_result.get("rest")):
        # TODO: holiday, opentimeで表示を絞りたい
        access = rest.get("access", {})
        access_walk = "徒歩 {}分".format(access.get("walk", ""))
        holiday = "定休日: {}".format(rest.get("holiday", ""))
        image_url = rest.get("image_url", {})
        image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
        if image1 == "":
            image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"
        name = rest.get("name", "")
        opentime = "営業時間: {}".format(rest.get("opentime", ""))
        # pr = rest.get("pr", "")
        # pr_short = pr.get("pr_short", "")
        url = rest.get("url", "")

        result_text = opentime + "\n" + holiday + "\n" + access_walk + "\n"
        if len(result_text) > 60:
            result_text = result_text[:56] + "..."

        result_dict = {
            "thumbnail_image_url": image1,
            "title": name,
            # "text": pr_short + "\n" + opentime + "\n" + holiday + "\n"
            # + access_walk + "\n",
            "text": result_text,
            "actions": {
                "label": "ぐるなびで見る",
                "uri": url
            }
        }
        response_json_list.append(result_dict)
    print("response_json_list is: {}".format(response_json_list))
    columns = [
        CarouselColumn(
            thumbnail_image_url=column["thumbnail_image_url"],
            title=column["title"],
            text=column["text"],
            actions=[
                URITemplateAction(
                    label=column["actions"]["label"],
                    uri=column["actions"]["uri"],
                )
            ]
        )
        for column in response_json_list
    ]
    # TODO: GoogleMapへのリンク実装

    messages = TemplateSendMessage(
        alt_text="喫茶店の情報をお伝えしました",
        template=CarouselTemplate(columns=columns),
    )
    print("messages is: {}".format(messages))

    line_bot_api.reply_message(
        event.reply_token,
        messages=messages
    )

cafe-guide_app.py-3

位置情報メッセージを受け取ったらそこから緯度、経度を取り出し、上で定義した call_restsearch に渡して、その結果をカルーセルに順次表示しています。

なお、ここでいくつかはまったポイントがありました。

  • カルーセル利用時、結果一つ当たりのテキスト最大文字数は 60文字まで 動作確認中エラーが出たため、Heroku でデバッグした結果判明しました。 この対応のため、以下処理で文字数の調整をしています。
if len(result_text) > 60:
    result_text = result_text[:56] + "..."
  • 画像が登録されていない結果が含まれるのに画像情報を受け取ろうとするとエラーとなる

店舗によっては、画像がぐるなびに登録されていない場合があります。 その場合に、画像情報を結果として表示しようとすると、エラーとなっていました。 このため、以下処理で画像データがない場合はデフォルトのサムネイルを参照するようにし、エラーを回避しています。

image_url = rest.get("image_url", {})
image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
if image1 == "":
    image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"

6. まとめ

 私は遠回りをしてしまいましたが、Python で LINE BOT を作成する場合、Python の基礎を除いて最低限必要な知識・技能は、以下で十分かと思います。

  • Git を利用できる
  • Heroku でサーバーを起ち上げる
  • Web API の知識 (ドキュメントを読んで利用方法が分かれば十分)
  • LINEの SDK、ドキュメント等を読んで理解できる
  • Heroku などでログを読み、デバッグできる

なお、詳しく説明していない個所もありますが、ぜひ各種ドキュメントを読んで挑戦してみてください。 Heroku や LINE の SDK など、英語が主になってしまいますが、今後開発を続ける上では避けられない部分ではあるので、LINE BOT の作成を機会に取り組んでみるのも良いと思います。

また、LINE BOT は HTML や CSS など、フロントエンドの知識・デザイン性が不要なので、その点ではハードルが低く、何か作ってみたい方にはお勧めです。

本記事が LINE BOT に挑戦しようとされている方の一助となれば幸いです。

7. 画像引用元サイト

喫茶案内所の画像、またデフォルトのサムネイル画像に利用したコーヒーのイラストは、以下サイト様のものを使用させていただきました。

フリーイラスト素材 「趣味で作ったイラストを配るサイト」

また、解説の画像に利用したアイコン等は、以下サイト様から利用させていただきました。

8. 参考記事

 今回の LINE BOT 作成に当たり、主に以下記事を参考にさせていただきました。ありがとうございました!

入門 Python 3

入門 Python 3