3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

MDCAdvent Calendar 2020

Day 16

カーセンサーのまわし者みたいなChatbotを作ってみた

Last updated at Posted at 2020-12-15

この記事はMDC Advent Calendar 2020 16日目の記事です。

はじめに

ChatBotに最適な構成について結論が出たので、実装してみることにしました。

作るもの

最近カーセンサーAPIを弄っており中々面白そうなので、カーセンサーのまわし者みたいなアプリを作ってみることにします。

  • 車のモデル名からカタログ情報を教えてくれる(例:プリウスについて教えて)
  • 車のモデル名、もしくはカテゴリから、中古車情報を教えてくれる(例:100万のセダン、プリウスの中古車を教えて)

そしてできたものはこちらです。
(2021/03追記)2020年末にカーセンサーAPIのサービスが終了したので、このアプリは動作しません。

誰でもお友達登録すれば使えますのであの懐かしの名車を訊ねてみてください。

image.png
image.png

構成

前回の結論から同じ構成にしました。
DialogflowとGASを行ったり来たりしながら作ります。
Chatbot構成図 (2).png

GAS部分のメイン構成はこんな感じです。

doPost()
├actionがカタログ情報なら
│ └カタログ情報を調べて返す
├actionがモデルから中古車を探すなら
│ └モデルから中古車を調べて返す
├actionがモデルから中古車を探す、の2回目なら
│ └モデルから中古車を調べて返す
├actionがボディタイプから中古車を探すなら
│ └ボディタイプから中古車を調べて返す
├actionがボディタイプから中古車を探す、の2回目なら
│ └ボディタイプから中古車を調べて返す
└その他
  └エラーハンドリング

工夫したところ

画像を出したい

車が見つかったら、画像を出して憧れの名車へ思いを馳せるお手伝いをしたいところです。
もしくはヒットした車両がとんでもない魔改造された曰く付きかも知れません。
しかし、LINEの仕様でhttpsでホストされた画像をURLで渡す必要があり、カーセンサーの画像URLはhttpだったのでダメでした。
Google画像検索で見つけて差し替える手もありましたがそれでは似て非なるものなので断念しました。

デバッグしやすいようにログを取りたい

Dialogflowにも会話の履歴やGASに渡されたJSONは残ってますが、必要な情報だけぱっと見できないのでスプレッドシートに追記していきます。これによって開発が捗ります。
タイムスタンプについては、LINEの仕様でエポック秒で返ってくるのでいい感じに変換します。
この勢いで、欲しい車の条件を記録しておいて、定期的にウォッチして新しい車を見つけたらプッシュ通知してくれる、なんてのも便利だなと思ってます。
image.png

ユーザーが使い方わからなくても使い始められるようにしたい

Chatbotで最も重要で、そして難しいところですが、Dialogflow、Lineの機能を利用してそこそこわかりやすく仕上げることができます。

  • LINEで「あいさつメッセージ」を設定(友だち登録した直後の1回のみ)
    image.png

  • 応答に「Card」を使う(選択肢をクリックするだけで、設定した入力を代わりにしてくれる)
    IMG_5052.PNG
    image.png

中古車を探すときの条件は何か?

ある程度、条件を絞ってあげないとユーザーが欲しい情報を返せないし、かといってあんまり質問攻めにするのも使いにくい。
今回は、2ケースに絞ってロジックを用意しました。

  • 欲しいモデル(プリウスとか)が決まっている
  • 欲しいモデルが決まってないが、ボディタイプ(セダンとか)が決まっている

それぞれリクエスト形式が違ってくるのでインテントは分け、GAS側も別の関数で用意しました。
image.png
image.png
→モデル名、年式、走行距離をパラメータに取り込みます。
モデル名は選択式にするのは諦めて、@sys.any:なんと言おうがパラメータに入れ込む、という設計になってます。
インテントのモデル名にハイライトが付いていますが「〜を買いたい」の「〜」がモデル名である、と設定してあることで、文章の中からモデル名部分が引き抜かれて設定できます。
そして安い順に上位100件を取得するようにしました。

image.png
→ボディタイプも同様です。
一方ボディタイプについては10通りしかないので、エンティティを個別設定しました。
そしてこちらは上限の予算を設定するようにして、価格が高い順に上位100件を取得するようにしました。

image.png

他の候補車両を返したい

中古車が複数ヒットした場合、全量出しても受け止めきれないのでランダムに選んだ1台だけ返すことにします。
すると次のアクションでは「他にないの?」となるのが自然でしょう。
そこで、「他の車は?」などをトリガーに次の候補を出すことにします。

しかし、パラメータはインテントが片付けば空にリセットされます。
つまり、usedcar_bodyで条件を聞き出してパラメータに詰め、GASを呼び出す⇨「他の車は?」⇨後続の子インテント「usedcar_body - yes」で受け取る⇨検索条件が格納されたパラメータは空。

image.png

そこで「usedcar_body - yes」ではinput contextで親のパラメータを引き継ぐよう設定することで、リクエストの「outputContexts」にパラメータが残り、取り出すことができます。
ただし何回呼ばれたか、は特定できなかったので、どの車を出すかはランダムにしました。

image.png

sample.json
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のサンプルコード
GAS.js


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("&#11", "").replace("&#11", "").replace("&#11", "")); 
  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("&#11", "").replace("&#11", "").replace("&#11", ""));
  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にあります。
こんな膨大で役に立つ情報を無償で提供してくれるリクルート様に感謝です。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?