単語を入力したらOK/NGを教えてくれるLinebot

#概要
犬に食べさせていいものや、種々の手続き・ルールなど、ある条件下における行動や物事のOK／NGだけをシンプルに知りたいときがあります。
そういうときには、いちいちグーグルで検索するよりもチャットボットに投げて答えが返ってくるほうが使いやすく便利な場合があります。
そこで、Line MessagingAPIとpythonでつくったサーバを連携させて、簡単なOK／NGを教えてくれるLineボットを作りました。

<img width="300" alt="IMG_3C25F35EDAF3-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/f17b379f-b0d3-5813-f04a-a822d35afb01.jpeg">

#環境
##開発・テスト環境
macOS Catalina 10.15.4
python 3.8.0
mecab-ipadic-neologd
ngrok
##本番環境
python 3.8.0
mecab-ipadic
heroku
#事前準備
[形態素解析の結果を返すLinebot（python × MeCab × ngrok）](https://qiita.com/shimajiroxyz/private/5e9bfba87391d9af9756)
[形態素解析の結果を返すLinebot（python × MeCab × heroku）](https://qiita.com/shimajiroxyz/items/5e9bfba87391d9af9756)

を参考に、pythonで作ったflaskサーバにline入力を投げて、mecabの解析結果が返ってくる状態にしておきます。
#FAQデータの作成
Linebotが参照するFAQデータとして、タイトル（単語など）、答え（OK、NG、補足情報など）をそれぞれkey, valueとして文字列で記述するjsonファイルを用意します。
ここでは`botanswer.json`という名前であるとします。
手動コピペやスクレイピングなどを駆使して作ります。
例えば下記を参考にしてください。

[Linebot用に犬の食べ物リストをつくる](https://qiita.com/shimajiroxyz/private/0ac7a0ac98af51acfb09)

#表記ゆれ吸収リストの作成
ユーザの自由入力を上記FAQデータにヒットさせるときに、完全一致のみだとヒット率が低すぎるため、想定されるユーザ入力とFAQタイトルの対応リストを作成します。手動で作ってもいいのですが、ここでは、タイトルの形態素解析結果を利用して、タイトルから構成単語の原型を抽出し、カタカナにしたものと、タイトルを結びつけます。Linebot入力からも同じアルゴリズムで原型のカナ読みを抽出すれば、入力と事前作成した表記ゆれ吸収リストをうまく連携することができます。
なおユーザ入力がひらがな、カタカナ、漢字かな交じりの場合でMeCabの形態素解析結果が異なる場合があり、そのどれにも対応できるように、表記ゆれ吸収リストを作るときも、タイトルの生データ、カタカナ、ひらがなから単語抽出するようにします。


```
$ pip install jaconv
```

```Python
import MeCab
import json
import jaconv

IN_FILE = 'botanswer.json' #FAQタイトルと回答文字列がkey, valueとなっているjsonデータ
OUT_FILE = 'simwords.json'
m=MeCab.Tagger()
myomi = MeCab.Tagger('-Oyomi')

#入力されたものから単語を抽出する
def extract_words(text):
  tokens = m.parse(text).splitlines()[:-1]
  words=[]
  for t in tokens:
    surface, pos = tuple(t.split('\t'))
    pos = pos.split(',')
    #記号、助詞、助動詞は抽出対象としない
    if pos[0] in ['記号','助詞','助動詞'] : continue 
    #原型を取得
    raw = pos[-3]
    #辞書にない単語など、原型が不明の場合はsurfaceを原型とする（ひらがなはカタカナにする)
    if raw != '*': raw = myomi.parse(raw)[:-1] 
    else: raw = jaconv.hira2kata(surface) 
    words.append(raw)
  #入力全体をただカタカナに直したものを追加する
  words.append(jaconv.hira2kata(myomi.parse(text)[:-1]))
  return words

with open(IN_FILE) as f:
  alldata = json.load(f)
#keyだけ抜き出す
titles = [t for t in alldata]

simwords = {}
for t in titles:
  #生文字列から単語を抽出
  words = set(extract_words(t)) 
  #カタカナ読みから単語を抽出
  katayomi = myomi.parse(t)[:-1]
  words |= set(extract_words(katayomi))
  #ひらがな読みから単語を抽出
  hirayomi = jaconv.kata2hira(katayomi)
  words |= set(extract_words(hirayomi))

  for w in words:
    if w not in simwords: simwords[w]=[]
    simwords[w].append(t)

with open(OUT_FILE, 'w') as f:
  json.dump(simwords, f, indent=2, ensure_ascii=False)
```
#回答検索機能の作成
mainから呼ぶための回答検索機能を別ファイルで作っておきます。
対応するbotanswer候補の個数によって戻り値を分岐します。
候補が0個のときは回答が見つからなかった旨と単語をGoogle検索するリンクを結合した文字列を返します（余談ですが、実際に使ってみると、この機能が思いの外便利でした）。
候補が１個のときはbotanswerに登録されている回答内容を文字列で返します。
候補が２個以上のときは、それらを入力するボタンをリストで返します。

```Python:faq.py
import MeCab
import jaconv
import json

BOTANSWER_PATH = 'faq/botanswer.json'
SIMWORDS_PATH = 'faq/simwords.json'

with open(BOTANSWER_PATH) as f:
  botanswer = json.load(f)
with open(SIMWORDS_PATH) as f:
  simwords = json.load(f)

m = MeCab.Tagger()
myomi = MeCab.Tagger('-Oyomi')

#単語抽出用の関数（表記ゆれ吸収リストを作るときの関数と同じ）
def extract_words(text):
  tokens = m.parse(text).splitlines()[:-1]
  words=[]
  for t in tokens:
    surface, pos = tuple(t.split('\t'))
    pos = pos.split(',')
    if pos[0] in ['記号','助詞','助動詞'] : continue 
    raw = pos[-3]
    if raw != '*': raw = myomi.parse(raw)[:-1] 
    else: raw = jaconv.hira2kata(surface) 
    words.append(raw)
  words.append(jaconv.hira2kata(myomi.parse(text)[:-1]))
  return words

#入力文字列から単語を抽出し、対応しそうな回答候補のタイトルのリストを返す
def get_candidates(question):
  #botanswerのkeyに一致するものがあれば、question１つのリストを候補として返す
  if question in botanswer: return [question]
  #botanswerのkeyに一致するものがない場合、candidateをsimwordsから取得して返す。
  candidates = {}
  words = extract_words(question)
  for word in words:
    #wordがsimwordsになければ処理をスキップ
    if word not in simwords: continue
    #適当なアルゴリズムで候補を取得する(現状は候補タイトルのうち登場回数が多いものを選ぶ)
    for w in simwords[word]:
      candidates[w]=candidates.get(w,0)+1
  #candidatesが一つもない場合、空のリストを返す
  if len(candidates) == 0: return []
  #candidatesが１つ以上存在する場合、最も多く登場した単語だけ抜き出す
  max_count = max([v for k,v in candidates.items()])
  candidate_words = [k for k,v in candidates.items() if v == max_count]
  return candidate_words

#回答候補が０個のときの回答文字列
def get_answer_with_zero_candidate(question):
  msg = 'すみません、よくわかりませんでしたが、多分大丈夫だと思います（適当）'
  msg += '\nもしよかったらググってみてください'
  msg += '\nhttps://www.google.com/search?q=妊娠+大丈夫+'+question
  return msg

#questionからanswerを生成する。候補が１個以下のときは文字列、２個以上のときはリストを返す。
def get_answer(question):
  candidates = get_candidates(question)
  #候補がゼロのとき
  if len(candidates) == 0: 
    return get_answer_with_zero_candidate(question)
  #候補が１のとき
  elif len(candidates) == 1:
    msg = botanswer[candidates[0]]
    return msg
  #候補が2以上のとき
  else:
    return candidates
```

#サーバの作成

サーバとなるmain.pyを作ります。一部、[形態素解析の結果を返すLinebot（python × MeCab × heroku）](https://qiita.com/shimajiroxyz/items/5e9bfba87391d9af9756)と重複していますので、不要な作業は適宜省いてください。

またLinebotでのボタンテンプレートの実装のため、下記を参考にしました。

参考：[LINEbotからの返信をボタンテンプレート（と、URIアクション）にする](https://qiita.com/shimayu22/items/c599a94dfa39c6466dfa)

まずライブラリをインストールします。

```
$ pip install Flask
$ pip install line-bot-sdk
```

Lineのチャンネルアクセストークン、チャンネルシークレットを環境変数に登録します。（ソースに直書きしても良いです）

```
$ nano ~/.zshrc
#下記を追記
export YOUR_CHANNEL_SECRET = "xxxx"
export YOUR_CHANNEL_ACCESS_TOKEN = "xxxx"

$ source ~/.zshrc
```
Linebotの公式チュートリアルなどをもとに、Flaskでサーバを作ります。

```Python: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,
  #変更①。ボタンテンプレートモデルの読み込み
  TemplateSendMessage,ButtonsTemplate,MessageAction
)

#変更②。faq管理の自作モジュールをimport
from faq import get_answer

app = Flask(__name__)

#変更③。環境変数からチャンネルアクセストークン、チャンネルシークレットを取得（ソースコードへの直書きが抵抗なければ、直書きしても良い）
import os
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)

#変更④。返信するボタンテンプレートのリストを返す。
#候補の文字列リストが入力
def make_button_template(candidates):
  messages = []
  #candidateは１度に４つまでなので、数が多い場合は分けて、リストにして返す。
  max_loop = len(candidates)//4
  if len(candidates)%4>0: max_loop+=1
  #連続で一度に送れるメッセージ数は5まで
  max_loop = max(5, max_loop)
  for i in range(max_loop):
    actions = []
    for c in candidates[i*4:i*4+4]:
      msg = MessageAction( label = c, text = c )
      actions.append(msg)
    message_template = TemplateSendMessage(
      alt_text="にゃーん",
      template=ButtonsTemplate(
        text="近いものを選んでください",
        #title="タイトルですよ",
        actions=actions
      )
    )
    messages.append(message_template)
  
  return messages

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

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

  # handle webhook body
  try:
    handler.handle(body, signature)
  except InvalidSignatureError:
    print("Invalid signature. Please check your channel access token/channel secret.")
    abort(400)

  return 'OK'


@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
  #変更⑤。入力文字列からcandidatesを取得し、得られたcandidateの個数に応じて返答を分岐する。
  given_msg = event.message.text
  return_msg = get_answer(given_msg)
  if type(return_msg) is str:
    line_bot_api.reply_message(
      event.reply_token,
      TextSendMessage(text=return_msg)
    )
  #listの場合、選択肢のボタンを返す
  elif type(return_msg) is list:
    messages = make_button_template(return_msg)
    line_bot_api.reply_message(
      event.reply_token,
      messages
    )


if __name__ == "__main__":
    #外部からの接続を受け付けられるようにした上で、port番号を指定して、立ち上げ
#    app.run()
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```

##サーバの起動
フォルダ構成を下記のようにします。

```
- .
  - main.py
  - faq.py
  - faq
    - botanswer.json
    - simwords.json
```

herokuやngrokを使って、Linebotの入力をmain.pyで受けられるようにし、想定した応答が返ってくるか確認します。

以下では、例として、ngrokを使う方法を書いておきます。

親フォルダ直下（main.pyと同じ階層）でngrokを立ち上げます。
その後表示された`https://xxxx.ngrok.io`のリンクをMessagingAPIのアクセス先に設定しておきます。

```
$ ngrok http 5000
```

ターミナルを別途立ち上げ、main.pyを起動します。

```
$ python main.py

* Serving Flask app "main" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
```

Lineアプリからボットに入力を行い、回答候補が0個、１個、２個以上のときにそれぞれ想定された応答が返ってくるか確認してください。

#補足
##herokuのスリープ防止
（herokuを使う場合）herokuは30分アクセスがないとsleepしてしまい、それ以降の最初の応答がとても遅くなってしまいます。そこで、addonを追加し、herokuを定期的にcurlで叩いてを飛ばしてsleepを防止するようにしました。

[herokuを24時間稼働させる設定 | エンジニアになりたい肉体労働者](https://yukitoku-sw.hatenablog.com/entry/2020/02/04/225151)



参考
[Herokuでサービスを動かすときに設定することまとめ](https://qiita.com/tonishi/items/397099be69aa33f0afee)
##一つのWebアプリで複数チャンネルからの受付
Linebotで2種類のチャンネルを作りたいときに、herokuのappをそれぞれ作っていると、月ごとの稼働時間を2倍消費してしまい、24時間稼働させられなくなります。
そこで、main.pyを編集し、herokuのアプリは一つのまま、異なるチャネルからのリクエストを受け付けられるようにしました。

[OK／NGチャットボットを複数のLinebotチャンネルに対応させる](https://qiita.com/shimajiroxyz/private/51701df15141e2f255f5)

#追記、変更履歴
* 2020年9月22日、`make_button_template`関数におけるmax_loopの上限数を追記