位置情報から最寄りのWi-Fiスポットを調べるLINEbotを作った

# ◆はじめに
* LINEのMessaging APIが面白そうだったので、自分でも少しかじってみました。
* 最近は官公庁や自治体が様々なオープンデータを公開しているので、今回は携帯の通信容量が無くなった時のために最寄りWi-Fiスポットを探してくれるBotを作りました。
    * [東京都オープンデータカタログサイト](https://portal.data.metro.tokyo.lg.jp/)

# ◆作ったもの
* 今回作ったものはこの3つです。（これら3つのファイルは同じディレクトリに保存します）
    * ① app.py
        * Botを動かすプログラム本体です。
    * ② .env
        * LINEの認証情報を入れておくファイルです。
    * ③ Wi-FiスポットのCSVファイル
        * オープンデータから取得して加工したデータです。各スポットの緯度経度情報が含まれます。

# ◆必要なもの
* ① Pythonの実行環境
    * Python3系が動けばWindows/Mac/Linuxどれでも動くと思います。
        * 筆者の環境はWindows11、Python3.9.9です。
* ② LINEアカウント
    * LINE Developersの登録に必要です。
* ③ Botのデプロイ先
    * LINEとWebhook通信するため、外部と通信できるサーバー環境が必要です。
    * 今回は[ngrok](https://ngrok.com/)を使ってローカルのサーバーを外部に公開して動かしました。
    * Heroku等のPaaSを使うのもアリです。

# ◆作り方

## 1. LINE Developersに登録し、プロバイダとチャネルを作成する
* 下記サイトを参考にLINE Developersにログインし、プロバイダーとチャネルを作成します。
    * [Messaging APIを始めよう | LINE Developers](https://developers.line.biz/ja/docs/messaging-api/getting-started/)
* チャネルは「Messaging API」を選択します。
![create_channel](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/28e0f0ee-448e-2b4f-d9c1-7ff331be1ff4.png)

## 2. Channel access tokenとChannel secretを取得する
* チャネルを作成したらChannel access tokenとChannel secretを取得します。
    * Channel access tokenはチャネルページの「Messaging API」タブにある「Channel access token」から取得します。
        * 「Issue」をクリックするとトークンが生成されます。
![channel_token](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/3427d763-96c6-9f48-d840-65b7e24c2c69.png)

    * Channel secretはチャネルページの「Basic settings」タブにある「Channel secret」に表示されています。
![channel_secret](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/62fa85ce-5cda-bc2b-c702-98473d101e6b.png)

## 3. 必要なライブラリをインストールする
* LINEbotを動かすのに必要なPythonライブラリをpipコマンドでインストールします。
```
$ pip install python-dotenv
$ pip install flask
$ pip install geopy
$ pip install line-bot-sdk
```

## 4. CSVデータとプログラムを作成する

### ①プログラム本体
* 下記コードをapp.pyとして保存します。
```python:app.py
import os
import sys
import csv
from dotenv import load_dotenv
from flask import Flask, request, abort
from linebot import (LineBotApi, WebhookHandler)
from linebot.exceptions import (InvalidSignatureError)
from linebot.models import (MessageEvent, TextSendMessage)
from geopy.distance import geodesic

# .env読み込み
load_dotenv()

# Messaging API 認証情報
line_bot_api = LineBotApi(os.environ.get('CHANNEL_ACCESS_TOKEN'))
handler = WebhookHandler(os.environ.get('CHANNEL_SECRET'))

# CSV読み込み(最初のコマンドライン引数にCSVのパスを指定する)
ap_list = []
with open(sys.argv[1], 'r', encoding = 'utf8') as file_p:
	csv_reader = csv.reader(file_p)
	csv_header = next(csv_reader)
	for row in csv_reader:
		ap_list.append({
			'id': row[0],
			'name': row[1],
			'address': row[2],
			'ssid': row[3],
			'latitude': row[4],
			'longitude': row[5]
		})

app = Flask(__name__)

# WebhookのPOST先
@app.route('/callback', methods = ['POST'])
def callback():
	signature = request.headers['X-Line-Signature']
	body = request.get_data(as_text = True)
	try:
		handler.handle(body, signature)
	except InvalidSignatureError:
		print('Invalid signature. Check your channel access token or channel secret.')
		abort(400)
	
	return 'OK'

# メッセージの返信処理
@handler.add(MessageEvent)
def handler_message(event):
	if event.type == 'message':
		if event.message.type == 'location':
			# 最寄りWi-Fiスポットを検索
			ap_distance = {}
			for ap_data in ap_list:
				start_point = (event.message.latitude, event.message.longitude)
				# CSVの空白以外(「-」以外)のデータを使う
				if ap_data['latitude'] != '-' and ap_data['longitude'] != '-':
					dest_point = (float(ap_data['latitude']), float(ap_data['longitude']))
					ap_distance[ap_data['id']] = geodesic(start_point, dest_point).km
				else:
					continue
			min_distance_idx = min(ap_distance, key = ap_distance.get)
			# ルート地図のURL生成
			route_url = 'https://www.openstreetmap.org/directions?engine=fossgis_osrm_foot&route=' + str(event.message.latitude) + '%2C' + str(event.message.longitude) + '%3B' + ap_list[int(min_distance_idx)]['latitude'] + '%2C' + ap_list[int(min_distance_idx)]['longitude'] + '#map=16/' + str(event.message.latitude) + '/' + str(event.message.longitude)
			# 返信するメッセージを生成
			reply_message = '最寄りのWi-Fi接続スポットは「' + ap_list[int(min_distance_idx)]['name'] + '」です。\n'
			reply_message = reply_message  + '◆住所:\n' + ap_list[int(min_distance_idx)]['address'] + '\n'
			reply_message = reply_message + '◆SSID:\n' + ap_list[int(min_distance_idx)]['ssid'] + '\n'
			reply_message = reply_message + '◆ルート:\n' + route_url
			line_bot_api.reply_message(event.reply_token, TextSendMessage(reply_message))

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

```

#### <1> CSVファイル読み込み
* csvモジュールを使ってWi-Fiスポットの位置情報データを読み込みます。
    * next(csv_reader)でヘッダ部分を飛ばします。
```python
ap_list = []
with open(sys.argv[1], 'r', encoding = 'utf8') as file_p:
	csv_reader = csv.reader(file_p)
	csv_header = next(csv_reader)
	for row in csv_reader:
		ap_list.append({
			'id': row[0],
			'name': row[1],
			'address': row[2],
			'ssid': row[3],
			'latitude': row[4],
			'longitude': row[5]
		})
```

#### <2> Webhookの処理
* Messaging APIのWebhook先パスを設定し、handler_messageメソッドの中でWebhookに対して返す処理を記述します。
```python
# WebhookのPOST先
@app.route('/callback', methods = ['POST'])
def callback():
	signature = request.headers['X-Line-Signature']
	body = request.get_data(as_text = True)
	try:
		handler.handle(body, signature)
	except InvalidSignatureError:
		print('Invalid signature. Check your channel access token or channel secret.')
		abort(400)
	
	return 'OK'

# メッセージの返信処理
@handler.add(MessageEvent)
def handler_message(event):
...
```

#### <3> 最寄りWi-Fiスポットの検索
* LINEで送られた位置情報から緯度経度を取得し、geopyライブラリを使い距離を計算して最寄りWi-Fiスポットを検索します。
```python
# 最寄りWi-Fiスポットを検索
ap_distance = {}
for ap_data in ap_list:
	start_point = (event.message.latitude, event.message.longitude)
	# CSVの空白以外(「-」以外)のデータを使う
	if ap_data['latitude'] != '-' and ap_data['longitude'] != '-':
		dest_point = (float(ap_data['latitude']), float(ap_data['longitude']))
		ap_distance[ap_data['id']] = geodesic(start_point, dest_point).km
	else:
		continue
min_distance_idx = min(ap_distance, key = ap_distance.get)
```

#### <4> メッセージを返す
* 現在位置の緯度経度と最寄りWi-Fiスポットの緯度経度をもとにルートマップのURLを生成します。
    * 地図の表示には[OpenStreetMap](https://openstreetmap.jp/)を使います。
    * URLパラメータを設定することでルート情報のURLを動的に生成します。
```python
# ルート地図のURL生成
route_url = 'https://www.openstreetmap.org/directions?engine=fossgis_osrm_foot&route=' + str(event.message.latitude) + '%2C' + str(event.message.longitude) + '%3B' + ap_list[int(min_distance_idx)]['latitude'] + '%2C' + ap_list[int(min_distance_idx)]['longitude'] + '#map=16/' + str(event.message.latitude) + '/' + str(event.message.longitude)
# 返信するメッセージを生成
reply_message = '最寄りのWi-Fi接続スポットは「' + ap_list[int(min_distance_idx)]['name'] + '」です。\n'
reply_message = reply_message  + '◆住所:\n' + ap_list[int(min_distance_idx)]['address'] + '\n'
reply_message = reply_message + '◆SSID:\n' + ap_list[int(min_distance_idx)]['ssid'] + '\n'
reply_message = reply_message + '◆ルート:\n' + route_url
line_bot_api.reply_message(event.reply_token, TextSendMessage(reply_message))
```

### ②.envファイル
* 先ほど取得したChannel access tokenとChannel secretを.envファイルに記述します。
```.env
CHANNEL_ACCESS_TOKEN='<取得したChannel access token>'
CHANNEL_SECRET='<取得したChannel secret>'
```

### ③CSVデータ
* Wi-Fiスポットの位置情報CSVファイルを下記6つのヘッダを先頭にしたフォーマットで作成します。(空白データは「-」で埋めます)
    * id
        * 各レコードの項番です。0からスタートとします。
    * name
        * Wi-Fiスポットの名前です。
    * address
        * Wi-Fiスポットの住所です。
    * ssid
        * Wi-Fiスポットのアクセスポイント名です。
    * latitude
        * Wi-Fiスポットの緯度です。
    * longitude
        * Wi-Fiスポットの経度です。
* ファイルのイメージ↓
![csv_sample](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/27860389-3591-b157-a9c6-ad5033b536b9.png)

# ◆動かしてみる

## 1. Pythonプログラムを実行
* ターミナルでapp.pyを実行します。
    * 引数にWi-Fiスポットの位置情報データのCSVファイル名を指定します。
* 5000番ポートでサーバーが起動します。
```
$ python app.py ./<Wi-FiスポットのCSVファイル名>.csv
```

## 2. ngrokで外部にポートを通す
* 別ウィンドウでターミナルをもう1つ開き、下記コマンドで5000番ポートを外部に公開します。
    * ターミナル上の「Forwarding」にhttpsから始まるURLが表示されるので、それをコピーします。
```
$ ngrok http 5000
```

## 3. Webhookを設定する
* チャネルページの「Messaging API」タブにある「Webhook settings」に「先ほどコピーしたngrokのURL」+「/callback」を入力し、保存します。
    * 「Verify」ボタンでngrokのURLへのPOSTリクエストをテストできます。
* 「Use webhook」もONにします。
![webhook_setting](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/1bd5b21b-7385-be66-9226-305da1fcbdb2.png)

## 4. LINEで位置情報を送る
* チャネルページの「Messaging API」タブにあるQRコードでチャネルと友達になり、位置情報を送ります。
    * 最寄りのWi-Fiスポット情報とその場所までのルート地図URLが返ってきます。
![sample_1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/54e32988-8463-57c1-7cdd-3195e81a363d.png)
    * リンクをクリックすると最寄りWi-Fiスポットまでのルートが表示されます。
![sample_2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181632/c2416f79-4855-d13c-1f8f-eab7acc3e198.png)

以上です。
Botで遊んでみると結構いろいろな場所にWi-Fiスポットってありますね。
自治体の提供しているもの以外に、お店に置いてあるWi-Fiスポットの情報も入ると良さそうです。

# ◆参考ページ
* [東京都オープンデータカタログサイト](https://portal.data.metro.tokyo.lg.jp/)
    * 「公衆無線LANアクセスポイント一覧」などのキーワードで検索
* [Messaging APIを始めよう | LINE Developers](https://developers.line.biz/ja/docs/messaging-api/getting-started/)
* [GitHub - line/line-bot-sdk-python: LINE Messaging API SDK for Python](https://github.com/line/line-bot-sdk-python)
* [Getting Started | ngrok Documentation](https://ngrok.com/docs/getting-started)
* [python-dotenv · PyPI](https://pypi.org/project/python-dotenv/#getting-started)
* [OpenStreetMap.orgでの経路探索実装 | OpenStreetMap Blog](https://blog.openstreetmap.org/2015/02/17/openstreetmap-org%E3%81%A7%E3%81%AE%E7%B5%8C%E8%B7%AF%E6%8E%A2%E7%B4%A2%E5%AE%9F%E8%A3%85/?lang=ja)