Python(Django)でアニメ情報を教えてくれるLINE Botを作成してみた

最近チーム開発でLINE Botを作成したのと偶然Qiitaで見かけた[LINE BOTを使ってアニメ情報を教えてくれるボットを作ってみた](https://qiita.com/kiiro/items/eadcf724173001df43ee)からインスピレーションを受けたので作ることにしました。  
今回作るのは今期放送しているアニメ、来期放送予定アニメ、前期アニメを教えてくれるボットです。下に完成デモのスクショを貼っておきます。
![img02.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3039414/b8bcbabc-247b-13eb-d38c-7052f3d0c065.jpeg)
![img03.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3039414/3c085c47-6524-6948-6399-c6048ebabaea.jpeg)
![img04.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3039414/dcd37b60-7bf5-1e1d-d841-1eedb58bbd01.jpeg)

  
一応対象読者としては、
* Djangoをある程度理解しているが作りたいものがない人
* おうむ返し以外のLINE Botを作ってみたい人
* ngrokの簡単な使い方をわかる人

を想定しています。順番としては、
1. LINE Botの動作が確認しやすいオウム返しの機能の作成で肩慣らし
1. APIを叩いてアニメ情報を取得・返信の機能の作成で目的のBotを作る

といった感じでチュートリアル形式で進めていこうと思います。チュートリアルで進めるにあたって

* [DjangoでLINE BOTを作るメモ](https://qiita.com/njn0te/items/d717840dc2addeae6439)

をベースにしていきますが、環境設定やフォルダ構成など微妙に違う点がありますので見落とさずに読んでいただければと思います。では早速取り掛かっていきましょう。

# LINE側の設定
## チャネルの作成
下記URLからLINE Developersにログインしてください。
https://developers.line.biz/ja/

プロバイダーの作成や、LINE Messageing API のチャネルを発行します。
新しくチャネルが作られたら、チャネル基本設定の「LINE Official Account Manager」というボタンを押します。すると、新しくタブが開きます。
## LINE Official Account Manager の設定
応答設定をクリックして、下記のように設定します。
- あいさつメッセージ：オン
- 応答メッセージ：オン
- Webhook：オン
![img01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3039414/33c24562-402c-87dd-f3c9-6123524579ed.png)

## アクセストークンの発行
こちらも参考記事の通りにLINE Developers のページに戻り、MessagingAPI設定の一番下の、チャネルアクセストークンから、アクセストークンを取得します。

# Django側の実装
## ディレクトリ作成
```
$ mkdir animentroduce
$ cd animentroduce
```

# 仮想環境を構築してDjangoとその他ライブラリを構築
Python3.6以上であれば、Pythonコマンドで仮想環境を作ることができます。今回はvenvという仮想環境を作ります。Djangoの3.2をインストールするのはLTSであるためです。
```
$ python -m venv venv
$ source venv/bin/activate
$ pip install Django==3.2
$ pip install django-environ
$ pip install requests
```
※Windowsの方は`source venv/bin/activate`ではなく、`source venv/Scripts/activate`で実行してみてください。

## プロジェクト作成
今回こちらのようなフォルダ構成にしていきたいと思います。
```
animentroduce
│  .env
│  .gitignore
│  db.sqlite3
│  manage.py
│  README.md
│  requirements.txt
│
├─config
│  │  asgi.py
│  │  settings.py
│  │  urls.py
│  │  wsgi.py
│  │  __init__.py
│  │
│  └─__pycache__
│          (略)
│
└─linebot
    │  admin.py
    │  apps.py
    │  line_message.py
    │  message_create.py
    │  models.py
    │  tests.py
    │  urls.py
    │  views.py
    │  __init__.py
    │
    ├─migrations
    │  │  __init__.py
    │  │
    │  └─__pycache__
    │          __init__.cpython-37.pyc
    │
    └─__pycache__
            (略)
```
ディレクトリとdjangoプロジェクトを作成していきます。
```
$ cd animentroduce
$ django-admin startproject config .
$ python manage.py startapp linebot
$ python manage.py migrate
```

次に`.env`ファイルを作成し、先ほど発行したアクセストークンを記述します。
```animentroduce/.env
ACCESSTOKEN='発行したアクセストークン'
```

ついでに`settings.py`も追加・変更を加えます。差分だけ示しておきます。
```python:config/settings.py
import os, environ #追加

env = environ.Env() # 追加
env.read_env(os.path.join(BASE_DIR, '.env')) # 追加

ACCESSTOKEN = env('ACCESSTOKEN') #追加

ALLOWED_HOSTS = ["127.0.0.1", ".ngrok.io"] # 変更

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'linebot', #追加
]

LANGUAGE_CODE = 'ja' #変更

TIME_ZONE = 'Asia/Tokyo' #変更
```

## アプリ側の`urls.py`を作成
続いてアプリ側に`urls.py`を作成していきます。
```python:linebot/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='callback'),
]
```

## プロジェクト側の`urls.py`を作成
続いてアプリ側に`urls.py`を作成していきます。
```python:config/urls.py
from django.contrib import admin
from django.urls import include, path # 追加部分

urlpatterns = [
    path('linebot/', include('linebot.urls')), # 追加部分
    path('admin/', admin.site.urls),
]
```

## アプリ側に`LineMessage`クラスの作成
views.pyを編集する前に、新しく`LineMessage`クラスを作成し、LINE Messaging APIにHTTPリクエストを送りまるための、replyメソッドを作成します。
参考サイトとは微妙に違うのでご注意を。
```python:linebot/line_message.py
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
import urllib.request
import json

REPLY_ENDPOINT_URL = "https://api.line.me/v2/bot/message/reply"
HEADER = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + settings.ACCESSTOKEN
}

class LineMessage():
    def __init__(self, messages):
        self.messages = messages

    def reply(self, reply_token):
        body = {
            'replyToken': reply_token,
            'messages': self.messages
        }
        print(body)
        req = urllib.request.Request(REPLY_ENDPOINT_URL, json.dumps(body).encode(), HEADER)
        try:
            with urllib.request.urlopen(req) as res:
                body = res.read()
        except urllib.error.HTTPError as err:
            print(err)
        except urllib.error.URLError as err:
            print(err.reason)
```
また、ここでメソッド`reply`に渡されている`message`は以下のような形式を想定しています。
```
{
  type: "text",
  text: "hogehoge"
}
```
今回は`text`タイプのメッセージしか送受信しない想定なので細かいことは割愛します。

## メッセージ作成用の`message_create.py`を作成
LINEにメッセージを送るためのメッセージ作成用のpythonファイルを作成します。
```python:linebot/message_create.py
def create_single_text_message(message):
    if message == 'ありがとう':
        message = 'どういたしまして！'
    test_message = [
                {
                    'type': 'text',
                    'text': message
                }
            ]
    return test_message
```
参考サイトでは「ありがとう」という文字が来たときにだけ「どういたしまして！」と返す仕様にしていますが、今はおうむ返しのLINE Botが動くように作るので細かいことは言いません。今回作るアニメ紹介Botは主にこのファイルをいじるのですが後でしっかり説明するので心配しないでください。

## アプリ側の`views.py`を編集
```python:linebot/views.py
from django.shortcuts import render
from django.http import HttpResponse
import json
from django.views.decorators.csrf import csrf_exempt

from .message_create import create_single_text_message
from .line_message import LineMessage

@csrf_exempt
def index(request):
    if request.method == 'GET':
        return HttpResponse("今期のアニメ一覧紹介Botだよ。")
    if request.method == 'POST':
        request = json.loads(request.body.decode('utf-8'))
        data = request['events'][0]
        message = data['message']
        reply_token = data['replyToken']
        line_message = LineMessage(create_single_text_message(message['text']))
        line_message.reply(reply_token)
        return HttpResponse("ok")
```
ひとまずオウム返しする機能は作成できました。

# ngrokでLINEと連携
## ngrokの設定
[参考サイト](https://qiita.com/njn0te/items/d717840dc2addeae6439#ngrok%E3%81%AE%E8%A8%AD%E5%AE%9A)通りに設定します。

## Webhook URLの設定
[参考サイト](https://qiita.com/njn0te/items/d717840dc2addeae6439#webhook-url%E3%81%AE%E8%A8%AD%E5%AE%9A)通りに設定します。※設定するURLは`https://*********.ngrok.io/linebot/`です。*はngrokで生成されたサブドメインになります。

## `ALLOWED_HOST`の編集
参考サイトではこのタイミングで編集していますが私の記事では最初に`settings.py`の設定の時に編集しているので特にやることなし。

## ひとまず完成
これで準備は整いました。サーバーを起動して、[参考サイト](https://qiita.com/njn0te/items/d717840dc2addeae6439#%E5%AE%8C%E6%88%90)のように返信されればひとまず完成です。
```
python manage.py runserver
```

# アニメ紹介機能の作成
## やりたいこととロジック
アニメ紹介Botでは、『今期アニメ』と入力すると今期アニメの一覧が返ってきたり、『今期アニメ-アニメ名』と入力するとアニメの詳細情報が返ってきたりするような機能を作っていきたいと思います。そこで対応するコマンドを以下のように定めます。
```
今期アニメ
今期アニメ-アニメ名
来期アニメ
来期アニメ-アニメ名
前期アニメ
前期アニメ-アニメ名
ヘルプ
```
それ以外の入力は弾くようにします。次にロジックを解説します。
1. 入力された文字を-(ハイフン)で分割する。要素数が1または2の配列ができると仮定する。ここでは第一引数、第二引数と呼ぶことにする。
1. 第一引数が『今期アニメ』である場合
1.第二引数が空でない場合
APIリクエストを送り、返ってきたjsonデータから第二引数と同じ名前のアニメの情報を返す。
2.第二引数が空である場合
APIリクエストを送り、返ってきたjsonデータを変換しタイトルの一覧で返す。
2. 第一引数が『来期アニメ』である場合
1.第二引数が空でない場合
APIリクエストを送り、返ってきたjsonデータから第二引数と同じ名前のアニメの情報を返す。
2.第二引数が空である場合
APIリクエストを送り、返ってきたjsonデータを変換しタイトルの一覧で返す。
3. 第一引数が『前期アニメ』である場合
1.第二引数が空でない場合
APIリクエストを送り、返ってきたjsonデータから第二引数と同じ名前のアニメの情報を返す。
2.第二引数が空である場合
APIリクエストを送り、返ってきたjsonデータを変換しタイトルの一覧で返す。
4. 第一引数が『ヘルプ』である場合
このBotの使い方を説明する文章を返す。
5. 第一引数がいずれの単語にも該当しない場合
エラーメッセージを返す。

このロジックを実装していきます。

## メッセージ作成用の`message_create.py`を編集
```python:linebot/message_create.py
from datetime import datetime
from math import ceil
import requests
import json
import pprint

def create_single_text_message(message):
    cool = {1: '冬アニメ', 2: '春アニメ', 3: '夏アニメ', 4: '秋アニメ'}
    msg_array = message.split('-')  # メッセージを分割 例：今期アニメ または 今期アニメ-アニメ名
    first_argument = msg_array[0]  # 今期アニメ 、来期アニメ 、前期アニメ 、ヘルプ
    try:
        second_argument = msg_array[1]  # アニメ名
    except IndexError:
        second_argument = None

    if (first_argument == '今期アニメ'):
        year = datetime.now().year  # 今年の年を取得
        course = ceil(datetime.now().month / 3) # 今クールの値を取得 例：1~3月なら1、4~6月なら2、7~9月なら3、10~12月なら4
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == '来期アニメ'):
        year, course = checkcourse(datetime.now().year, ceil(datetime.now().month / 3)+1)
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == '前期アニメ'):
        year, course = checkcourse(datetime.now().year, ceil(datetime.now().month / 3)-1)
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == 'ヘルプ'):
        message = 'このBotはアニメの情報を提供するよ。ボタンの使い方を説明するね。\n' + '『今期アニメ』\n『来期アニメ』\n『前期アニメ』\nそれぞれの時期に合ったアニメ情報が得られるよ。\n' + '『ヘルプ』\nこのメッセージを表示できるよ。'
    else:
        message = 'なんか君不正操作しようとしてない？'

    test_message = [
                {
                    'type': 'text',
                    'text': message
                }
            ]
    return test_message
```
いきなり長いコードを見せられて「何これ？」と思うかもしれませんが一つ一つ解説していきます。まずこちらを見ていきます。
```
    cool = {1: '冬アニメ', 2: '春アニメ', 3: '夏アニメ', 4: '秋アニメ'}
    msg_array = message.split('-')  # メッセージを分割 例：今期アニメ または 今期アニメ-アニメ名
    first_argument = msg_array[0]  # 今期アニメ 、来期アニメ 、前期アニメ 、ヘルプ
    try:
        second_argument = msg_array[1]  # アニメ名
    except IndexError:
        second_argument = None
```
最初に辞書配列でクールを設定しておきます。またsplitメソッドで入力されてきた`message`を分割し、作成された配列を変数`msg_array`に代入します。第一引数や第二引数は`first_argument`、`second_argument`とします。ここで`try`~`except`構文を使用しているのは第二引数がなかったとき`IndexError`が起こってしまうので、なかったときには`None`を代入して空扱いにするからです。次に『今期アニメ』のロジック部分を見ていきます。
```
    if (first_argument == '今期アニメ'):
        year = datetime.now().year  # 今年の年を取得
        course = ceil(datetime.now().month / 3) # 今クールの値を取得 例：1~3月なら1、4~6月なら2、7~9月なら3、10~12月なら4
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
```
まずAPIリクエストを送るために何年のどのクールなのかを設定する必要があります。年は`datetime`ライブラリの`datetime`メソッドで取得し、クールは`math`ライブラリの`ceil`メソッドを使って現在の月から算出します。これらの変数を新しく定義するcallapi関数に代入します。第二引数のロジックは上で解説するので読めばわかると思います。肝心のcallapi関数についてみていきたいと思います。
```python:linebot/message_create.py
def callapi(year, course):
    # 取得先のURLに年とクールを指定して、APIを呼び出す
    API_URL = f'https://api.moemoe.tokyo/anime/v1/master/{year}/{course}'
    res = requests.get(API_URL)
    data = json.loads(res.text)
    # pprint.pprint(data) # デバッグ用
    return data
```
この関数は年とクールを引数にとって、アニメ情報を格納した辞書配列を返す関数です。
先ほど設定した`year`と`course`をURLに埋め込みます。requestsライブラリのgetメソッドで得られたjson形式のデータを`data`という変数に渡します。コメントアウトしているのですがpprintメソッドを使うとこのような感じでjson形式のデータが返ってくることが確認できます。
```
[{'city_code': 0,
  'city_name': '',
  'cours_id': 0,
  'created_at': '2022-12-31T21:36:21Z',
  'id': 1663,
  'product_companies': 'ファンワークス',
  'public_url': 'https://www.sanrio.co.jp/characters/retsuko/',
  'sequel': 5,
  'sex': 0,
  'title': 'アグレッシブ烈子 シーズン5',
  'title_en': '',
  'title_short1': 'アグレッシブ烈子',
  'title_short2': '',
  'title_short3': '',
  'twitter_account': 'retsuko_sanrio',
  'twitter_hash_tag': 'アグレッシブ烈子',
  'updated_at': '2022-12-31T21:36:21Z'},
 {'city_code': 0,
  'city_name': '',
  'cours_id': 0,
  'created_at': '2022-12-31T21:36:21Z',
  'id': 1664,
  'product_companies': 'CONNECT',
  'public_url': 'https://ayakashitriangle-anime.com/',
  'sequel': 0,
  'sex': 0,
  'title': 'あやかしトライアングル',
  'title_en': '',
  'title_short1': 'あやかしトライアングル',
  'title_short2': '',
  'title_short3': '',
  'twitter_account': 'ayakashi_anime',
  'twitter_hash_tag': 'あやトラ',
  'updated_at': '2022-12-31T21:36:21Z'}]
```
次に『来期アニメ』のロジック部分を見ていきます。基本的には『今期アニメ』のロジックと変わりません。違うのは年とクールの部分だけです。来期アニメは次のクールになるので値としては1増えます。この時1~4の範囲を超えてはいけないので5になってしまっては困りますそこでcheckcourseという関数を新しく定義します。
```
    elif (first_argument == '来期アニメ'):
        year, course = checkcourse(datetime.now().year, ceil(datetime.now().month / 3)+1)
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
```
```python:linebot/message_create.py
def checkcourse(year, course):
    # courseの値が5になったら、次の年の1期になるので、年を1増やす。一方で、courseの値が0になったら、前の年の4期になるので、年を1減らす。
    if (course == 5):
        year += 1
        course = 1
    elif (course == 0):
        year -= 1
        course = 4
    return year, course
```
コードのコメントに書いてある通りです。


## 完成
これでアニメ情報を教えてくれるBotの作成は完了です。最後に`message_create.py`の全体コードを貼っておきます。
```python:linebot/message_create.py
from datetime import datetime
from math import ceil
import requests
import json
import pprint

def create_single_text_message(message):
    cool = {1: '冬アニメ', 2: '春アニメ', 3: '夏アニメ', 4: '秋アニメ'}
    msg_array = message.split('-')  # メッセージを分割 例：今期アニメ または 今期アニメ-アニメ名
    first_argument = msg_array[0]  # 今期アニメ 、来期アニメ 、前期アニメ 、ヘルプ
    try:
        second_argument = msg_array[1]  # アニメ名
    except IndexError:
        second_argument = None

    if (first_argument == '今期アニメ'):
        year = datetime.now().year  # 今年の年を取得
        course = ceil(datetime.now().month / 3) # 今クールの値を取得 例：1~3月なら1、4~6月なら2、7~9月なら3、10~12月なら4
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == '来期アニメ'):
        year, course = checkcourse(datetime.now().year, ceil(datetime.now().month / 3)+1)
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == '前期アニメ'):
        year, course = checkcourse(datetime.now().year, ceil(datetime.now().month / 3)-1)
        data = callapi(year, course)
        if (second_argument != None):
            for i in range(len(data)):
                if(data[i]['title'] == second_argument):
                    message = data[i]['title'] + '\n' + data[i]['public_url'] + '\n' + str(year) + "年" +cool[course]
        else:
            message = ''
            for i in range(len(data)):
                message += data[i]['title'] + '\n'
    elif (first_argument == 'ヘルプ'):
        message = 'このBotはアニメの情報を提供するよ。ボタンの使い方を説明するね。\n' + '『今期アニメ』\n『来期アニメ』\n『前期アニメ』\nそれぞれの時期に合ったアニメ情報が得られるよ。\n' + '『ヘルプ』\nこのメッセージを表示できるよ。'
    else:
        message = 'なんか君不正操作しようとしてない？'

    test_message = [
                {
                    'type': 'text',
                    'text': message
                }
            ]
    return test_message

def callapi(year, course):
    # 取得先のURLに年とクールを指定して、APIを呼び出す
    API_URL = f'https://api.moemoe.tokyo/anime/v1/master/{year}/{course}'
    res = requests.get(API_URL)
    data = json.loads(res.text)
    # pprint.pprint(data[0:2]) # デバッグ用
    return data

def checkcourse(year, course):
    # courseの値が5になったら、次の年の1期になるので、年を1増やす。一方で、courseの値が0になったら、前の年の4期になるので、年を1減らす。
    if (course == 5):
        year += 1
        course = 1
    elif (course == 0):
        year -= 1
        course = 4
    return year, course
```

いかがでしたか。ロジックの実装の部分は馬鹿の一つ覚えみたいにif文でネストしていってしまったので読みづらくなってしまったと思うのですが、もしもっと効率の良い書き方があればこの記事をパクって新しく書いていただけると嬉しいです。

この次はLINEのリッチメニュー機能を使ってユーザーから見て**いい感じ**に仕上げていきます。