🎸

レシピ作家のレシピを検索できるトイアプリがちょいバズったので爆速でLINE Botを作った話

2022/03/10に公開

こんにちは、家庭では休日の料理担当をしている二児の父です。

山本ゆりさんやリュウジさんなど有名レシピ作家さんのレシピをよく参考にするのですが、ブログ記事、Instagram、Youtubeなど皆さん複数のチャネルにレシピを公開してて探しにくいなぁと常々思っていたため、チャネル横断でレシピを検索できる、制作期間わずか3日のトイアプリを公開してTwitterでお知らせしました。

すると山本ゆりさんご本人がリアクションしてくださり(!)、フォロワー数100人ちょっとだった自分のツイートにいいね!が止まらない状況に。これに気を良くして、さらに近所のスーパーで安くなってる食材を調べて、関連レシピを検索できる機能を追加してLINE Botにしてみたよ、という話です。

自分がLINE Botの作り方を検索した時には、オウム返しボットを作ってみた、など実用性に欠ける情報が多かったので、なるべく詳細にBotの作り方を書いていきます。また、全文検索にalgoliaを使っているのでその話も後半で触れます。

この記事で触れること

  • LINE BotをGoで書く場合のlambda設計パターン(Serverless Framework使用)
  • スクレイピング(scrapy)のことを少し
  • algoliaを使ったレシピの全文検索とGoでの形態素解析

🎺作ったもの

実際に使ってみるにはこちら https://lin.ee/1lXs0r5

🎸構成図

Serverless Frameworkで、lambdaのruntimeはGo 1.xです。最初はpythonで書いたのですが、形態素解析をする部分でずっこけるぐらい遅かったのでGoで書き直したところ、体感できるぐらいスピードアップすることができました! Goは初心者なんですが、そんなに難しくもなく、書いてて安心感もあっていいですね。

構成のポイントとしては、ApiGatewayからリクエストを受け付けるlamdbaはリクエストをそのままSQSのキューに入れて即レスポンスを返し、キューを受け取った2つ目のlamdbaがメッセージを返すようにしています。

主要ライブラリ

  • github.com/aws/aws-lambda-go v1.28.0
  • github.com/aws/aws-sdk-go v1.42.49
  • github.com/line/line-bot-sdk-go/v7 v7.12.1
  • github.com/ikawaha/kagome/v2 v2.7.0 // 形態素解析で使用
  • github.com/algolia/algoliasearch-client-go/v3 v3.23.0 // レシピの全文検索で使用
  • cloud.google.com/go/storage v1.21.0 // イベントログ保存

🐟まずはボットの骨格を作っていくぅ!(気まぐれクック風に)

Serverless Frameworkを使用しているので、まずはserverless.ymlを記述します。DynamoDBテーブルだけは別のprojectで作ったものを使っています。

繰り返しますが、functions.enqueueはLINEのwebhookから送られてきたリクエストをSQSのキューに入れて、functions.executeがキューに入ったリクエストを処理してLINEにメッセージを返します。(LINE Messaging APIドキュメントで、ユーザーからのメッセージを受信後すぐにレスポンスを返した方が良い的なことがどっかに書いてあったためです。)

# 中略
frameworkVersion: "3"

custom:
  tableName: "CouchPotatoTable"

provider:
  name: aws
  runtime: go1.x
  environment:
    DYNAMODB_TABLE: ${self:custom.tableName}

