お気に入りのテレビ番組の有無を答えてくれるAlexaスキルを作ってみた

# はじめに

日曜日の夜に日本テレビ系列で放送されている「世界の果てまでイッテQ!」見てますか? 面白いですよね。
娘もこの番組大好きで、週末になると毎日「今日イッテQある?」と聞いてきます。

だいたい毎週日曜日にはあるのですが、時々特番とかで放送なかったりで、その時の娘のガッカリ感ときたら…。

それはさておき、毎週末聞いてくるので、大好きなアレクサが答えてくれたら娘も喜ぶんじゃないかな、と思って、「今日イッテQある?」と聞いたら答えてくれるアレクサスキルを作ってみました。

# 要件

以下のようなものを目標にしてみました。

* 「アレクサ、今日イッテQある?」と聞いたら
 * WEB上にある番組表から「イッテQ」というキーワードで検索。
 * 今日見つかったら「HH:MM〜HH:MMに世界の果てまでイッテQ…があります」と答える。
 * 今日はないけど見つかったら「MM/DDのHH:MMにあるようです」と答える。
 * 見つからなかったら「ありません」と答える。

# 実装方針

* スキルの呼び出し名を「今日イッテQある」にする。
 * こうすると「アレクサ、今日イッテQあるを開いて」で起動する設定になりますが、「アレクサ、今日イッテQある?」でも起動できたので…。
 * たぶんスキル名にもよるのだと思いますが、もしこれで起動できなかった場合は、定型アクションを使うつもりでした。
* いつもPythonの人なので、今回は自分自身の新たな取組としてNode.jsで書いてみる。
 * ほぼ初めてのNode.jsなので、お見苦しい点はお許しくださいませ。
* 今回はLambda上で実装するする。
 * Alexa-hostedスキルでもできるのかもしれないですが、初めてNode.jsでアレクサスキルを作るということをやる上で、慣れない環境でハマるのもイヤだったので。

# 実装内容

## Alexa Developer Console

※いくつかキャプチャを貼っていますが、作成済みのスキルの編集画面ですので、作成手順に沿っているわけではありません。

### 呼び出し名

方針に書いたように「今日イッテQある」にしました。
「呼び出し名は2名詞以上である必要があります。」と赤字でエラーっぽく書かれていますが、これで動いています。
※保存したらQが小文字になりました

|![Alexa Developer Console 呼び出し名](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/99fccd27-c3c8-7fd4-8f41-efb2cba789ff.png)
|:-:|

### インテント

今回はスキル呼び出されたらそのまま回答して終了するスキルにするので、インテント用意しなくてもいいのかな…と思っていたのですが、何も設定していない状態だと、ビルドエラーが出てしまいます。

|![Alexa Developer Console ビルドエラー](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/2a831320-9ff0-64c3-d674-e7bc65dce9cd.png)
|:-:|

何かしらサンプル発話を登録すればいい、ということなので、今回はビルトインインテントのAMAZON.StopIntentにサンプル発話を設定することで、ビルドが通るようになります。

|![Alexa Developer Console インテント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/dae3b28d-4728-6492-d9f7-cd03172a8c38.png)
|:-:|

### エンドポイント

ここは普通にLambdaを使ったAlexaスキル一般通り、LambdaのARNを指定してます。

## AWS Lambda

Lambda側は以下方針で実装しました。

