Q. 『Google Colaboratory だけでLINE Botを作ることができるのか？』

**A. 『運用は現実的でないが、簡単なBotの開発程度なら可能』**

# そもそも Google Colaboratory でAPIサーバ立てられるの？
この疑問について調べ始めたのが本記事を執筆するきっかけです。
調べてみるとすぐに見つかりました。

https://relaxing-living-life.com/605/

技術要素としては `ngrok(エングロック)` というサービスを利用しています。
ざっくり説明すると、**ローカルサーバをインターネット上に簡単に公開できる**というものです。
私も今回知りましたが、個人開発するうえでとても便利なサービスだと感じました。
興味のある方は先程のリンクをぜひご参照ください。

# 実際にLINE Botを作ってみよう
ここからは実際に作成したものをご紹介します。
概要としては **とあるポケモンカード通販サイトの商品検索アプリ** になります。
トークルームで検索キーワードを送信すると商品情報がカルーセル形式で最大10件表示されます。
11件目移行は「もっと見る」が表示され、押下で元サイトの検索結果画面に遷移します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1532684/369e3317-d9a9-caa0-e139-28a0753f9183.jpeg" width="49%">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1532684/69741ce8-952b-4d76-f21f-25fa738ec247.jpeg" width="49%">


## 処理フロー
![処理フロー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1532684/dd3fa279-5be9-9a9b-4923-f0688198eb82.png)


## 環境
- クライアント：スマートフォン版LINEアプリ（PC版はカルーセル形式が表示されない）
- APIサーバ：`Google Colaboratory`

## LINE Developers
LINE Bot作成にあたり、`LINE Developers` のアカウント登録が必要です。
登録方法は本記事では説明を省きますので他記事等をご参照ください。
参考までに公式クイックスタートガイドを紹介します。

https://developers.line.biz/ja/docs/messaging-api/getting-started/#using-console

## 実装
:::note warn
今回の開発のゴールは **Google Colaboratory をAPIサーバとして利用できるか確かめる**ことなので、処理失敗時や例外発生時の処理は実装しておりません。
:::

<details><summary>折り畳み</summary>

```powershell:インストールセル
!pip install fastapi
!pip install line-bot-sdk
!pip install nest_asyncio
!pip install pyngrok
!pip install uvicorn
```