functions:
  enqueue:
    handler: bin/enqueue
    package:
      individually: true
      patterns:
        - "!./**"
        - "./bin/enqueue"
    description: line webhookのリクエストをSQSにenqueueする
    timeout: 10
    events:
      - http:
          path: /
          method: post
    environment:
      QUEUE_URL:
        Ref: CouchPotatoLINEBotGoJobQueue
    role: CouchPotatoLineBotGoEnqueueRole
  execute:
    handler: bin/execute
    package:
      individually: true
      patterns:
        - "!./**"
        - "./bin/execute"
        - "./userdict.csv" # kagomeで使用するユーザー辞書
    description: SQSメッセージを解析して、LINEに返信する
    timeout: 30
    memorySize: 512
    environment:
      ALGOLIA_APPLICATION_ID: ${file(./config.${sls:stage}.json):ALGOLIA_APPLICATION_ID}
      ALGOLIA_API_KEY: ${file(./config.${sls:stage}.json):ALGOLIA_API_KEY}
      LINE_CHANNEL_SECRET: ${file(./config.${sls:stage}.json):LINE_CHANNEL_SECRET}
      LINE_CHANNEL_ACCESS_TOKEN: ${file(./config.${sls:stage}.json):LINE_CHANNEL_ACCESS_TOKEN}

    role: CouchPotatoLineBotGoExecuteRole
    events:
      - sqs:
          arn:
            Fn::GetAtt:
              - CouchPotatoLINEBotGoJobQueue
              - Arn

resources:
  Resources:
    CouchPotatoLINEBotGoJobQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: CouchPotatoLINEBotGoJobQueue-${sls:stage}
        VisibilityTimeout: 60
    CouchPotatoLineBotGoEnqueueRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: CouchPotatoLineBotGoEnqueueRole-${sls:stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: botEnqueuePolicy-${sls:stage}
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                  Resource:
                    - "Fn::Join":
                        - ":"
                        - - "arn:aws:logs"
                          - Ref: "AWS::Region"
                          - Ref: "AWS::AccountId"
                          - "log-group:/aws/lambda/*:*:*"
                - Effect: Allow
                  Action:
                    - sqs:SendMessage
                    - sqs:SendMessageBatch
                  Resource:
                    Fn::GetAtt:
                      - CouchPotatoLINEBotGoJobQueue
                      - Arn
    CouchPotatoLineBotGoExecuteRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: CouchPotatoLineBotGoExecuteRole-${sls:stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: botExecutePolicy-${sls:stage}
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                  Resource:
                    - "Fn::Join":
                        - ":"
                        - - "arn:aws:logs"
                          - Ref: "AWS::Region"
                          - Ref: "AWS::AccountId"
                          - "log-group:/aws/lambda/*:*:*"
                - Effect: Allow
                  Action:
                    - sqs:ReceiveMessage
                    - sqs:DeleteMessage
                    - sqs:GetQueueAttributes
                  Resource:
                    Fn::GetAtt:
                      - CouchPotatoLINEBotGoJobQueue
                      - Arn
                - Effect: Allow
                  Action:
                    - dynamodb:Query
                    - dynamodb:GetItem
                    - dynamodb:UpdateItem
                  Resource:
                    - "arn:aws:dynamodb:${aws:region}:*:table/${self:provider.environment.DYNAMODB_TABLE}"

次に、enqueue, executeそれぞれのlambdaハンドラを作ります。lambdaでのGoハンドラの書き方はこちらのドキュメントに詳しく書いてありますが、要点をまとめると以下のような決まりがあるみたいです。

  • github.com/aws/aws-lambda-go/lambdaパッケージを使用する
  • package mainに書いたfunc main()がエントリポイントとなる
  • ハンドラでは0~2つの引数を取れ、2つの引数の場合第一引数はcontext.Contextを実装している必要がある

これらを念頭にコードを書いていきます。ちなみに、一つのserverlessプロジェクトで複数のlamdbaを作る場合、package mainに複数のfunc main()を書くことになるのはやむを得ないのかなと思うのですが、VSCodeさんに注意されるのがウザいです。

Handler: enqueue

enqueue.goではrequestをキューに入れて即レスポンスを返します。

// enqueue.go
package main

import (
	"encoding/json"
	"fmt"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/sqs"
)

var sess = session.Must(session.NewSession())
var svc = sqs.New(sess)
var queueUrl = os.Getenv("QUEUE_URL")

func SendMessage(request events.APIGatewayProxyRequest) error {

	j, err := json.Marshal(request)
	if err != nil {
		return err
	}

	params := &sqs.SendMessageInput{
		MessageBody: aws.String(string(j)),
		QueueUrl:    aws.String(queueUrl),
	}

	sqsRes, err := svc.SendMessage(params)
	if err != nil {
		return err
	}
	fmt.Printf("%+v\n", sqsRes)
	return nil
}

func EnqueueHandler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	err := SendMessage(request)
	if err != nil {
		return events.APIGatewayProxyResponse{
			Body:       "",
			StatusCode: 500,
		}, err
	}
	return events.APIGatewayProxyResponse{
		Body:       "",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(EnqueueHandler)
}

Handler: execute

execute.goでwebhookから受け取ったイベントを処理してメッセージを返します。

ここでParseRequestValidateSignatureについて多少補足が必要かもしれません。
公式SDKにbot.ParseRequest(req)という同様のメソッドがあるのですが、引数が*http.Requestを実装している必要があり、lambdaで使用する場合に型が合いませんでした。そのためこちらの実装を参考に自前の処理を書いています。

// execute.go
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/sqs"
	"github.com/line/line-bot-sdk-go/v7/linebot"
	"github.com/myproject/couch-potato-line-bot-go/bot"
)

var channelSecret = os.Getenv("LINE_CHANNEL_SECRET")
var channelAccessToken = os.Getenv("LINE_CHANNEL_ACCESS_TOKEN")

func HandleEvent(event *linebot.Event, client *linebot.Client) error {
    // eventを処理してメッセージを返すbot
	handler := bot.CouchPotatoRecommender{Bot: client}
	switch event.Type {
	case linebot.EventTypeFollow:
		handler.HandleFollow(event)
	case linebot.EventTypeMessage:
		handler.HandleMessage(event)
	case linebot.EventTypePostback:
		handler.HandlePostback(event)
	}
	return nil
}

func ValidateSignature(channelSecret, signature string, body []byte) bool {
	decoded, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		log.Println(err)
		return false
	}
	hash := hmac.New(sha256.New, []byte(channelSecret))
	hash.Write(body)
	return hmac.Equal(decoded, hash.Sum(nil))
}