* [ASK ADK for Node.js](https://ask-sdk-for-nodejs.readthedocs.io/ja/latest/)を使用。
* 番組を調べるためのWebサービスとして[Yahoo!テレビ](https://tv.yahoo.co.jp)を使用させていただく。
 * 検索するのに使い勝手がよかったので…
* スクレイピングするためのHTMLパースには[Cheerio](https://www.npmjs.com/package/cheerio)を使用。

ということでソースは以下の通りです。コメント入れているので何やっているかはわかるかと思います。

```javascript:app.js
const Alexa = require('ask-sdk-core')
const Axios = require('axios')
const Cheerio = require('cheerio')
const Moment = require('moment-timezone')
const Moji = require('@eai/moji')

Moment.tz.setDefault('Asia/Tokyo')

// 最後のa=10が札幌を示している
const Url = 'https://tv.yahoo.co.jp/search/?q=%E3%82%A4%E3%83%83%E3%83%86Q&a=10'
const Title = '世界の果てまでイッテQ!'

// スキル起動ハンドラ
const LaunchRequestHandler = {
  canHandle (handlerInput) {
    // スキル起動時に反応する
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest'
  },
  async handle (handlerInput) {
    const response = await Axios.get(Url)
    const $ = Cheerio.load(response.data)
    const programInfos = $('.programlist li').map((index, elm) => {
      const left = $('div.leftarea', elm)
      const right = $('div.rightarea', elm)
      const dateString = $('p:first-of-type > em', left).text()
      const timeString = $('p:nth-of-type(2) > em', left).text()
      const titleString = $('p:first-of-type > a', right).text()
      return {
        date: dateString,
        time: timeString,
        // 番組表の文字列、全角半角の混ざり方が不規則なので、英数は半角、カナは全角に揃える
        title: Moji(titleString).convert('ZEtoHE').convert('HKtoZK').toString()
      }
    }).get().filter(x => x.title.indexOf(Title) === 0)
    // 特番とかだと違う番組が引っかかることがあるので、番組名先頭に「世界の果てまでイッテQ!」
    // と書かれているものを対象番組として扱うことにする

    // 今日あるかどうかの判断を行う
    const timestamp = Moment(handlerInput.requestEnvelope.request.timestamp)
    const dateString = timestamp.format('M/D')
    const filtered = programInfos.filter(x => x.date === dateString)

    let speechText

    if (filtered.length > 0) {
      // 今日ある場合は、番組詳細まで返す
      const subStr = filtered.map(x => {
        const timeString = x.time.replace('～', 'から')
        const result = timeString + 'に' + x.title + 'が'
        return result
      }).join('、')
      speechText = '今日は' + subStr + 'あります。'
    } else if (programInfos.length > 0) {
      // 今日はないけど、明日以降見つかったら、日付と時刻を返す。
      const subStr = programInfos.map(x => {
        const result = x.date.replace('/', '月') + '日' + x.time.replace('～', 'から')
        return result
      }).join('と、')
      speechText = '今日はイッテQはありませんが、' + subStr + 'にあるようです。'
    } else {
      // ない
      speechText = '今日はイッテQはありません。'
    }

    return handlerInput.responseBuilder
      // 最後は全角で返さないとうまく読んでもらえない。
      .speak(Moji(speechText).convert('HEtoZE').toString())
      .getResponse()
  }
}
exports.handler = Alexa.SkillBuilders.custom()
  .addRequestHandlers(
    LaunchRequestHandler
  )
  .lambda()
```

ビルド、デプロイはAWS SAMを使いました。
特に特筆する内容はないですが、以下のようなテンプレートを使いました。

```yaml:template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  alexa itteq
  今日イッテQがあるか答えるAlexaスキル

Globals:
  Function:
    Timeout: 30

Resources:
  AlexaItteqFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.handler
      Runtime: nodejs12.x
      Events:
        Alexa:
          Type: AlexaSkill
```

あとは、デザイナー画面の「Alexa Skills Kit」でスキルIDをセット。
スキルIDはAlexa Developers Consoleで確認可能。

# 作ってみての所感など…

今回作ったスキルは期待通り娘にも使ってもらえてよかった〜と感じです。

ただ、スキルを呼び出した後、返答が返ってくるまで少しタイムラグがあるのが気になりますね。
娘には「イッテQある?って聞かれてからアレクサは一所懸命調べたり考えたりしているんだよ〜」って説明して納得してくれていますが、やはりUX的には気になるところ…

番組表取得自体はデイリーとかで回してDynamoDBなりS3なりに保存しておいて、Alexaからの要求が来たときはDynamoDBなりS3なりを参照にする方がいいのかなぁ…
いずれにしてもX-RAYを仕掛けて、番組表取得で遅くなっているのか、その後の処理で遅くなっているのかなどを見極めたいと思っています。

# 関連記事

[Node.js製Lambdaの速度劣化箇所をX-RAYを使って特定する](https://qiita.com/masaminh/items/d59613c77fc94293057d)