Node.js
cloudfunctions
GoogleAssistant
GoogleHome
dialogflow

Google Home に東急バスの到着時間を教えてもらう

はじめに

最近、引っ越ししてバスに乗ることが多くなったんだけど、そこで気付いたのは、バスって電車と違って渋滞とか天候とかの外的要因に影響されやすく時刻表通りに来ないということ。なので、時刻表通りにバス停にいても待ちぼうけを食らうことが多い。そんな中、普段じぶんが使っている東急バスには、東急バスナビ という、バスのリアルタイムの位置情報をもとに、指定したバス停にあと何分でバスが到着するかを知ることができる Web ページベースのサービスがある。ただ、出掛けようとしている時にスマホでアプリを開いて確認するという作業は、時間もかかるし結構面倒なのである(子供を抱っこしているときはなおさら)。ということで、スマートスピーカーで音声だけでさくっと確認できれば楽だなぁと思って、東急バスナビの情報を使わせてもらって自分用のスマートスピーカー用アプリを作ってみた。なお、東急バスではないけども、同じようなことを考えて既に作っている人はいて ( この記事 とか この記事 とか )、特に本記事自体に目新しさはないんだけど、自分の記録用メモ 兼 今後同じようなアプリを作リたい人(特に東急バスユーザー)向けに少しでも参考になればと思い、残しておきます。

完成形

先に完成形をお見せしておくと、このような会話になります。
#アプリ名(=Actions on Google の名前)は ”次のバスの時間” としました。

image.png

構築手順

大きく分けて、

・Actions on Google の設定
・Dialogflow の設定
・Fulfillment (= Web サーバー) の構築

の3つになります。

Actions on Google の設定

新規プロジェクトの作成

まず、Actions on Google の新規プロジェクトを作成します。
Actions on Google のコンソール にログインして、「Add/import project」をクリックします。
image.png

プロジェクト名の入力と言語と国の設定

「New Project」というダイアログ画面が現れるので、プロジェクト名を入力して、言語を "Japanese"、国を "Japan" に設定して、「CREATE PROJECT」ボタンをクリックします。
image.png
次の画面は、右上の「SKIP」 でよいです。
image.png

これで、プロジェクトの作成は終わりです。

Invocation の設定

ここでは、アプリの起動フレーズを設定します。
左メニューの一番上にある「Invocation」を選択します。
image.png

Display name の設定

ここで設定した名前が、”OK Google, 次のバスの時間 につないで” のように、アプリの起動に使用されます。
image.png

Google Assistance voice の設定

お好きな声のタイプを選択してください。
終わったら、右上にある「SAVE」ボタンを忘れずにクリックします。

Action の作成

続いて Action を設定します。この Action で、具体的な会話のやり取りを構築するのですが、Actions on Google 側で設定することはほぼ何もなく、ほとんどの作業は、Dialogflow 側になります。

左メニューの中から「Actions」を選択して、「ADD YOUR FIRST ACTION」ボタンをクリックします。
image.png

「CREATE ACTION」というダイアログ画面が開くので、そのまま、右下の「BUILD」ボタンをクリックします。
image.png

そうすると、Dialogflow のコンソール画面に遷移します。Actions on Google の設定はここまででいったん完了です。

Dialogflow の設定

基本設定

「DEFAULT LANGUAGE」を「Japanese - ja」に設定、右上の「CREATE」ボタンをクリックします。
image.png

Intent の作成

3つの Intent を作成します。

  • アプリを起動した際の Intent
  • 行き先別のバスの到着時間を尋ねる Intent
  • 会話を終了する Intent

左メニューの中から「Intents」を選択します。
image.png

アプリを起動した際の Intent の作成

”OK Google, 次のバスの時間につないで” と言って、アプリを起動した際に呼ばれる Intent を作成します。
アプリを起動した際に呼ばれる Intent は、あらかじめ用意されている「Default Welcome Intent」になりますので、こちらを選択します。

ユーザーへの応答の設定