// 署名を検証してLINEのイベントオブジェクトを取り出す
func ParseRequest(channelSecret string, r events.APIGatewayProxyRequest) ([]*linebot.Event, error) {
	if !ValidateSignature(channelSecret, r.Headers["x-line-signature"], []byte(r.Body)) {
		return nil, linebot.ErrInvalidSignature
	}
	request := &struct {
		Events []*linebot.Event `json:"events"`
	}{}
	if err := json.Unmarshal([]byte(r.Body), request); err != nil {
		return nil, err
	}
	return request.Events, nil
}

func HandleRecord(record events.SQSMessage, client *linebot.Client) {
	var r events.APIGatewayProxyRequest
	if err := json.Unmarshal([]byte(record.Body), &r); err != nil {
		return err
	}

	events, err := ParseRequest(channelSecret, r)
	if err != nil {
		return err
	}
	for _, event := range events {
		HandleEvent(event, client)
	}
}

func ExecuteHandler(ctx context.Context, event events.SQSEvent) (events.SQSEventResponse, error) {
	client, err := linebot.New(channelSecret, channelAccessToken)
	if err != nil {
		return events.SQSEventResponse{}, err
	}

	failures := []events.SQSBatchItemFailure{}
	for _, record := range event.Records {
		if err := HandleRecord(record, client); err != nil {
			log.Println(err)
			failure := events.SQSBatchItemFailure{ItemIdentifier: record.MessageId}
			failures = append(failures, failure)
		}
	}
	// failuresを返す意味があるのか実は良く分からない
	return events.SQSEventResponse{BatchItemFailures: failures}, nil
}

func main() {
	lambda.Start(ExecuteHandler)
}

あとはイベントを処理してメッセージを返すbotを書けば完成です!

