[Amazon Echo] AlexaでYahoo路線の問い合わせを行うスキルを開発する

概要

年末年始にAmazonEchoをお借りする機会があったので、年末年始休み中にスマートスピーカーデビューも含めてスキル開発をやることに。開発トレーニングを第3回まで終えて、基本的なスキル開発のノウハウを得たので、さっそく実用的なモノを作ってみようと思い、日頃お世話になっているYahoo路線を口頭で利用できるスキルを開発することにした。本記事はその内容を整理して、大雑把にAlexaスキル開発の流れを共有できるようにしたもの。

前提

以下環境で開発、実装

  • Amazon Echo
  • AWS Lambda(node 6.1.0)

及び以下は前提として本記事では取り扱わない

  • AmazonEcho及びAlexaの基本
  • Amazonアプリ開発者コンソールの利用準備
  • AWSLambdaを含むAWSの利用準備

スキルイメージ

本記事で開発したスキルは、概ね以下のような会話をAmazonEchoと行えるようにする。

「Alexa、Yahoo路線を開いて、渋谷駅から東京駅への行き方教えて」

『19:24に渋谷駅に到着する、東京メトロ銀座線・浅草行きに乗車すると、19:43に東京駅に到着します。料金は195円で、1回の乗り換えがあります。』

「その1本後の電車は?」

『19:27に渋谷駅に到着する、東京メトロ銀座線・浅草行きに乗車すると、19:47に東京駅に到着します。料金は195円で、1回の乗り換えがあります。』

「ありがとう。いってきます。」

『いってらっしゃいませ』

デモ

前述のスキルイメージのやりとりを動画化したもの。

スキルの作成

Amazon開発者コンソールにログインし、ダッシュボード → Alexa Skills Kitと遷移し、Alexaスキルの新規作成を行う。

スキルの情報

スキルの基本情報は以下のようにする。呼び出し名を「Yahoo路線」にすることで、「Alexa、Yahoo路線を開いて」をスキル実行のトリガにすることができる。

対話モデル

インテントスキーマ

本スキルのインテントスキーマは以下の通り。

{
  "intents": [
    {
      "slots": [
        {
          "name": "StationFrom",
          "type": "LIST_OF_STATIONS"
        },
        {
          "name": "StationTo",
          "type": "LIST_OF_STATIONS"
        }
      ],
      "intent": "Transit"
    },
    {
      "intent": "Complete"
    },
    {
      "intent": "Next"
    },
    {
      "intent": "Prev"
    },
    {
      "intent": "AMAZON.RepeatIntent"
    }
  ]
}

インテントスキーマは、スキルのインテント一覧をJSONで定義したもので、インテントとは「そのスキルで、発話者がAlexaに要求する『意図』」のことである。本スキルでは、以下のインテントを定義する

インテント名 発話者の意図 具体例
Transit ある駅からある駅までの路線情報を知りたい ◯◯駅から✗✗駅までの行き方教えて
Complete スキルの利用が完了した ありがとう/いってきます/もういいよ
Next 教えてくれた路線情報の1本後の路線情報を知りたい 次は?/1本後は?
Prev 教えてくれた路線情報の1本前の路線情報を知りたい 前は?/1本前は?
AMAZON.RepeatIntent 教えてくれた路線情報を繰り返して欲しい もう1回/繰り返して

※ AMAZON.RepeatIntentのように、AMAZONと付くインテントはビルトインインテントと言う、最初から組み込まれているインテント。ビルトインインテントは認識の精度が高いので、これで代用できる場合はそのまま利用したほうが使い勝手が良くなることが多い。Complete,Next,Prevインテントに該当するビルトインインテントもあるが、今回は勉強のためにあえて利用していない。

スロットとカスタムスロットタイプ

前述のインテントスキーマの中で、Transitインテントのみ、slotsというフィールドが定義されていた。

      "slots": [
        {
          "name": "StationFrom",
          "type": "LIST_OF_STATIONS"
        },
        {
          "name": "StationTo",
          "type": "LIST_OF_STATIONS"
        }
      ],
      "intent": "Transit"