ユーザーへの応答を「Responses」で設定します。ユーザーへの応答としては、まず、ユーザーに行き先(降車停留所)を聞きたいので、”行き先はどちらですか?” と聞くことにします。なお、このアプリは家で使用することを前提としており、乗車停留所は、家の最寄り停留所固定としています。行き先(降車停留所)は変えられるようにしたので、同じようにして乗車停留所も可変にすることは容易に可能です。
image.png

設定が完了したら、右上の「SAVE」ボタンをクリックして保存しておきます。

行き先別のバスの到着時間を尋ねる Intent の作成

行き先(降車停留所)を指定して、行き先別のバスの到着時間を尋ねる Intent を作成します。この Intent が本アプリのメインロジックを担当することになります。ここでは、あらかじめ用意された Intent ではなく、新しい Intent を作成するので、再度左メニューの「Intents」を選択して、右上の「CREATE INTENT」ボタンをクリックします。

一番上の Intent name に適当な名前を設定します。「Ask Next Bus Time」とでもしておきます。ここまでで、一旦、右上の「Save」ボタンで保存しておいて、次に、バス停 Entity を設定します。あとで、Intent の設定に戻ってきます。
image.png

バス停 Entity の設定

ユーザーが行き先のバス停(降車停留所)を指定できるように、バス停の名前を Entity で登録します。

Entity の新規作成

左メニューの「Entities」を選択し、右上の「CREATE ENTITY」ボタンをクリックします。
image.png

Entity の設定

まず、一番上の「Entity name」で、Entity の名前を設定します。"BusStop" とでもしておきます。
次に、バス停の名前を登録します。「Click here to edit entry」となっている部分をクリックし、左側の「Enter reference value」に参照値(Google Assistant (Dialogflow) から Fulfillment へのリクエストの際に使用される値になります)を、右側の「Enter synonym」となっている部分に、発話の揺らぎを考慮したシノニム(同義語、別名)を設定します。このように設定することにより、ユーザーが、”大井町駅” と発話しても ”大井町” と発話しても、Dialogflow 側で言葉の揺らぎを吸収して、どちらも ”大井町駅” として認識してくれます。行き先として利用するバス停の数分、この登録作業を実施します。下の例では2つほど設定しています。
image.png

設定が完了したら、右上の「SAVE」ボタンを押して保存します。Intent の設定の続きに戻ります。
左メニューの「Intent」を選択して、先ほど作成した「Ask Next Bus Intent」を選択します。
image.png

Action and parameters の設定

先ほど設定した バス停を行き先として指定できるようにします。完成形はこんな感じです。
image.png

Action は今回は使用しないので、「Enter action name」の部分は空のままでよいです。今回は Parameter だけ設定します。それぞれの項目について説明すると、まず、ENTITY の項目には、プルダウンの中から先ほど設定した Entity (@BustStop) を選択します。REQUIRED の項目にはチェックを入れます。REQUIRED の項目が発話に含まれない場合、その項目を含めるようユーザーに催促できますので、その催促文を PROMPTS の項目で設定します。PROMPTS と複数形になっているように、催促文は複数指定できて、ランダムでどれかが使用されます。

image.png

Training phrases の設定

ユーザーの発話フレーズを登録します。行き先のバス停の単語を含めた発話フレーズを登録し、その単語の部分には、先ほど登録した Parameter (@BusStop:destination) を設定します。単語を選択すると、プルダウンが表示されるので、その中から選択すればよいです。
image.png

Fulfillment の設定

「Enable webhook call for this intent」を ON にします。
image.png

Fulfillment の構築については、こちらで後述します。

これで、行き先別のバスの到着時間を尋ねる Intent の設定は完了です。

会話を終わらせる Intent の作成

最後に、会話を終わらせる Intent を作成します。先ほどと同じように、あらかじめ用意された Intent ではなく、新しい Intent を作成するので、再度左メニューの「Intents」を選択して、右上の「CREATE INTENT」ボタンをクリックします。
image.png
この Intent で設定するのは「Intent name」と「Training phrases」と「Responses」の3つになります。会話を終了する際のユーザーの発話フレーズを「Training phrases」で定義し、その返事を「Responses」で定義します。なお、この Intent で会話が終了するので、「Responses」の「Set this intent as end of coversation」を ON にしてください。設定した完成形はこんな感じです。
image.png

