GoogleHomeMiniにバスの遅延を調べてもらう

#はじめに

GoogleHomeMiniに「次のバスは？」と尋ねると、最寄りのバス停の予定時刻と遅延状況を答えてもらうようにした。
その実現方法を、主に自分の将来のメンテナンス用メモとして残しておく。探せばもっと良い手法が有るのかなぁとも思いつつ。

## 背景

バス通勤する者の悩みとして、バスの遅延が推測しづらいと言うことがある。夕方から雨という天気予報が出ている日は遅くなる、といった法則性はあるが、遅れの幅は日によってまちまちである。
幸い、私が使う国際興業バスはバス停ごとの時刻表・運行状況をネットで公開しているので、それを見れば遅延状況は把握できる。スマホが手元にあれば、だが。

Googleアシスタントには、電車やバスの時刻を調べて答えてくれるアプリはある。しかし、朝の忙しいときに、路線やバス停を声で細かく指定するなんてのは面倒だ。また、手が塞がっていてスマホを操作できないタイミングもある。

そこで、「次のバスは？」と尋ねると、最寄りのバス停の予定時刻と遅延状況を答えてもらうようにした。

なお、バス停は２箇所２路線あるので、サブのバス停も応答可能なものを目指した（妥協あり）

##準備したもの

最終的に利用したのは、次の通り。

+ Google Home mini
+ Googleアカウント（Google Home miniと連携済み）
+ webブラウザ（Chrome）
+ クレジットカード（今の使い方だと無料枠内に納まるとは言え、カード番号の登録が必要。）

#詳細

## Actions on google