スロットとは、発話内に含まれているキーワードを変数として抜き出せるようにするための定義である。
前述の通り、Transitインテントでは、「◯◯駅から✗✗駅に行きたい」などと発話するが、この場合の「◯◯駅」や「✗✗駅」の部分がスロットに該当する。◯◯、✗✗の内容を後からスクリプトで取得するために、スロットを定義する。

上記インテントスキーマでは、Transitインテント内で、”StationFrom”、”StationTo”の2つのスロットが定義されており、それぞれのtype属性が”LIST_OF_STATIONS”と定義されている。スロットのtype属性は、そのスロットがどんな値を取りうるかの定義である。特に、”LIST_OF_STATIONS”はカスタムスロットタイプと言い、取りうる値のサンプルを別途定義することで、それらのワードを認識しやすくすることができる。

本スキルでは、”LIST_OF_STATIONS”に、以下のように個人的によく使う駅(一部ダミー)をカスタムスロットタイプとして定義している。

もちろんここで定義した駅名以外を発話しても認識はされるが、定義した値が優先的に認識されるので、よく使う駅については事前に定義しておいたほうが良い。

サンプル発話

最後に、各インテントについて、具体的にどんな発話がされた場合にそのインテントであると認識するかのサンプルを定義する。本スキルでは以下のようなサンプル発話を定義した。

Transit {StationFrom} から {StationTo} に行きたい
Transit {StationFrom} から {StationTo} への行き方
Transit {StationFrom} から {StationTo} まで
Transit {StationFrom} から {StationTo} までの路線
Transit {StationFrom} から {StationTo} までの時間
Transit {StationFrom} から {StationTo} の時間
Transit {StationFrom} から {StationTo} の路線
Complete ありがとう
Complete わかった
Complete 了解
Complete OK
Complete 行ってきます
Next 次
Next 次は
Next 次の電車
Next 一本後
Next その次
Next その次は
Prev 前
Prev 前は
Prev その前
Prev 一本前
Prev 前の電車

上記の通り、サンプル発話はインテント毎に定義し、スロットは{SlotName}のように記述する。

もちろんここで定義したサンプル発話以外の発話をしても、近い発話内容を元にインテントを確定してくれるので、本来はこんなに書く必要もないと思われる。

Yahoo路線をスクレイピングするスクリプトを作成

ここからは一旦AmazonEcho及びAlexaから離れて、Yahoo路線をスクレイピングして目的の路線情報を取得するスクリプトを実装する。本スクリプトはAWSLambda上で実行させることを想定して、node6.1で開発する。

下準備

nodeによるWebクロール及びWebスクレイピングのために、今回はcheerio-httpcliを利用する。スクレイピング関連のnodeモジュールをいくつか調べたが、個人的にはこれが最も手軽にできる。何よりjQueryライクにDOMをparseできるのが素晴らしい。

nodeモジュールなので、npmでインストールする。

$ npm install --save cheerio-httpcli

スクリプトの実装

先にソースコード全文

const client = require('cheerio-httpcli')
const BASE_URL = 'https://transit.yahoo.co.jp'

/**
 * 始発駅と到着駅を指定すると、
 * 現在駅から直近のルートに関する情報をYahoo路線から収集する
 */
exports.fetchTransitInfo = (stationFrom, stationTo) => {
  return client.fetch(BASE_URL)
    .then((result) => {
      result.$('#sfrom').val(stationFrom)
      result.$('#sto').val(stationTo)
      return result.$('#searchModuleSubmit').click()
    })
    .then((result) => {
      return parseTransitInfo(result)
    })
}

/**
 * Yahoo路線ページのURLを指定すると
 * そのページの前後どちらかの路線に関する情報を収集する
 * @param [String] currentUrl 元となるYahoo路線のページURL
 * @param [String] operation  次の電車の場合next,前の電車の場合prevを指定
 */
exports.fetchAdjacentTransitInfo = (currentUrl, orientation = 'next') => {
  return client.fetch(currentUrl)
    .then((result) => {
      const nextUrl = result.$(`.${orientation} a`).first().attr('href')
      return client.fetch(BASE_URL + nextUrl)
    })
    .then((result) => {
      return parseTransitInfo(result)
    })
}