```python:メイン処理セル
# スクレイピング関連
import urllib.parse
import requests
from bs4 import BeautifulSoup

# fastAPI関連
from fastapi import FastAPI, Header, Request

# LINE Bot関連
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextSendMessage, FlexSendMessage
from starlette.exceptions import HTTPException

# サーバ関連
import nest_asyncio
from pyngrok import ngrok
import uvicorn

# 認証情報
CHANNEL_ACCESS_TOKEN = {取得したチャネルアクセストークン}
ACCESS_SECRET = {取得したチャネルシークレット}

# @markdown #検索パラメータ

# @markdown - available: 検索結果に在庫なしを含むかどうか
# @markdown - 0: 在庫なしを含む
# @markdown - 1: 在庫なしを含まない  
available = "0"  # @param ["0", "1"]

# @markdown - order: 検索結果の並び順
# @markdown - featured: おすすめ順
# @markdown - asc: 価格の安い順
# @markdown - desc: 価格の高い順
order = "desc"  # @param ["featured", "asc", "desc"]

# @markdown - num: 検索結果の表示件数
num = "20"  # @param ["10", "20", "30"]


def search(keyword):
    """
    検索キーワードから商品を検索し、商品情報を返却.

    Parameters:
        keyword (string): 検索キーワード
    Returns:
        (dictionary): 商品情報
    """
    # 検索パラメータを設定しURL生成
    query_string_dict = {
        "keyword": keyword,
        "num": num,
        "img": "200",
        "available": available,
        "order": order,
    }
    base_url = "https://www.cardrush-pokemon.jp/product-list/0/0/photo?"
    url = base_url + urllib.parse.urlencode(query_string_dict)

    # HTMLを取得し、パース
    html = requests.get(url)
    soup = BeautifulSoup(html.content, "html.parser")

    # パースしたHTMLから特定の要素を抽出し、商品情報を取得
    elem = soup.select("a.item_data_link")
    items = {}
    for i, e in enumerate(elem):
        img = e.findChild("img")
        item = {
            "title": img.attrs["alt"],
            "price": e.select_one("span.figure").text,
            "stock": e.select_one("p.stock").text,
            "item_url": e.attrs["href"],
            "img_url": img.attrs["data-x2"],
        }
        items["item" + str(i).zfill(2)] = item

    item_count_str = soup.select_one("span.number").text.replace(",", "")
    item_count_int = int(item_count_str)

    return {
        "url": url,
        "item_count": item_count_int,
        "disp_items": items,
    }


def createReplyMessageJson(products):
    """
    商品情報からリプライメッセージを生成.

    Parameters:
        products (dictionary): 商品情報
    Returns:
        (dictionary): 検索結果の件数と商品情報JSON
    """
    json = {
        "type": "carousel",
        "contents": []
    }
    for i, item in enumerate(products["disp_items"].values()):
        # 背景色を交互に変更
        background_color = "#c6e5d9"
        if i % 2 == 1:
            background_color = "#f8ecc9"

        inner_json = {
            "type": "bubble",
            "size": "micro",
            "hero": {
                "type": "image",
                "size": "full",
                "aspectMode": "cover",
                "url": item["img_url"]
            },
            "body": {
                "type": "box",
                "layout": "vertical",
                "spacing": "sm",
                "contents": [
                    {
                        "type": "text",
                        "text": item["title"],
                        "wrap": True,
                        "weight": "bold",
                        "size": "md",
                        "color": "#3a5134"
                    },
                    {
                        "type": "box",
                        "layout": "baseline",
                        "contents": [
                            {
                                "type": "text",
                                "text": item["price"],
                                "wrap": True,
                                "weight": "bold",
                                "size": "sm",
                                "flex": 0,
                                "color": "#8c9184"
                            }
                        ]
                    },
                    {
                        "type": "box",
                        "layout": "baseline",
                        "contents": [
                            {
                                "type": "text",
                                "text": item["stock"],
                                "wrap": True,
                                "weight": "bold",
                                "size": "sm",
                                "flex": 0,
                                "color": "#8c9184"
                            }
                        ]
                    }
                ],
                "backgroundColor": background_color
            },
            "action": {
                "type": "uri",
                "label": "action",
                "uri": item["item_url"]
            }
        }
        json["contents"].append(inner_json)

        # カルーセルに表示する商品情報は10件までとする
        if i == 9:
            break

    # 検索結果が11件以上の場合は「もっと見る」を表示
    if products["item_count"] >= 11:
        more_json = {
            "type": "bubble",
            "size": "micro",
            "body": {
                "type": "box",
                "layout": "vertical",
                "spacing": "sm",
                "contents": [
                    {
                        "type": "button",
                        "flex": 1,
                        "gravity": "center",
                        "action": {
                            "type": "uri",
                            "label": "もっと見る",
                            "uri": products["url"]
                        },
                        "color": "#3a5134"
                    }
                ]
            }
        }
        json["contents"].append(more_json)

    return {"item_count": products["item_count"], "carousel": json}


# FastAPIに関するインスタンス作成
app = FastAPI()

# LINE Botに関するインスタンス作成
line_bot_api = LineBotApi(channel_access_token=CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(channel_secret=ACCESS_SECRET)


@app.post("/")
async def callback(request: Request, x_line_signature=Header(None)):
    """
    LINE Message APIからのコールバック.
    ユーザーからメッセージが送信された際、LINE Message APIからこのメソッドが呼び出される.
    """
    body = await request.body()
    try:
        handler.handle(body.decode("utf-8"), x_line_signature)
    except InvalidSignatureError:
        raise HTTPException(status_code=400, detail="InvalidSignatureError")
    return "OK"


@handler.add(MessageEvent)
def handle_message(event):
    """
    LINE Messaging APIのハンドラより呼び出される処理.
    送信されたメッセージに従い返信メッセージを返却.

    Parameters:
        event (MessageEvent): 送信されたメッセージの情報
    """
    # 改行は半角スペースに変換
    products = search(event.message.text.replace("\n", " "))
    results = createReplyMessageJson(products)
    reply_message = [
        TextSendMessage("検索結果：" + '{:,}'.format(results["item_count"]) + "件")
    ]
    
    if results["item_count"] > 0:
        reply_message.append(
            FlexSendMessage(
                alt_text="検索結果",
                contents=results["carousel"]
            )
        )

    line_bot_api.reply_message(
        event.reply_token,
        reply_message
    )


# サーバを立てる
ngrok_tunnel = ngrok.connect(8000)
print("Public URL:", ngrok_tunnel.public_url.replace("http", "https"))
nest_asyncio.apply()
uvicorn.run(app, port=8000)
```
</details>

メイン処理セルを実行するとこのような内容が出力されます。

```console:出力結果
INFO:     Started server process [61]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
Public URL: https://xxxx-xx-xxx-xx-xxx.ngrok.io
```

上記の `Public URL` を `LINE Developers` に登録します。
**[プロバイダー] > [チャネル] > Messaging API設定 > Webhook設定 > Webhook URL**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1532684/b476f834-c270-3e9e-648a-aa44b970a0f6.png)
登録後、LINEにてメッセージを送信して商品情報が表示されれば成功です。

## 立ちはだかる「90分／12時間ルール」の壁
`Google Colaboratory` には利用制限があり、一定期間でランタイムリセットが行われて実行環境が初期化されます。
詳細や回避方法等は以下の記事をご参照ください。

https://qiita.com/enmaru/items/2770df602dd7778d4ce6

今回は `90分／12時間ルール` の対策はしておりません。
そのため、定期的にセルを実行してサーバを立ち上げる必要があります。
また、**割り当てられるURLが毎回異なる**ため、サーバ立ち上げの度に `LINE Developers` にてAPIのURLを登録しなおす必要があります。
以上が冒頭で **「運用は現実的でない」** とした根拠になります。

## さいごに
ここまで記事を読んでいただいてありがとうございました。
本記事が皆様のよりよい Google Colaboratory ライフの一助となれば幸いです。