概要
本記事は LINE DC Advent Calendar 2021 19日目の記事です。
以前に友人とLINE Botのハンズオンを一緒に触って楽しかったので、復習もかねてもう一回整理しながらボットをつくってみました。
前回は何も知らない状態から「とりあえず動くものをつくろう」というスタンスで愚直に開発していたので、今回は保守性とか再利用性みたいなところも少し意識して、つぎにボットを作るときの参考になるようにつくってみました。
出来上がったもの
とりあえず先にどんなものが出来上がったか紹介しておきます。
最終的に↓こんな感じのもの↓が出来上がりました。
以下の機能があります
-
「メニューを開く」を押すとリッチメニューが表示される
-
「ランダム選択」を押すとランダムに選んだ作品を紹介してくれる(画像①)
-
「作品検索」を押すと検索方法を教えてくれる(画像②)
-
トークに単語を送信するとその単語で検索した結果を10件まで見せてくれる(画像③)
-
検索結果が10件を超える場合はdアニメストアで実際に検索した画面へのリンクも表示してくれる(画像④)
解説
ここからは開発時に意識したところなどを紹介してみます。
ボットサーバーの構成
ボットサーバーは Google Spread Sheet と Google Apps Script (GAS) を使って作りました。
POSTリクエストにさえ対応できればいいのでGASで十分というわけですね。(しかも無料!)
ちなみにコードのコピーを以下に置いているので細かいところはぜひこちらを参考にしてください。
実行する処理の選択
LINE Botでは特定の機能を実行する方法としてメッセージ内容で識別することがメジャーな方法だと思います。
これを踏まえて今回は
- 「ランダムに作品を選択する」というメッセージが送信されたらランダムに選択された作品情報を返す
- 「キーワードで作品を検索する」というメッセージが送信されたら検索方法のヒントメッセージを返す
- 上記以外のメッセージが送信されたらキーワードと解釈して検索した結果を返す
という仕様でBotクラスにまとめる形で実装しました。
3番目の仕様に関しては、本当は検索機能を指定した後に入力された文字をキーワードとして扱うという処理にしたかったのですが、ボットサーバー側でユーザの状態(検索機能を選択したこと)を管理することが難しかったのでこのような仕様にしました。(あるいは前回のメッセージを取得できたりしたら実現できたんですけどね...)
なにか実現方法を知っている方がいたら教えてほしいです;_;
また、この仕様で重要なのは機能を選択するためのキーワードが検索ワードになれないというところなので、機能選択のためのキーワードは文章にしておきました。
const FUNC_MSG = {
RANDOM : "ランダムに作品を選択する",
SEARCH : "キーワードで作品を検索する"
}
これはボタンを押したときに勝手に入力される文字なのであえて長めに設定してキーワードとしてのユニーク性を高めた方がいいと思います。
ログの記録
GASではconsole.log(~~)
やLogger.log(~~)
でログを記録して、実行履歴からログ内容を見ることができます。
ただし、外部から呼ばれて実行されたとき?はログに記録されないらしく、GCP側のプロジェクトとして定義して監視機能を追加する必要があるみたいです。(この辺を読んでのざっくり理解)
それだったらGCPやCloud Functionsを勉強してそっちでつくればよくない?ということでスプレッドシートにロギングするというアナログな方法を使いました。
この方法のいいところは処理がGASで完結するところだったりログに色を付けられる点です。基本は黒でエラーログの場合は赤みたいな使い方をしています。
ただしデメリットとして、シートへの書き込み処理が遅いのでロギング処理の数に比例して処理時間が大幅に遅くなるところがあります。
なのでリリース用では無効化するようにしています。あくまで開発時の確認用です。
const debuglog = (obj, color="black") => {
if(CURRENT_RELEASE_MODE != RELEASE_MODE.PROD) {
// ブックとシートの取得
const book = SpreadsheetApp.openById(BOOKID);
var sheet = book.getSheetByName(SHEET.LOG);
// シートが存在しない場合は作成する
if (!sheet) {
sheet = book.insertSheet();
sheet.setName(SHEET.LOG);
sheet.appendRow(["Timestamp", "Log"])
sheet.setFrozenRows(1)
sheet.getRange(1,1, 1,2).setFontWeight("bold")
}
sheet.appendRow([`[${getTimeStamp()}]`, JSON.stringify(obj)]);
sheet.getRange(sheet.getLastRow(), 1, 1, 2).setFontColor(color);
}
}
dアニメストアのAPIハック
このボットではdアニメストアの内部APIをハックして使わせてもらっているのでその点について少し話しておきます。
注意事項
まずはじめに注意事項があります。
内部APIは公式に公開されているAPIではないので利用に関しては利用規約を確認したうえで自己責任で利用すること
dアニメストアの利用規約を確認したところ、特に言及しているところが見つからなかったのでアクセス過多等に気を付けながら使わせてもらっています
内部APIとは
内部APIというのはサービスの中で使われているAPIで、また外部に対して仕様やドキュメントが公開されていないAPIのことです。
じゃあどうやって使い方を調べるの?というと、そのサイトからサーバへのリクエストを監視すれば割と簡単に見つけることができるので雰囲気で仕様を把握します。
例えば作品検索でいうと、dアニメストアの検索ページでキーワードを入力すると検索結果のページが表示されますが、このときフロントからサーバには以下のようなリクエストが飛んでいます。
https://anime.dmkt-sp.jp/animestore/rest/WS000105?length=20&mainKeyVisualSize=2&searchKey=hello&vodTypeList=svod_tvod
このURLをブラウザのアドレスバーに入れるとjsonが返ってきて表示されると思います。
これが内部APIで、dアニメストアの検索結果ページはこのAPIを使って検索結果を取得してフロントで表示しているというわけです。
なので検索結果ページのHTMLをスクレイピングしなくてもこのAPIを使えば検索結果一覧を取得できます。というか検索結果はJSで後入れなのでHTML本体のスクレイピングでは結果はとれないです。
ただし内部APIということで注意点がいくつかあります。
-
APIを呼びすぎないように注意して、通常時と同程度の実行回数に収まるようにする。
- 内部APIは基本的に自社のサービスからのみ利用される想定で作られているので不特定多数からのアクセス数を考慮していません。なので短時間に大量にAPIを呼んでサーバに負荷をかけないようにしましょう。
-
いつの間にか仕様が変わることがあるので、その時は適宜修正する。
- 公開しているAPIではないのでドキュメントはありませんし仕様が急に変わることもあります。なのでHTTPリクエスト等を監視して最新の仕様把握を自分で行う必要があります。もしAPIが使えなくなっても文句は言えないのであきらめましょう。
ランダム選択機能の実装
ランダムに作品を取得する機能はdアニメストアにないので自前で作る必要があります。
作品ページは https://anime.dmkt-sp.jp/animestore/ci_pc?workId=22675
のように workId
(作品ID) で指定できるので、最初は作品IDをランダムに生成すればいいかなと思いました。
ただ、この作品IDは連番というわけでもないみたいなので作品IDの有効無効判別が必要で、いっそ全作品リストが欲しいな~なんて考えていました。
ところで、dアニメストアには全作品一覧というページがあって、五十音別で作品一覧を見ることができます。
この作品一覧ページでは以下のような内部APIで五十音別に作品を取得して一定件数ごとに表示していました。
https://anime.dmkt-sp.jp/animestore/rest/WS000108?workTypeList=anime&length=50&mainKeyVisualSize=2&initialCollectionKey=1&consonantKey=4&vodTypeList=svod
ちなみにlengthに設定できる数値は300が上限のようでした。今のところは頭文字1種類について作品数が300件を超えるケースはないみたいですが、今後増えてきたらこの仕様も変わるんでしょうね。
ということで、このAPIを呼んで「あ」~「ん」までの全作品リストをスプレッドシートにまとめて、このシートからランダムに作品を選択するという手法をとりました。
具体的にはworkListシートを作成するsetWorkItemList関数というものを用意しました。
setWorkItemList関数
const setWorkItemList = () => {
// 母音子音のKey
// keyにするときは母音も子音もともに+1する(1スタートのため)
// _はスキップ対象
const INITIAL_TABEL = [
['あ', 'い', 'う', 'え', 'お'],
['か', 'き', 'く', 'け', 'こ'],
['さ', 'し', 'す', 'せ', 'そ'],
['た', 'ち', 'つ', 'て', 'と'],
['な', 'に', 'ぬ', 'ね', 'の'],
['は', 'ひ', 'ふ', 'へ', 'ほ'],
['ま', 'み', 'む', 'め', 'も'],
['や', '_', 'ゆ', '_', 'よ'],
['ら', 'り', 'る', 'れ', 'ろ'],
['わ', 'を', 'ん', '_', '_']
];
// ブックとシートの取得
const book = SpreadsheetApp.openById(BOOKID);
var sheet = book.getSheetByName(SHEET.WORKLIST);
// シートが存在する場合は既存シートをクリアし、存在しない場合は作成する
if(sheet) {
sheet.clear();
} else {
sheet = book.insertSheet();
sheet.setName(SHEET.WORKLIST);
}
// シートのヘッダー行を作成する
sheet.appendRow(["workId", "workTitle", "mainKeyVisualPath"]);
sheet.setFrozenRows(1);
sheet.getRange(1,1, 1,3).setFontWeight("bold");
// 作品一覧を取得しシートに記入する
var row_start = 2;
// 母音のループ
for(var ik=0; ik<INITIAL_TABEL.length; ik++) {
// 子音のループ
for(var ck=0; ck<INITIAL_TABEL[ik].length; ck++) {
if(INITIAL_TABEL[ik][ck] != "_") {
var json = DA_API.fetchAllItem(ik+1, ck+1);
var data = [];
json.data.workList.forEach(d => data.push([d.workId, d.workInfo.workTitle, d.workInfo.mainKeyVisualPath.replace(/_[0-9]\.png/, '_1.png')]));
Utilities.sleep(2 * 1000);
if(data.length > 0) {
// シートにまとめて記入(appendRowより高速)
sheet.getRange(row_start,1, data.length, 3).setValues(data);
row_start += data.length;
Logger.log(`${INITIAL_TABEL[ik][ck]} から始まる作品一覧の取得完了`);
} else {
Logger.log(`${INITIAL_TABEL[ik][ck]} から始まる作品なし`);
}
}
}
}
}
この方法のメリットは全作品の取得のために約50回のリクエストを投げる以外はすべてシート内の情報で完結するのでサイトに負荷がかからないところです。
画像のように、作品ID・タイトル・サムネイル画像のURLを保管する全作品一覧シートが作成されます。
ちなみにdアニメストアの作品は少なくともクール単位で追加・更新されるのでこのシートも定期的に更新する必要があります。
そういう時はこの関数を週一や月一で実行するトリガーを設定することで作品情報を常に最新に保つことができます。個人的にこれもGASの大きなメリットだと思っています。
またシート作成時の注意点が1つあって、作品一覧取得で取得できるjsonには最大で300件の作品情報が含まれるのですが、これをシートに記録するときにシートに対して sheet.appendRow(<1行データ>)
を使うとGASの実行時間制限に引っかかってしまいます。
なので自分は sheet.getRange(<開始行位置>, <開始列位置>, <データの行数>, <データの列数>).setValues(<全件データ>)
のようにオブジェクトを一括で設定することで回避しました。この方法の方が圧倒的に早かったです。
検索機能の実装
検索機能では単純に検索画面で使われている内部APIをそのまま呼んで検索結果のjsonを取得しています。
https://anime.dmkt-sp.jp/animestore/rest/WS000105?length=11&mainKeyVisualSize=2&sortKey=4&searchKey=第2期&vodTypeList=svod
手を加えているところとしては、まずlengthで検索結果の取得数を最大11件に限定しています。
LINE Messaging APIのカルーセルテンプレートメッセージ(横スクロール可能な複数カードのメッセージ)は最大で10件までしか表示できないので、通信の負担が少しでも下がるように必要最低限のデータを取得するようにしています。(効果があるかは不明)
また、キーワードの中の日本語はURLエンコードしなくても大丈夫なのですが、一部記号に関してはそのままだとエラーになるのでエンコードしています。
リッチメニューの設定方法
リッチメニューはトーク画面の下の方に表示されるボタンみたいなもので、画像とエリアへのタッチイベントの設定で実現されています。
今回でいうと以下の画像を1000x500サイズとして指定して、左の500x500と右の500x500のそれぞれにタッチイベントを設定しています。
リッチメニューについての詳細や設定方法は以下の公式ドキュメントを参照すればわかると思います。
リッチメニューは設定内容を含めたリクエストをリッチメニュー用のエンドポイントに送信するだけなので、GASにかぎらずcurlコマンドやPowerShellなどHTTPリクエストを送信できるクライアントならなんでも大丈夫です。
自分は RichMenuManager.gs として、リッチメニューの作成や画像の指定、リッチメニューID一覧の取得等の処理を個別に関数化し、それぞれ呼ぶことでリッチメニューを設定したり確認できるようにしました。たぶん次回以降にも使いまわせるかなと思っています。
サンプルとしてリッチメニュー一覧を取得する関数はこんな感じです。(token
はGASのグローバルで定義する変数です)
// 仕様:https://developers.line.biz/ja/reference/messaging-api/#get-rich-menu-list
const getRichMenuList = () => {
checkExists(token);
const resp = UrlFetchApp.fetch('https://api.line.me/v2/bot/richmenu/list', {
"method" : "get",
"headers" : {
"Authorization" : `Bearer ${token}`
},
});
if(resp.getResponseCode() != 200){
throw new Error("リッチメニュー一覧を取得できませんでした");
}
const rms = JSON.parse(resp).richmenus;
Logger.log(rms);
}
アイコンやリッチメニュー画像の重要性
最後に、ボットのアイコンやリッチメニューの画像について話しておきたいことがあります。
結局、見た目がよければクオリティも高くみえてモチベがあがります
こういったアプリを作るときにアイコンや画像をちゃんと用意すると成果物に対する愛着がわいてやる気が出ます!(出ました!)
さいごに
前回のハンズオン時に得た知識を整理しながら記事として公開できるようにまとめてボットをつくってみました。
まとまっているのかは不安ですが誰かの役に立ったらうれしいです。
あと今回作成したボットは大々的に公開していいのか微妙なので公開していません。ご了承ください。
次回はポストバックアクションやカメラアクションを使ってみたり、LINE Bot Designerでも同じようなことができるのか試してみたいなと思います。
最後までお読みいただきありがとうございました。 m(__)m