🐟食材とレシピのデータを集めていくぅ!(気まぐれクック風に)

このアプリには、①今日安くなっている食材を探せる、②食材名をキーワードにして関連するレシピを探せる、という二つの機能があります。

データ収集にはscapyを使ってます。自分が大好きなライブラリの一つで、zyteというホスティング環境にコマンド一つでデプロイでき、月額9ドル払えば定期実行もやってくれる優れものです。

スクレイピング周りの話は詳らかにしませんが、robots.txtにできるだけ従い、サーバに負荷をかけないようリクエストの間隔を十分にあけるようにしましょう。幸いscrapyではsettings.pyでの設定で簡単にこれらを実現することができます。

# settings.py
# Obey robots.txt rules
ROBOTSTXT_OBEY = True

# Configure a delay for requests for the same website (default: 0)
# See https://docs.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
DOWNLOAD_DELAY = 2

データをどう格納してるのか

基本的にレシピも食材情報も、DynamoDBの一つのテーブルにぶち込んでます。

よくDynamoDBのレシピとかをネットで探すとUserTable, ProductTableのようにエンティティごとにテーブルを作る例をよく見ますが、本家が推奨しているのはsingle table designです。前々からこの奇想天外なデザインパターンを試してみたかったことと、なるべくケチケチで作りたかったので、Capacity Unitsの無料枠を最大限有効活用できるようにという意図です。

Entity PK SK attributes
INGRADIENT SHOP#{marchant}#{shop_id} INGRADIENT#{category}#{product_id} attributes...
RECIPE #AUTHOR#{author} RECIPE#{src_type}#{uid} attributes...
USER USER#{uid} #USERMETA#{uid}

ただ実際にアプリを動かしてみるとパーティションキー、ソートキーの設計が悪く「あれ、これじゃ一発でクエリ出来ないじゃん...」みたいなことが多々あり、何度もデータを入れ直して試行錯誤しました。未だに、例えば全ユーザーを取得したい場合にはスキャンするしかないなど不便さもあり、本当にこの設計でいいのか自信がありません😇

また、レシピのデータについては食材名で全文検索がしたかったので、全文検索エンジンのalgoliaにインデックスを作成しています。なんでElasticsearchじゃないのかって?なるべくケチケチで作りたかったからですよ🤗

algoliaはpay as you goな料金体系になっていて、10,000レコード + 月間10,000リクエストまで無料で使うことができるので、スモールスタートで全文検索を試したい方にはお勧めできます。synonymやstopwordの追加も可能なのですが、一つ難点があって、カスタム辞書の登録や形態素解析器(kuromoji)を変更することができません。ここらへんの制約で要件が満たせなくなったり、プロダクトが育って課金が発生してきた時がElasticsearchへの変え時かなぁーと思っています。

補足: jsだとFlexSearch, goだとBlugeといったオープンソースの全文検索エンジンを使うという選択肢もありそうです。今度使ってみよう。

🐟食材名でレシピを全文検索していくぅ!(気まぐれクック風に)

ここまで組めたらあとはbotの実装を書いていくだけなのですが、一つ困ったことがありました。

スクレイピングで集めた食材名は「アメリカ産『セブンプレミアムフレッシュ』アンガスビーフ バラ 切り落とし(500g)」のように修飾されまくりで、これをそのままalgoliaでindex.Search(query)しても何もヒットしてくれません。algoliaではクエリをkuromojiで分かち書きして、stopwordを除くすべての単語にヒットするコンテンツを探しに行くためでした。

import (
	"encoding/json"
    "fmt"
	"os"
	"github.com/algolia/algoliasearch-client-go/v3/algolia/search"
)

client := search.NewClient(os.Getenv("ALGOLIA_APPLICATION_ID"), os.Getenv("ALGOLIA_API_KEY"))
index := client.InitIndex("recipes")