Fulfillment (= Web サーバー) の構築

指定した行き先に行くバスの最寄り停留所への到着時間を教えてくれる Fulfillment (= Web サーバー) を構築します。バスの到着時間は、リクエストのたびに、オンデマンドで 東急バスナビ を Web スクレイピングして取得することにします。下記のようなページにアクセスして、バスの到着時間を抽出します。
image.png
バスの位置情報はリアルタイムで刻々と変化するため、バッチ処理等で事前に取得しておくことができません。そのため、応答性は多少落ちてしまいますが、オンデマンド型にせざるを得ません。この処理を動かす Web サーバーは、Cloud Functions for Firebase を利用することにし、ビルドとデプロイは、Dialogflow の Inline Editor を利用することにしました。Inline Editor を利用するには、Dialogflow の左メニューの「Fulfillment」を選択し、Inline Editor (Powered by Cloud Functions for Firebase) を ENABLED にします。
image.png

コードの完成形が下記となります。こちらを Inline Editor に記述し、右下の「DEPLOY」ボタンをクリックしてデプロイします。

'use strict';

const functions = require('firebase-functions');
const {WebhookClient} = require('dialogflow-fulfillment');
const client = require('cheerio-httpcli');

process.env.DEBUG = 'dialogflow:debug';

const DSMK = 2890;
const RAMK = 124;

const terminalMap = new Map();
terminalMap.set("大井町駅", ["大井町", "品川駅"]);
terminalMap.set("大森駅", ["蒲田駅", "池上駅", "池上営"]);

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
  console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
  console.log('Dialogflow Request body: ' + JSON.stringify(request.body));

  const agent = new WebhookClient({ request, response });

  function askNextBusTime(agent) {
    const destination = agent.parameters.destination;
    console.log('destination: ' + destination);

    let response;

    const nextBusTimes = getNextBusTimesFromTokyuBusNavi(terminalMap.get(destination));
    if (nextBusTimes.length > 0) {
      response = destination + ` 行きの次のバスは` + nextBusTimes[0] + `分後に来ます。`;
      if (nextBusTimes.length > 1) {
       response += `その次のバスは` + nextBusTimes[1] + `分後`;
        if (nextBusTimes.length > 2) {
          response += `、さらにその次のバスは` + nextBusTimes[2] + `分後`;
        }
        response += `に来ます。`;
      }
    } else {
      response = destination + `行きの次のバスは発車準備中です。`;
    }

    agent.add(response);
  }

  function getNextBusTimesFromTokyuBusNavi(terminals) {
    const nextBusTimes = [];

    const url = 'http://tokyu.bus-location.jp/blsys/navi?VID=rsl&EID=nt&DSMK=' + DSMK + '&RAMK=' + RAMK;
    const res = client.fetchSync(url);
    res.$('dd').each(function() {
      const text = res.$(this).text();
      if (text.includes("分待ち")) {
        terminals.forEach(function(terminal) {
          if (text.includes(terminal)) {
            nextBusTimes.push(Number(text.substr(text.length-5, 2)));
          }
        });
      }
    });

    nextBusTimes.sort(function(a, b) {
      return (a > b) ? 1 : -1;
    });

    return nextBusTimes;
  }

 const intentMap = new Map();
  intentMap.set('Ask Next Bus Time Intent', askNextBusTime);
  agent.handleRequest(intentMap);
});

たいしたことは特にしていないのですが、一応ポイントを説明しておきます。

Web ページの URL の設定