まずは Actions on google を開き、"Add/import project"でプロジェクトを作成する。
![ActionsOn-AddProj.png](https://qiita-image-store.s3.amazonaws.com/0/229508/69172235-26f2-9165-82b1-c7bb1f8a61e9.png)

プロジェクト名は自分が覚えやすいように「buscheck」などとしておく。

カテゴリ選択画面はSKIP。

プロジェクトの画面が開く。

### プロジェクトの画面 

#### SETUP - LANGUAGE

単純にjapanese選んで右上の「SAVE」を押す。

#### SETUP - invocation 

+ invocation name は、「次のバスは」
+ Directory title も同じのにしておく
+ Google Assistant voice はお好みのでよさそう...Femaleにしとくか..

そして右上の「SAVE」を押す。

#### BUILD - actions

真ん中に表示されている「ADD YOUR FIRST ACTION」を押す
![ActionsOn-BuildActions.png](https://qiita-image-store.s3.amazonaws.com/0/229508/3b2d3391-e212-e3eb-76e4-8463a00a4e81.png)

CREATE ACTIONというダイアログが開く。近いサンプルを選べと言うことだが、見つけられなかったので「Custom intent」を選択し BUILD ボタンを押す。

すると Dialog flow の画面が開いた。

## Dialog flow、まずは特定のバス停のみ対応

### 最初の画面
![DialogFlow-1st.png](https://qiita-image-store.s3.amazonaws.com/0/229508/d23d87b9-d25b-c43f-e292-f5a6114ca12d.png)

+ DEFAULT LANGUAGE は Japanese - ja を選択
+ DEFAULT TIME ZONE は TOKYO

そして右上の「CREATE」を押す。しばらくしたら作成が終了する。

### Intents

#### Default Fallback Intent

現時点で、右端の列の上「Try it now」に「次のバスは」と文字入力すると、「すみません。よく分かりませんでした。」などの応答がある。これは Default Fallback Intent の候補からランダムで表示されている。うまく認識できなかったときに喋らせたい文言なので、好みに応じて書き換える。

#### Default Welcome Intent

アプリが接続された直後の応答を、ここに定義する、らしい。
今回は少しでも早く応答を返して欲しいので、ここを拡張してバス時刻を応答させることにした。

#### Default Welcome Intent - Fulfillment 

Intent画面 の一番下に「Fulfillment」という項目がある。
"Enable webhook call for this intent"をONにする。

上部のSAVEボタンで保存する。

### Fullfillment

ここでデータ取得・応答文字列の生成を実装することとなる。
今回は Inline Editorで実装する。ただし、スクレイピング処理を成功させるには、Google Cloud Functionsの料金プランを有料プランにする必要がある。

Inline Editor の右のswitchをONにする。

末尾のほうのこの行を探す
```js
    intentMap.set('Default Welcome Intent', welcome);
```
この、第２引数welcome部分を自分の関数で置き換えることになる。

#### Fullfillment 編集時にハマったこと

+ Deployボタンを押してから反映までに時間がかかる。完了したと表示されても、しばらく変更前の処理が実行されたりした。
+ 関数の動作は問題無さそうなのに、DialogFlow側の結果がおかしいと感じたら、画面右下[DIAGNOSTIC INFO]のボタンを押して webhookStatus を確認する。
+ Inline Editorで編集した関数のログを手早く開くには、画面左下[View execution logs in the Firebase console]　のリンクを押せば良い。Deployを一度行えば出現するはず。
    +  ![DialogFlow-Fullfillment-ViewExecutionLogs.png](https://qiita-image-store.s3.amazonaws.com/0/229508/292db9b5-afe7-a30f-b866-da74bdd50ae7.png)

#### Fullfillment 置き換え後

```json:package.json(末尾)
{
（途中省略）
  "dependencies": {
    "actions-on-google": "2.0.0-alpha.4",
    "firebase-admin": "^4.2.1",
    "firebase-functions": "^0.5.7",
    "dialogflow": "^0.1.0",
    "dialogflow-fulfillment": "0.3.0-beta.3",
    "cheerio-httpcli": "^0.7.2"
  }
}
```

(注意)
+ package.jsonはcheerio-httpcli以外は初期状態のままで良いです。actions-on-googleなどのバージョンは上がっていますが、2019.02.03現在、以降のスクリプトも問題なく動作しています。（本行追加 2019.02.03）
+ ネットにUpするため、あえて普段使わない駅とバス停を指定した。普段使わないバス停だけど動作はするはず。。
+ 国際興業バスの運行状況ページへのリンクは割愛します。

```js:index.js
var client = require('cheerio-httpcli');
const nerima01 = "（練馬駅０１の運行状況ページのURL）";

function getTimetable() {
  let standURL = nerima01;
  let resStopName = "練馬駅０１";

  // スクレイピング開始
  return new Promise(function(resolve, reject){
      client.fetch(standURL, {}, function (err, $, res) {
        let busList = [];
        $('table.R_Table').each(function(tbl_idx) {      
          const tableRoot = $(this);
          tableRoot.find('tr').each(function(tr_idx) {
            const ths = $(this).find('th');
            if (ths.length > 1) return;
    
            const rowitems = [];
            const oneRow = $(this);
            oneRow.find('td').each(function(td_idx) {
              let tmpStr = $(this).text();
              rowitems.push(tmpStr.trim());
            });
            // 行き先を短縮したい
            const dest = ( /赤羽駅西口/.test(rowitems[3]) ) ? "赤羽" : "北町車庫";
            const resObj = {
              dest,
              plan: rowitems[0],
              prospect: rowitems[1],
              comment: rowitems[5]
            };
            busList.push(resObj);
          });
        });
        console.log(busList);
        return resolve({resStopName, busList});
      });
  });
}

（途中省略）
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
  function getNextBusInfoHandler(agent) {

    return getTimetable().then(resObj => {
        console.log("getTimetable successed");
        const {resStopName, busList} = resObj;         
        console.log(busList);

        let resMessage = `${resStopName}発\n`;
        // const len = busList.length;
        const maxLen = Math.min(busList.length, 3);
        for (let i = 0; i < maxLen ; i++) {
            const onePlan = busList[i];
            let commentStr = "";
            if (onePlan.comment.length < 3) {
                commentStr = "不明";
            } else {
                commentStr = onePlan.comment.slice(0, -3);
            }
            const planTime = onePlan.plan.split(":");
            let onePlanMessage = `${onePlan.dest}${planTime[1]}分発 ${commentStr}\n`;
            resMessage += onePlanMessage;
        }
        if ( maxLen === 0) {
            resMessage += "データがありません"; 
        }
        agent.add(resMessage);
        return;
    }).catch(error => {
        console.log(error);
        agent.add("バスの時刻表検索に失敗しました。");
        return;
    });     
  }
  （途中省略）
  let intentMap = new Map();
  //　intentMap.set('Default Welcome Intent', welcome);
  intentMap.set('Default Welcome Intent', getNextBusInfoHandler);

```


このコードをDeployし、[Try it now]で"次のバスは？"と入力する。
すると、「こんにちは！」としか表示されない。

右下を見ると次のように表示されている。

```
FULFILLMENT
Cloud function	failed
Billing is not configured. External network traffic is not available and quotas are severely limited. Configure billing account to remove these restrictions.
```
![DialogFlow-DIAGNOSTIC_FAILED.png](https://qiita-image-store.s3.amazonaws.com/0/229508/c6cbde94-42bb-1b38-6119-85002179d824.png)

Functionsの料金プラン「無料」（初期値）だとスクレイピングは出来ないというエラー。なので料金を変更する。


<font color="Red">2019.02.03追記</font>
先ほど試したところ、別のエラーが出ていました。client.fetchの結果を処理する所で
```
 TypeError: $ is not a function
```
というエラーでした。
ただ、解決法は同じで、Functionsの料金プランを変える、です。


#### Functionsの料金プラン変更

画面左下[View execution logs in the Firebase console]　のリンクを押すなどして、Firebase Functionsの画面に移動する。

左下「GROW」のところの「アップグレード」ボタンを押す。
![Functions-price.png](https://qiita-image-store.s3.amazonaws.com/0/229508/e82feb49-e778-48c4-7364-ddb52c412c52.png)

Blaze 従量制を選択。個人の家で使う分には、無料枠で納まるだろうと思う・・


もいちど Fullfillment に戻って、実行確認。
これで[練馬駅０１発 北町車庫45分発 定刻]などの答えが返ってくれば、第一段階は終了。



##  Dialog flowでバス停２つに対応させる

２つめのバス停かどうかを判定して、対応するバス停の情報を取得したい。
ただ、私の場合２つめのバス停は、１つめほどは利用しない。
ここまで作成したものをベースに拡張していく。

### Entities

Entitiesを使って、バス停を登録する。

まずは + ボタンを押して追加から。

#### Entity name

分かり易く busstop としておく。

#### Entity

まずは正式名称を登録。その右に、略称・愛称といった感じのものを登録していく。
改めて書くけど、ネットにUpするため、あえて普段使わない駅とバス停にしている。

![DialogFlow-Entities.png](https://qiita-image-store.s3.amazonaws.com/0/229508/abbf9e85-0e11-f6da-3015-c47d569b063f.png)

### Intents

#### Default Welcome Intent

Training phrases の所に、さきほどEntityで設定した略称・愛称のどれかを含む文言を設定する。Entityの文言だと自動判定されたら黄色いハイライトが着く。
![DialogFlow-Intent-Training.png](https://qiita-image-store.s3.amazonaws.com/0/229508/e39034c0-e22a-e49e-afc2-e4366c8bf9c5.png)

そしてSAVE

### Fullfillment

前回作成した関数をちょっといじるだけで済むはず・・

```js:index.js

var client = require('cheerio-httpcli');
const nerima01 = "（練馬駅０１の運行状況ページのURL）";
const kuyakusho02 = "（練馬区役所入り口０２の運行状況ページのURL）";

function getTimetable(busStopName) {
  let standURL = nerima01;
  let resStopName = busStopName;
  switch (busStopName) {
    case "練馬駅":
      standURL = nerima01;
      break;
    case "練馬区役所入口":
      standURL = kuyakusho02;
      break;
    default:
      resStopName = "練馬駅";
      standURL = nerima01;
      break;
  }
  
  // スクレイピング開始
  return new Promise(function(resolve, reject){
      client.fetch(standURL, {}, function (err, $, res) {
        console.log("err**************");
        console.log(err);
        console.log("res**************");
        console.log(res);
        let busList = [];
        $('table.R_Table').each(function(tbl_idx) {      
          const tableRoot = $(this);
          tableRoot.find('tr').each(function(tr_idx) {
            const ths = $(this).find('th');
            if (ths.length > 1) return;
    
            const rowitems = [];
            const oneRow = $(this);
            oneRow.find('td').each(function(td_idx) {
              let tmpStr = $(this).text();
              rowitems.push(tmpStr.trim());
            });
            // 行き先を短縮したい
            const dest = ( /赤羽駅西口/.test(rowitems[3]) ) ? "赤羽" : "北町車庫";
            const resObj = {
              dest,
              plan: rowitems[0],
              prospect: rowitems[1],
              comment: rowitems[5]
            };
            busList.push(resObj);
          });
        });
        console.log(busList);
        return resolve({resStopName, busList});
      });
  });
}

（中略）

 function getNextBusInfoHandler(agent) {
    let busStop = agent.parameters.busstop;
    
    console.log("busStop input is...");
    console.log(busStop);
     
    return getTimetable(busStop).then(resObj => {
        console.log("getTimetable successed");
        const {resStopName, busList} = resObj;         
        console.log(busList);

        let resMessage = `${resStopName}発\n`;
        // const len = busList.length;
        const maxLen = Math.min(busList.length, 3);
        for (let i = 0; i < maxLen ; i++) {
            const onePlan = busList[i];
            let commentStr = "";
            if (onePlan.comment.length < 3) {
                commentStr = "不明";
            } else {
                commentStr = onePlan.comment.slice(0, -3);
            }
            const planTime = onePlan.plan.split(":");
            let onePlanMessage = `${onePlan.dest}${planTime[1]}分発 ${commentStr}\n`;
            resMessage += onePlanMessage;
        }
        if ( maxLen === 0) {
            resMessage += "データがありません"; 
        }
        agent.add(resMessage);
        return;
    }).catch(error => {
        console.log(error);
        agent.add("バスの時刻表検索に失敗しました。");
        return;
    });
     
  }
（中略）
  let intentMap = new Map();
  //　intentMap.set('Default Welcome Intent', welcome);
  intentMap.set('Default Welcome Intent', getNextBusInfoHandler);

```

これで、「次のバスは？」と聞いたら練馬駅前のバス、「区役所のバスは？」と聞いたら練馬区役所入口発のバスの状況を教えてくれるようになったはず・・


#### Close Intent

アプリ終了のきっかけを作るため、Intentsをもうひとつ作成する。

Responses の「Set this intent as end of conversation」をONにする。

![DialogFlow-Intent-Close.png](https://qiita-image-store.s3.amazonaws.com/0/229508/1854e945-a210-3455-0756-e45cb326883c.png)



# まとめ

ざっくりと、GoogleHomeMiniで「次のバスは？」が動くまでを紹介しました。


## 今後の課題

+ そもそもこんな実装で大丈夫なのかな・・？
    + 初めてのActionsOnGoogleなので不安だらけです。（Qiitaも初めてなので、以下略）
+ 「次のバスは？」で最初のバス停の状況を喋った後がどうにも不自然です。
    + 現状だと、 Default Fallback Intent に流れた後にもう一度 Fulfillmentが走ります。美しくない。でも、アプリ切り替え後にバス停を問いただす手間は省きたい・・
+ サブのバス停をいきなり調べたいとき、どうしたものだか思案中です。

## 雑感

使い始めるまでは、「こんなの必要なのかな？スマホで直ぐに調べられるのに・・」と思ってました。使い始めたら、意外と、クセになります。

## 参考にしたページなど

+ https://qiita.com/kenz_firespeed/items/0979ceb05e4e3299f313
    + (2018.05.20追記)着手したときは、Actions on google と DialogFlow の区別も付いていませんでした。ほんと、助かりました。
+ (2018.05.20追記) https://qiita.com/ktty1220/items/72109a6419e23a26002c
    + スクレイピング処理はこちらを参考にしましたです


など。~~（後で書き足す予定）~~~~