/**
 * Yahoo路線のページリザルトから、先頭の路線情報を抜き出す
 */
const parseTransitInfo = (page) => {
  const $routeDetail = page.$('.routeDetail').first()
  const distance     = page.$('.distance').first().text()
  const fare         = page.$('.fare').first().text()
  const transfer     = Number(page.$('.transfer').first().text().match(/\d/)[0])
  const transport = $routeDetail.find('.transport')
                    .text()
                    .split(/\r\n|\r|\n/)
                    .filter((line) => line.indexOf('[train]') >= 0 || line.indexOf('[bus]') >= 0)[0]
                    .split(/\[.+\]/)[1];
  const startTime = $routeDetail.find('.time li').first().text()
  const arrivalTime = $routeDetail.find('.time li').last().text()
  const url = page.response.request.uri.href
  return {
    distance,
    fare,
    transfer,
    startTime,
    arrivalTime,
    transport,
    url
  }
}

本スクリプトは、以下の3つの機能を提供する

  • 出発地と到着地を指定し、現在時刻から直近の路線情報を取得する
  • 上記機能で取得したYahoo路線のURLを指定し、その1本後または1本前の路線情報を取得する

取得する路線情報は、以下の赤枠で囲った部分に対応する。

動作確認

渋谷駅から東京駅への路線情報を取得

> transit.fetchTransitInfo('渋谷駅', '東京駅').then((result) => console.log(result))
{ 
  distance: '7.7km',
  fare: '195円',
  transfer: 1,
  startTime: '20:22',
  arrivalTime: '20:41',
  transport: '東京メトロ銀座線・浅草行',
  url: 'https://transit.yahoo.co.jp/search/result?from=%E6%B8%8B%E8%B0%B7%E9%A7%85&to=%E6%9D%B1%E4%BA%AC%E9%A7%85&via=&via=&via=&y=2018&m=01&d=03&datePicker=&hh=20&type=1&ticket=ic&expkind=1&ws=3&s=0&al=1&shin=1&ex=1&hb=1&lb=1&sr=1&kw=%E6%9D%B1%E4%BA%AC%E9%A7%85'
}

fetchTransitInfoメソッドはPromiseを戻すので、thenで結果を受け取る。見た目はわかりづらいが、各種情報が取得できていることがわかる。

1本後の路線情報を取得

前項で取得したYahoo路線のURLを用いて、その1本後の路線情報を取得する。

> transit.fetchAdjacentTransitInfo('https://transit.yahoo.co.jp/search/以下略', 'next').then((result) => { console.log(result) })
{ distance: '7.7km',
  fare: '195円',
  transfer: 1,
  startTime: '20:26',
  arrivalTime: '20:46',
  transport: '東京メトロ銀座線・浅草行',
  url: 'https://transit.yahoo.co.jp/search/result?from=%E6%B8%8B%E8%B0%B7%E9%A7%85&flatlon=&to=%E6%9D%B1%E4%BA%AC%E9%A7%85&tlatlon=&viacode=&ym=201801&y=2018&m=01&d=03&hh=20&m1=2&m2=3&shin=1&ex=1&hb=1&al=1&lb=1&sr=1&type=1&ws=3&s=0&ei=&fl=1&tl=3&expkind=1&mtf=&out_y=&mode=&c=&searchOpt=&stype=&ticket=ic&userpass=0&passtype=&detour_id=&kw=%E6%9D%B1%E4%BA%AC%E9%A7%85' }

それぞれ正しく機能していることがわかる。

Alexaからリクエストを受け取るエンドポイントの実装

ここからは、ここまでに作成したAlexaスキルと、Yahoo路線スクレイピングスクリプトの間に入り、発話者の発話に応じた路線情報を発話してくれるスクリプトを実装する。

下準備

まずは、Alexaからのリクエストを受け取り、Alexaに発話させるための仕組みをnodeで利用するためのalexa-sdkをnpmでインストールする。

$ npm install --save alexa-sdk

ソースコード

const Alexa   = require('alexa-sdk');
const transit = require('./transit.js')