東急バスナビの Top ページ -> 路線バス位置情報 -> バス停名称 で、乗車バス停名称で検索して系統を指定すると、以下のような URL で路線別運行情報ページになるので、この URL から、DSMK と RAMK の値を抜き出して下記のコードの部分を差し替えると、乗車するバス停をカスタマイズできます。
http://tokyu.bus-location.jp/blsys/navi?VID=rsl&EID=nt&PRM=&SCT=1&DSMK=2890&DSN=%E4%B8%89%E3%83%83%E5%8F%88&ASMK=0&ASN=null&FDSN=0&FASN=0&RAMK=124

const DSMK = 2890;
const RAMK = 124;

バスの特定

本記事で記載した例は、比較的複雑な路線で複数の系統のバスが走っているのですが、そのような場合、どのバスが指定したバス停(降車停留所)に停車するかを指定する必要があり、このような Map で指定しています。MAP の key に指定している値が、Dialogflow の BusStop Entity で設定した参照値になります。この例では、"大井町駅" には、"大井町行" と "品川駅行" のバスが、"大森駅" には、"蒲田駅行" と "池上駅行" と "池上営行" のバスが、停車することを意図しています。

const terminalMap = new Map();
terminalMap.set("大井町駅", ["大井町", "品川駅"]);
terminalMap.set("大森駅", ["蒲田駅", "池上駅", "池上営"]);

上述の Web ページの URL の設定とあわせて、この二つをご自身の環境に合わせてカスタマイズすれば、使用できると思います。

次に来るバスと、その次に来るバス、さらにその次に来るバスまで抽出

以下のコードでは次に来るバスと、その次に来るバス、さらにその次に来るバスまで抽出してユーザーに返答しています。当初は、最初に来るバスだけ返答して、それ以降は、ContextsFollow-up intents を使って、「次のバスは?」と続けざまに聞く仕様にしようと思ったのですが、最初に来るバスの到着時間までにバス停に向かえることがあまりなく、その次以降のバスを聞くことがほとんどなので、最初から次に来るバスと、その次に来るバス、さらにその次に来るバスまで返答するようにしました。

    const nextBusTimes = getNextBusTimesFromTokyuBusNavi(terminalMap.get(destination));
    if (nextBusTimes.length > 0) {
      response = destination + ` 行きの次のバスは` + nextBusTimes[0] + `分後に来ます。`;
      if (nextBusTimes.length > 1) {
       response += `その次のバスは` + nextBusTimes[1] + `分後`;
        if (nextBusTimes.length > 2) {
          response += `、さらにその次のバスは` + nextBusTimes[2] + `分後`;
        }
        response += `に来ます。`;
      }
    } else {
      response = destination + `行きの次のバスは発車準備中です。`;
    }

スクレイピング

スクレイピングには、cheerio-httpcli を使用しています。指定したバス停に停車するバスの到着時間までの時間を抽出しています。

    const url = 'http://tokyu.bus-location.jp/blsys/navi?VID=rsl&EID=nt&DSMK=' + DSMK + '&RAMK=' + RAMK;
    const res = client.fetchSync(url);
    res.$('dd').each(function() {
      const text = res.$(this).text();
      if (text.includes("分待ち")) {
        terminals.forEach(function(terminal) {
          if (text.includes(terminal)) {
            nextBusTimes.push(Number(text.substr(text.length-5, 2)));
          }
        });
      }
    });

なお、コードとは関係ありませんが、Cloud Functions for Firebase では Google のネットワーク外への通信は有料プランにしないと行えないので、Firebase で Project を選択し、左メニューの下をクリックし従量制に変更します。個人で使用する分には、従量制でよいと思います。
image.png

おわり

乗車バス停、降車バス停が決まっていて個人で使用する分には、今のところこれで満足しています。全路線、全バス停に対応したアプリを作って公開しようと一瞬頭をよぎりましたが、そもそも Web スクレイピングしていますし、東急バスナビの免責事項に「当社の許諾を得ないで、本サービスを通じて提供されるいかなる情報も、著作権法で定める利用者個人の私的利用の範囲外の使用は出来ません。」としっかり記載されているので、あきらめました。東急さんは、既に Google Assistant アプリで 東急線アプリ という東急各線の運行状況を案内するアプリを出されているので、東急バス版も出しくれないかなぁと思っています。