kwd := "アメリカ産『セブンプレミアムフレッシュ』アンガスビーフ バラ 切り落とし(500g)"
// 「バラ 切り落とし」でヒットしてほしい!
res, err := index.Search(kwd)
if err != nil {
	panic(err)
}

j, err := json.Marshal(res.Hits)
if err != nil {
    panic(err)
}
var recipes []*entities.Recipe
if err := json.Unmarshal(j, &recipes); err != nil {
	panic(err)
}

// 空っぽ...
fmt.Prinfln(recipes)

先述したようにalgoliaの辞書はカスタマイズできないのでsynonymやstopwordやrulesといった機能でカバーするしかないのですが、さすがに全てのパターンを一個ずつ登録していくは無理筋です。なので、クライアント側で食材名を形態素解析し、algoliaに渡すクエリを抽出してやることにしました。

Goで形態素解析は何がいいのか

調べてみるとpythonほど選択肢はなく、候補に挙がったのがkagome, mecab-golang, gosudachiの3つぐらいでした。

もともとpythonでこのBotを書き始めて、sudachipyを使っていたのでgosudachiが最有力だったのですが、簡単な修正PRを送るついでに聞いてみると「リソース不足で開発が止まっている」との回答だったので諦めました。

mecab-golangも同様に4年以上更新が止まっているようなので不採用、唯一更新が続いているkagomeを使用させていただくことにしました。

kagomeはgo製の形態素解析器で、日本語だとMeCab IPADIC、UniDIC、mecab-ipadic-NEologd(Experimental)の三つの辞書を使用できます。辞書がバイナリに埋め込まれるのが特徴で、ファイルサイズはその分大きくなってしまいますが、lambdaへのデプロイはバイナリ一つで済むので簡単という利点があります。

ユーザー辞書を登録することも可能で、ユーザー辞書に登録された語彙は最優先で使われます。
ドキュメントでは3つの辞書登録方法が紹介されていますが、僕はcsvファイルから読み込ませる方法にしました。料理に関する1700ほどのワードリストを作成し、品詞を「カスタム名詞」としてプログラム側で判別できるようにしています。

# text, tokens, yomi, pos
カイワレダイコン,カイワレダイコン,カイワレダイコン,カスタム名詞
udict := tokenizer.Nop()
dir, err := os.Getwd()
if err != nil {
	panic(err)
}
d, err := dict.NewUserDict(dir + "/userdict.csv")
if err != nil {
	panic(err)
}
udict = tokenizer.UserDict(d)
t, err := tokenizer.New(ipa.DictShrink(), tokenizer.OmitBosEos(), udict)
if err != nil {
	panic(err)
}
tokens := t.Analyze(text, tokenizer.Search)

🐟使っていくぅ!(気まぐれクック風に)

Twitterでお知らせしたところ、一日でalgoliaの無料枠10,000リクエストを消費してしまったので瞬間風速はそれなりにあったように思います。

検索精度についてはまだまだ改善の余地があって、例えば「獅子唐」で検索すると「獅子頭鍋」のレシピがヒットしたり、「豚肉ロース切り身」(多分とんかつ用の肉)で検索すると豚肉ロース(薄切り肉)のレシピがヒットしたりします💁これからの課題ですが、如何せんalgoliaの自然言語処理に依存する部分も多いため、将来的にはElasticsearchへの乗り換えを検討することになりそうです。

今後の野望

今はイトーヨーカドーのネットスーパー全店舗と西友ネットスーパー(サンプル店)から食材を探せるんですが、イオンとか他のコレクションも増やしていきたいと思っています。(イオンは店舗ごとにカテゴリ分類が異なり一度挫折した経緯があります。。)

カートに追加とかも実装したいんですが、三つともカートに追加のAPIがないんですよね🙅「うちはカートに追加できるよ!」といったショップさんがいらっしゃったらお声がけください😁

それでは、銀色のやつ!(気まぐれクック風に)
※気まぐれクックが分からない人は是非Youtubeを見てみてください。面白いですよ

Discussion