exports.handler = function(event, context, callback) {
  const alexa = Alexa.handler(event, context);
  alexa.registerHandlers(firstHandlers, secondHandlers);
  alexa.execute();
};

/**
 * 路線情報に関するメッセージを生成する
 * @param [String] stationFrom 出発地
 * @param [String] stationTo   到着地
 * @param [Object] transitInfo 路線情報オブジェクト
 */
const makeTransitMessage = (stationFrom, stationTo, transitInfo) => {
  const msg = `
    ${transitInfo.startTime}に${stationFrom}に到着する、
    ${transitInfo.transport}に乗車すると、${transitInfo.arrivalTime}に
    ${stationTo}に到着します。
    料金は${transitInfo.fare}で、${transitInfo.transfer}回の乗り換えがあります。
  `
  return msg.replace('行に', '行きに')
            .replace('0回の乗り換えがあります。', '乗り換えはありません。')
}

/**
 * 1本前または1本後の路線情報を発話する
 * この関数はthisを束縛しないのでcall/applyで呼び出すこと
 * @param [String] orientation 1本後の場合next,1本前の場合prevを指定
 */
const emitAdjacentTransitInfo = function(orientation = 'next') {
  transit.fetchAdjacentTransitInfo(this.attributes['currentUrl'], orientation).then((result) => {
    const transitMessage = makeTransitMessage(
      this.attributes['stationFrom'],
      this.attributes['stationTo'],
      result
    )
    this.attributes['transitMessage'] = transitMessage
    this.attributes['currentUrl'] = result.url
    this.emit(':ask', transitMessage)
  })
}

/**
 * 初回のハンドラ
 */
const firstHandlers = {
  /**
   * 他のインテントに合致しない場合に、スキルの説明をする
   */
  'LaunchRequest': function () {
    const launchMessage = `
      Yahoo路線を使ってルート案内します。東京駅から渋谷駅まで。のように、
      出発駅と到着駅を教えて下さい。
    `
    this.emit(':ask', launchMessage)
  },
  /**
   * セッション終了
   */
  'Complete': function() {
    this.emit(':tell', 'スキルを終了します。')
  },
  /**
   * 発着駅を指定した場合に、路線情報を発話する
   */
  'Transit': function () {
    const stationFrom = this.event.request.intent.slots.StationFrom.value
    const stationTo   = this.event.request.intent.slots.StationTo.value
    transit.fetchTransitInfo(stationFrom, stationTo).then((result) => {
      const transitMessage = makeTransitMessage(stationFrom, stationTo, result)
      this.attributes['stationFrom']    = stationFrom
      this.attributes['stationTo']      = stationTo
      this.attributes['transitMessage'] = transitMessage
      this.attributes['currentUrl']     = result.url
      this.handler.state = 'SECOND';
      this.emit(':ask', transitMessage)
    })
  },
};

/**
 * 初回以降のハンドラ
 */
const secondHandlers = Alexa.CreateStateHandler('SECOND', {
  /**
   * 他のインテントに合致しない場合に聞き返す
   */
  'LaunchRequest': function () {
    const launchMessage = `え??`
    this.emit(':ask', launchMessage)
  },

  /**
   * 保持している路線情報をリピート
   */
  'AMAZON.RepeatIntent': function() {
    this.emit(':ask', this.attributes['transitMessage'])
  },

  /**
   * 1本後の路線情報を発話
   */
  'Next': function() {
    emitAdjacentTransitInfo.call(this, 'next')
  },

  /**
   * 1本前の路線情報を発話
   */
  'Prev': function() {
    emitAdjacentTransitInfo.call(this, 'prev')
  },

  /**
   * セッション終了
   */
  'Complete': function() {
    this.emit(':tell', 'いってらっしゃいませ')
  },
})

ざっくり解説

AlexaSDKでは、ハンドラをalexaに適用することで、インテント毎に任意の処理を実行することができる。

alexa.registerHandlers(firstHandlers, secondHandlers);

ハンドラは複数定義することができ、ステートに応じて使用するハンドラを切り替えることができる。

ステートが未定義である場合のハンドラ

