こんにちは、家庭では休日の料理担当をしている二児の父です。
山本ゆりさんやリュウジさんなど有名レシピ作家さんのレシピをよく参考にするのですが、ブログ記事、Instagram、Youtubeなど皆さん複数のチャネルにレシピを公開してて探しにくいなぁと常々思っていたため、チャネル横断でレシピを検索できる、制作期間わずか3日のトイアプリを公開してTwitterでお知らせしました。
すると山本ゆりさんご本人がリアクションしてくださり(!)、フォロワー数100人ちょっとだった自分のツイートにいいね!が止まらない状況に。これに気を良くして、さらに近所のスーパーで安くなってる食材を調べて、関連レシピを検索できる機能を追加してLINE Botにしてみたよ、という話です。
自分がLINE Botの作り方を検索した時には、オウム返しボットを作ってみた、など実用性に欠ける情報が多かったので、なるべく詳細にBotの作り方を書いていきます。また、全文検索にalgoliaを使っているのでその話も後半で触れます。
この記事で触れること
- LINE BotをGoで書く場合のlambda設計パターン(Serverless Framework使用)
- スクレイピング(scrapy)のことを少し
- algoliaを使ったレシピの全文検索とGoでの形態素解析
作ったもの
実際に使ってみるにはこちら https://lin.ee/1lXs0r5
@syunkon0507さん, @innocence_yuuさん, @mariegohanさん, @ore825さんなどの人気レシピ作家さんのレシピをキーワード検索できるLINEアプリを以前作ったのですが、リニューアルして「近所のスーパーで安い食材を見つけて、その食材を使ったレシピを探せる」アプリに進化した🤣 https://t.co/vZcRrWutCb pic.twitter.com/E3PZv3lWR1
— Kazuki Nagata (@KnPublic) March 7, 2022
構成図
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から受け取ったイベントを処理してメッセージを返します。
ここでParseRequest
とValidateSignature
について多少補足が必要かもしれません。
公式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
を書けば完成です!
食材とレシピのデータを集めていくぅ!(気まぐれクック風に)
このアプリには、①今日安くなっている食材を探せる、②食材名をキーワードにして関連するレシピを探せる、という二つの機能があります。
データ収集にはscrapyを使ってます。自分が大好きなライブラリの一つで、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を見てみてください。面白いですよ