この記事はMDC Advent Calendar 2020 16日目の記事です。
はじめに
ChatBotに最適な構成について結論が出たので、実装してみることにしました。
作るもの
最近カーセンサーAPIを弄っており中々面白そうなので、カーセンサーのまわし者みたいなアプリを作ってみることにします。
- 車のモデル名からカタログ情報を教えてくれる(例:プリウスについて教えて)
- 車のモデル名、もしくはカテゴリから、中古車情報を教えてくれる(例:100万のセダン、プリウスの中古車を教えて)
そしてできたものはこちらです。
(2021/03追記)2020年末にカーセンサーAPIのサービスが終了したので、このアプリは動作しません。
誰でもお友達登録すれば使えますのであの懐かしの名車を訊ねてみてください。
構成
前回の結論から同じ構成にしました。
DialogflowとGASを行ったり来たりしながら作ります。
GAS部分のメイン構成はこんな感じです。
doPost()
├actionがカタログ情報なら
│ └カタログ情報を調べて返す
├actionがモデルから中古車を探すなら
│ └モデルから中古車を調べて返す
├actionがモデルから中古車を探す、の2回目なら
│ └モデルから中古車を調べて返す
├actionがボディタイプから中古車を探すなら
│ └ボディタイプから中古車を調べて返す
├actionがボディタイプから中古車を探す、の2回目なら
│ └ボディタイプから中古車を調べて返す
└その他
└エラーハンドリング
工夫したところ
画像を出したい
車が見つかったら、画像を出して憧れの名車へ思いを馳せるお手伝いをしたいところです。
もしくはヒットした車両がとんでもない魔改造された曰く付きかも知れません。
しかし、LINEの仕様でhttpsでホストされた画像をURLで渡す必要があり、カーセンサーの画像URLはhttpだったのでダメでした。
Google画像検索で見つけて差し替える手もありましたがそれでは似て非なるものなので断念しました。
デバッグしやすいようにログを取りたい
Dialogflowにも会話の履歴やGASに渡されたJSONは残ってますが、必要な情報だけぱっと見できないのでスプレッドシートに追記していきます。これによって開発が捗ります。
タイムスタンプについては、LINEの仕様でエポック秒で返ってくるのでいい感じに変換します。
この勢いで、欲しい車の条件を記録しておいて、定期的にウォッチして新しい車を見つけたらプッシュ通知してくれる、なんてのも便利だなと思ってます。
ユーザーが使い方わからなくても使い始められるようにしたい
Chatbotで最も重要で、そして難しいところですが、Dialogflow、Lineの機能を利用してそこそこわかりやすく仕上げることができます。
中古車を探すときの条件は何か?
ある程度、条件を絞ってあげないとユーザーが欲しい情報を返せないし、かといってあんまり質問攻めにするのも使いにくい。
今回は、2ケースに絞ってロジックを用意しました。
- 欲しいモデル(プリウスとか)が決まっている
- 欲しいモデルが決まってないが、ボディタイプ(セダンとか)が決まっている
それぞれリクエスト形式が違ってくるのでインテントは分け、GAS側も別の関数で用意しました。
→モデル名、年式、走行距離をパラメータに取り込みます。
モデル名は選択式にするのは諦めて、@sys.any:なんと言おうがパラメータに入れ込む、という設計になってます。
インテントのモデル名にハイライトが付いていますが「〜を買いたい」の「〜」がモデル名である、と設定してあることで、文章の中からモデル名部分が引き抜かれて設定できます。
そして安い順に上位100件を取得するようにしました。
→ボディタイプも同様です。
一方ボディタイプについては10通りしかないので、エンティティを個別設定しました。
そしてこちらは上限の予算を設定するようにして、価格が高い順に上位100件を取得するようにしました。
他の候補車両を返したい
中古車が複数ヒットした場合、全量出しても受け止めきれないのでランダムに選んだ1台だけ返すことにします。
すると次のアクションでは「他にないの?」となるのが自然でしょう。
そこで、「他の車は?」などをトリガーに次の候補を出すことにします。
しかし、パラメータはインテントが片付けば空にリセットされます。
つまり、usedcar_bodyで条件を聞き出してパラメータに詰め、GASを呼び出す⇨「他の車は?」⇨後続の子インテント「usedcar_body - yes」で受け取る⇨検索条件が格納されたパラメータは空。
そこで「usedcar_body - yes」ではinput contextで親のパラメータを引き継ぐよう設定することで、リクエストの「outputContexts」にパラメータが残り、取り出すことができます。
ただし何回呼ばれたか、は特定できなかったので、どの車を出すかはランダムにしました。
sample.json
{
"responseId": "e5a474ad-5001-4c4e-b9f9-5f63c0c7622b-b87e6a39",
"queryResult": {
"queryText": "はい",
"action": "usedcar_body.usedcar_body-yes",
"parameters": {},
"allRequiredParamsPresent": true,
"fulfillmentText": "他の車も見てみますか?",
"fulfillmentMessages": [
{
"text": {
"text": [
"他の車も見てみますか?"
]
}
}
],
"outputContexts": [
{
"name": "projects/nice-limiter-191418/locations/global/agent/sessions/53512c30-d883-b49b-78e6-3642c12ae961/contexts/usedcar_body",
"lifespanCount": 5,
"parameters": {
"body": "S",
"body.original": "セダン",
"price_max": 1000000,
"price_max.original": "100万",
"year_old": 2017,
"year_old.original": "2017",
"odd_max": 50000,
"odd_max.original": "50000"
}
},
{
"name": "projects/nice-limiter-191418/locations/global/agent/sessions/53512c30-d883-b49b-78e6-3642c12ae961/contexts/usedcar_body-followup",
"lifespanCount": 1,
"parameters": {
"body": "S",
"body.original": "セダン",
"price_max": 1000000,
"price_max.original": "100万",
"year_old": 2017,
"year_old.original": "2017",
"odd_max": 50000,
"odd_max.original": "50000"
}
},
{
"name": "projects/nice-limiter-191418/locations/global/agent/sessions/53512c30-d883-b49b-78e6-3642c12ae961/contexts/usedcar_body-followup-2",
"lifespanCount": 1,
"parameters": {
"body": "S",
"body.original": "セダン",
"price_max": 1000000,
"price_max.original": "100万",
"year_old": 2017,
"year_old.original": "2017",
"odd_max": 50000,
"odd_max.original": "50000"
}
},
{
"name": "projects/nice-limiter-191418/locations/global/agent/sessions/53512c30-d883-b49b-78e6-3642c12ae961/contexts/__system_counters__",
"parameters": {
"no-input": 0,
"no-match": 0
}
}
],
"intent": {
"name": "projects/nice-limiter-191418/agent/intents/34f4692f-7705-4b37-9ba7-86ae200262be",
"displayName": "usedcar_body - yes"
},
"intentDetectionConfidence": 1,
"languageCode": "ja"
},
"originalDetectIntentRequest": {
"source": "DIALOGFLOW_CONSOLE",
"payload": {}
},
"session": "projects/nice-limiter-191418/locations/global/agent/sessions/53512c30-d883-b49b-78e6-3642c12ae961"
}
GASのサンプルコード
function doPost(e){
const sheet = SpreadsheetApp.getActiveSheet();
const LastRow = sheet.getLastRow();
var jsonString = e.postData.getDataAsString();
let responseJson = JSON.parse(jsonString);
var responseId = responseJson.responseId
var queryText = responseJson.queryResult.queryText
var action = responseJson.queryResult.action
var parameters = responseJson.queryResult.parameters
var source = responseJson.originalDetectIntentRequest.source
var timestamp = u2d(responseJson.originalDetectIntentRequest.payload.data.timestamp)
var id = responseJson.originalDetectIntentRequest.payload.data.message.id
var userId = responseJson.originalDetectIntentRequest.payload.data.source.userId
var replyToken = responseJson.originalDetectIntentRequest.payload.data.replyToken
var parameters_old = responseJson.queryResult.outputContexts[0].parameters
var lifespanCount = responseJson.queryResult.outputContexts[0].lifespanCount
var length = responseJson.queryResult.outputContexts.length;
let data = [[timestamp,responseId,queryText,action,parameters,source,id,userId,replyToken,length,lifespanCount,parameters_old]];
sheet.getRange(LastRow+1, 1, 1, 12).setValues(data);
//Utilities.sleep(10000);
switch (true) {
case action == "car_sarch.car_sarch-maker":
var rc = push(model_search("all",parameters.model).text,userId);
var rc = push("メーカーは、" + model_search("maker",parameters.model) + "です",userId);
break;
case action == "car_catalog":
var r = model_search("all",parameters.model);
var rc = reply(r.text,replyToken);
break;
case action == "usedcar_model":
var url = usedcar_model(parameters.year_old,parameters.odd_max,parameters.model)
var rc = reply(url.len + "件ヒットしました。\n" + url.url +"\n他の車も見てみますか?",replyToken);
break;
case action == "usedcar_body":
var url = usedcar_body(parameters.price_max,parameters.year_old,parameters.odd_max,parameters.body)
var rc = reply(url.len + "件ヒットしました。\n" + url.url +"\n他の車も見てみますか?",replyToken);
break;
case action == "usedcar_body.usedcar_body-yes":
var url = usedcar_body(parameters_old.price_max,parameters_old.year_old,parameters_old.odd_max,parameters_old.body)
var rc = reply("こちらはいかがでしょうか。(" +url.len+"件)\n" + url.url ,replyToken);
break;
case action == "usedcar_model.usedcar_model-yes":
var url = usedcar_model(parameters_old.year_old,parameters_old.odd_max,parameters_old.model)
var rc = reply("こちらはいかがでしょうか。(" +url.len+"件)\n" + url.url ,replyToken);
break;
default:
var rc = reply("該当の項目はお調べできません:"+action,replyToken);
break;
}
return 0;
var look=data.result.parameters.look;
var who=data.result.parameters.who;
if(look=="true"){
lookCheck();
return;
}
else if(who=="true"){
var type=data.result.parameters.type;
var num=data.result.parameters.no;
whoCheck(type, num);
return;
}
}
function doGet(e){
const sheet = SpreadsheetApp.getActiveSheet();
Logger.log(e)
var rc = sheet.getRange('A1').setValue("test");
var rc = sheet.getRange('A2').setValue(e);
var rc = createMessage(e.body)
var rc = sheet.getRange('A3').setValue(e.body);
var jsonString = e.postData.getDataAsString();
var rc = sheet.getRange('A4').setValue(jsonString);
var responseId = jsonString.responseId;
var rc = sheet.getRange('A5').setValue(responseId);
var uid=e.parameter.uid
var how=e.parameter.how
var what=e.parameter.what
var when=e.parameter.when
Logger.log(e.parameter.how)
Logger.log(e.parameter.what)
Logger.log(e.parameter.when)
const range = sheet.getRange('A3');
var rc = sheet.getRange('A3').setValue(uid);
var rc = sheet.getRange('B3').setValue(how);
var rc = sheet.getRange('C3').setValue(what);
var rc = sheet.getRange('D3').setValue(when);
//var rc = createMessage("U72232d47924bf0091d85d57fb9bc4437")
var rc = createMessage(e.parameter.uid)
return 0;
}
// LINE Developersに書いてあるChannel Access Token
var access_token = "〜〜〜"
//送信するメッセージ定義する関数を作成します。
function createMessage(to) {
//メッセージを定義する
message = "おはよう!";
return push(message,to);
}
function test3(){
var url = new Object();
url.var1 = "hello";
return url;
}
function usedcar_model(year_old,odd_max,model){
//カタログURL
var sURL="http://webservice.recruit.co.jp/carsensor/usedcar/v1/?key=〜〜〜&start=1&count=100&order=1&model=" + model + "&year_old=" + year_old + "&odd_max=" + odd_max;
//検索結果を取得する
var response = UrlFetchApp.fetch(sURL);
//XMLを取得する
var ns = XmlService.getNamespace("", "http://webservice.recruit.co.jp/carsensor/");
var xmldocs = XmlService.parse(response.getContentText().replace("", "").replace("", "").replace("", ""));
var results_available = xmldocs.getRootElement().getChildText('results_available', ns);
var doc = xmldocs.getRootElement().getChildren('usedcar', ns);
var length = doc.length;
var k = 0;
var i = Math.floor(Math.random()*length);
var price = doc[i].getChildText('price', ns);
var odd = odd_cnv(doc[i].getChildText('odd', ns));
var id = doc[i].getChildText('id', ns);
var body = doc[i].getChild('body', ns).getChildText('name', ns);
var brand = doc[i].getChild('brand', ns).getChildText('name', ns);
var brand_code = doc[i].getChild('brand', ns).getChildText('code', ns);
var model = doc[i].getChildText('model', ns);
var grade = doc[i].getChildText('grade', ns);
var inspection = inspection_days(doc[i].getChildText('inspection', ns));
var year = doc[i].getChildText('year', ns);
var place = doc[i].getChild('shop', ns).getChild('pref', ns).getChildText('name', ns);
var lat = doc[i].getChild('shop', ns).getChildText('lat', ns);
var lng = doc[i].getChild('shop', ns).getChildText('lng', ns);
var color = color_normalization(doc[i].getChildText('color', ns));
Logger.log(id)
var url = new Object();
url.url = "https://www.carsensor.net/usedcar/detail/" + id + "/index.html";
url.len = results_available;
return url;
}
function usedcar_body(price_max,year_old,odd_max,body){
//カタログURL
var sURL="http://webservice.recruit.co.jp/carsensor/usedcar/v1/?key=〜〜〜&start=1&count=100&order=2&body=" + body + "&year_old=" + year_old + "&odd_max=" + odd_max + "&price_max=" + price_max;
//検索結果を取得する
var response = UrlFetchApp.fetch(sURL);
//XMLを取得する
var ns = XmlService.getNamespace("", "http://webservice.recruit.co.jp/carsensor/");
var xmldocs = XmlService.parse(response.getContentText().replace("", "").replace("", "").replace("", ""));
var results_available = xmldocs.getRootElement().getChildText('results_available', ns);
var doc = xmldocs.getRootElement().getChildren('usedcar', ns);
var length = doc.length;
if(length == 0){
return "条件に合う車両は見つかりませんでした。";
}
var k = 0;
// var i = 0;
var i = Math.floor(Math.random()*length);
var price = doc[i].getChildText('price', ns);
var odd = odd_cnv(doc[i].getChildText('odd', ns));
var id = doc[i].getChildText('id', ns);
var body = doc[i].getChild('body', ns).getChildText('name', ns);
var brand = doc[i].getChild('brand', ns).getChildText('name', ns);
var brand_code = doc[i].getChild('brand', ns).getChildText('code', ns);
var model = doc[i].getChildText('model', ns);
var grade = doc[i].getChildText('grade', ns);
var inspection = inspection_days(doc[i].getChildText('inspection', ns));
var year = doc[i].getChildText('year', ns);
var place = doc[i].getChild('shop', ns).getChild('pref', ns).getChildText('name', ns);
var lat = doc[i].getChild('shop', ns).getChildText('lat', ns);
var lng = doc[i].getChild('shop', ns).getChildText('lng', ns);
var color = color_normalization(doc[i].getChildText('color', ns));
Logger.log(id)
var url = new Object();
url.url = "https://www.carsensor.net/usedcar/detail/" + id + "/index.html";
url.len = results_available;
return url;
}
function model_search(param,model){
// Logger.log(model);
//カタログURL
var sURL="http://webservice.recruit.co.jp/carsensor/catalog/v1/?key=〜〜〜&model=" + model;
//検索結果を取得する
var response = UrlFetchApp.fetch(sURL);
//XMLを取得する
var xmldocs = XmlService.parse(response.getContentText());
var ns = XmlService.getNamespace("", "http://webservice.recruit.co.jp/carsensor/");
var doc = xmldocs.getRootElement().getChildren('catalog', ns);
var len = doc.length;
//Logger.log("catalog:len:" + doc.length);
if(len == 0){
return "該当のモデルは見つかりませんでした。";
}
i=0;
var model = doc[i].getChildText('model', ns);
var price = doc[i].getChildText('price', ns);
//Logger.log("catalog:model:" + model + ",price:" + price);
var brand_code = doc[i].getChild('brand', ns).getChildText('code', ns);
var brand = doc[i].getChild('brand', ns).getChildText('name', ns);
var grade = doc[i].getChildText('grade', ns);
var body = doc[i].getChild('body', ns).getChildText('name', ns);
var person = doc[i].getChildText('person', ns);
var period = doc[i].getChildText('period', ns);
var period_from = period.split("-")[0].substr(0,4)+"/"+period.split("-")[0].substr(4,2)+"/01";
if(period.split("-")[1]=="999999"){
var period_to = " - ";
}else{
var period_to = period.split("-")[1].substr(0,4)+"/"+period.split("-")[1].substr(4,2)+"/01";
}
var width = doc[i].getChildText('width', ns);
var height = doc[i].getChildText('height', ns);
var length = doc[i].getChildText('length', ns);
var photo = doc[i].getChild('photo', ns).getChild('front', ns).getChildText('l', ns);
//var photo = doc[i].getChild('photo', ns).getChild('front', ns).getChildText('s', ns);
switch (true) {
case param == "maker":
return "メーカーは、" + brand;
break;
case param == "price":
return "販売価格は、" + price/10000 + "万円";
break;
case param == "body":
return "ボディータイプは、" + body;
break;
case param == "person":
return "乗車人数は、" + person;
break;
case param == "period":
return "販売期間は、" + period_from+":"+period_to;
break;
case param == "size":
return "サイズは、W:"+width+"、H:"+height+"、L:"+length;
break;
case param == "all":
var url = usedcar_model("2001","100000",model);
var r = new Object();
r.text = model+"は、"+brand+"から"+period_from+"から"+period_to+"まで"+price/10000+"万円で販売された"+person+"人乗り"+body+"で、サイズはW:"+width+"、H:"+height+"、L:"+length+"です。\n"+photo+"\n"+url.url;
r.url = photo;
return r;
break;
//シビックは、ホンダからいつからいつまでいくらで販売された、○人乗り、サイズ○○のハッチバックです。
default:
return "null";
break;
}
}
function u2d(e){
// var FORMAT = "YYYY-MM-DDTHH:mm:ss.SSSZ";
var FORMAT = "YYYY-MM-DDTHH:mm:ss";
var COLOR = "#84dccf";
var num = e / 1000;
var m = Moment.moment(num, "X").utc();
var u = m.valueOf() * 0.001;
var d = m.format(FORMAT);
return d;
}
// LINE Developersに書いてあるChannel Access Token
var access_token = "〜〜〜"
//送信するメッセージ定義する関数を作成します。
function createMessage(to) {
//メッセージを定義する
message = "おはよう!";
return push(message,to);
}
function reply(text,replyToken){
var url = "https://api.line.me/v2/bot/message/reply";
var headers = {
"Content-Type" : "application/json; charset=UTF-8",
'Authorization': 'Bearer ' + access_token,
};
//toのところにメッセージを送信したいユーザーのIDを指定します。(toは最初の方で自分のIDを指定したので、linebotから自分に送信されることになります。)
//textの部分は、送信されるメッセージが入ります。createMessageという関数で定義したメッセージがここに入ります。
var postData = {
"replyToken":replyToken,
"messages" : [
{
'type':'text',
'text':text,
}
]
};
var options = {
"method" : "post",
"headers" : headers,
"payload" : JSON.stringify(postData)
};
return UrlFetchApp.fetch(url, options);
}
//実際にメッセージを送信する関数を作成します。
function push(text,to) {
var url = "https://api.line.me/v2/bot/message/push";
var headers = {
"Content-Type" : "application/json; charset=UTF-8",
'Authorization': 'Bearer ' + access_token,
};
//toのところにメッセージを送信したいユーザーのIDを指定します。(toは最初の方で自分のIDを指定したので、linebotから自分に送信されることになります。)
//textの部分は、送信されるメッセージが入ります。createMessageという関数で定義したメッセージがここに入ります。
var postData = {
"to" : to,
"messages" : [
{
'type':'text',
'text':text,
}
]
};
var options = {
"method" : "post",
"headers" : headers,
"payload" : JSON.stringify(postData)
};
return UrlFetchApp.fetch(url, options);
}
//実際にメッセージを送信する関数を作成します。
function image(text,to) {
var url = "https://api.line.me/v2/bot/message/push";
var headers = {
"Content-Type" : "application/json; charset=UTF-8",
'Authorization': 'Bearer ' + access_token,
};
//toのところにメッセージを送信したいユーザーのIDを指定します。(toは最初の方で自分のIDを指定したので、linebotから自分に送信されることになります。)
//textの部分は、送信されるメッセージが入ります。createMessageという関数で定義したメッセージがここに入ります。
var postData = {
"to" : to,
"messages" : [
{
"type": "image",
"originalContentUrl": text,
"previewImageUrl": text
}
]
};
var options = {
"method" : "post",
"headers" : headers,
"payload" : JSON.stringify(postData)
};
return UrlFetchApp.fetch(url, options);
}
まとめ
Chatbotの基本さえ掴めば、Botアプリは簡単に作れました。
そしてこのアプリのベースは、何よりカーセンサーAPIにあります。
こんな膨大で役に立つ情報を無償で提供してくれるリクルート様に感謝です。