const firstHandlers = {
}

ステートが’SECOND’である場合のハンドラ

const secondHandlers = Alexa.CreateStateHandler('SECOND', {
})

ステートは以下のように書き換えることができる。

this.handler.state = 'SECOND';

ハンドラはインテント毎に定義し、対応するインテントの関数のみ呼び出される

'Complete': function() {
},
'Transit': function () {
}

emitメソッドを用いることで、Alexaに発話させることができる。

emitの第一引数に:tellを指定した場合、それを発話してセッション終了(スキル終了)する。

this.emit(':tell', 'スキルを終了します。')

:askを指定した場合、セッションを維持し、次の発話をユーザに促す。

const launchMessage = `え??`
this.emit(':ask', launchMessage)

スロットはeventオブジェクトから辿って取得できる。

const stationFrom = this.event.request.intent.slots.StationFrom.value
const stationTo   = this.event.request.intent.slots.StationTo.value

attributesを用いることでセッションを超えてデータを保持することができる

this.attributes['stationFrom']    = stationFrom
this.attributes['stationTo']      = stationTo
this.attributes['transitMessage'] = transitMessage

以上を組み合わせて、発話者の意図に基づいたYahoo路線のスクレイピング結果をAlexaに発話させることができる。

Alexaスキルとスクリプトを紐付ける

ここまででスキルはほぼ完成したが、最後にスキルとスクリプトを紐付ける必要がある。Alexaは、発話者から受け取った発言から、インテントやスロットなどを抽出し、それを任意のエンドポイントにJSONで送信する。自前のWebサーバなどに飛ばすことも出来るのだが、ここではAmazonも推奨しているAWS Lambdaに、作成したスクリプトをデプロイして、そこにAlexaからリクエストを投げてもらうことにする。

AWS Lambdaは、平たく言うとソースコードをアップロードするといい感じの環境で実行できるエンドポイントを用意してくれるサービス。無料枠が大きいので、軽く遊ぶ程度には無料で出来る。

もちろんクレジットカードを登録したAWSアカウントが必要だが、本記事ではそこまでは割愛する。

AWS Lambdaにアップロードするzipファイルの作成

AWS Lambdaは、zipファイルをアップロードするか、S3からの直接アップロードが可能だが、本記事では毎回zipファイルを作成、アップロードする。また、AWS用のcliツールは特に使用せず、zipコマンドでzip化し、ブラウザ経由でアップロードするシンプルな手法を取る。

ここまでで、作成したエンドポイント(index.js)、Yahoo路線スクレイピングスクリプト(transit.js)及び、node_modulesが出来上がっているので、それらをまとめてzip化する。

$ ls
index.js  node_modules  package.json  transit.js
$ zip -r ../lambda.zip *;

AWS Lambdaにzipでアップロード可能な上限は10MBまでなので、ここで10MBを超えるようであれば諦めてS3を使うしか無い。今回はどうにか収まったのでこのままzipで続ける。

$ du -h ../lambda.zip
9.0M	../lambda.zip

AWS Lambdaにzipファイルをアップロードする

AWS Lambda関数作成時に、関数のトリガをAlexa Skills Kitにし、関数本体をzipでアップロードする。

保存が完了すると、
ARN – arn:aws:lambda:ap-northeast-1:000000000000:function:AlexaTransitSkill
のように、関数のリソースネームが出来上がるので、Alexaスキルの基本情報画面に戻って、エンドポイントにこのARNを設定する。

デモ(再掲)

以上のようにすると、以下のようにYahoo路線情報を問い合わせるAlexaスキルが動くことが確認できる。

備考

  • AmazonEcho及びAlexaのスキル開発が初めてで、かなり試行錯誤したので間違いや冗長的な記述が多かったかも
  • 本記事のスキルはスクレイピング負荷が大きかったりするので、スキルの公開はしない。個人的に活用させてもらう
  • AmazonEchoは想像していた以上に使いみちがありそう。これは借り物なので返却が必要なため、自分用を購入するかあるいは手軽に購入できるGoogleHomeなどに乗り換えることも検討したい。
  • 本記事で扱ったソースコードはこちら