30分くらいでサクッと作るPodcast対応Alexaスキル

スマートスピーカーAdventカレンダー18日目担当 @snaka です。投稿間に合わず、日付変わってしまいました。すみません。

自作のスキルはもっぱらPodcast再生スキルのみなので、必要最低限レベルの実装で動くPodcast再生スキルの作り方というテーマで記事を書いてみた。

例題として「[Misreading Chat](https://misreading.chat/)」というポッドキャストを再生するスキルを作ってみた。

この記事での作業手順は基本的に CLI ベースで作業する前提となっている。以下の記事も参考にすると良いかも。

* [CLIベースの開発で ap-northeast-1 リージョンでスキルを稼働させる手順 - Qiita](https://qiita.com/snaka/items/ea56eed3f4f7b3db565e)

この記事の手順を実行した結果は GitHub に公開しているので参考まで。

* [snaka/skill-sample-misreading-chat](https://github.com/snaka/skill-sample-misreading-chat)


## プロジェクト作成

まずは、`ask new` でプロジェクトを作成する。

```terminal
$ ask new
? Please select the runtime Node.js V8
? List of templates you can choose Hello World
? Please type in your skill name:  skill-misreading-podcast
Skill "skill-misreading-podcast" has been created based on the chosen template
```

## スキルのデプロイ

以下のように skill.json を編集

* `manifest.publishingInformation.locales` の下に `ja-JP` のエントリを作成
* `manifest.publishingInformation.category` を `PODCAST` に
* `manifest.apis.custom` に `interfaces` のエントリを記述

以下のようになっているはず。

```skill.json
{
  "manifest": {
    "publishingInformation": {
      "locales": {
        "ja-JP": {
          "summary": "Misreading Chat",
          "examplePhrases": [
            "アレクサ、ミスリーディングチャットを開いて"
          ],
          "name": "skill-misreading-podcast",
          "description": "Sample Full Description"
        }
      },
      "isAvailableWorldwide": true,
      "testingInstructions": "Sample Testing Instructions.",
      "category": "PODCAST",
      "distributionCountries": []
    },
    "apis": {
      "custom": {
        "endpoint": {
          "sourceDir": "lambda/custom",
          "uri": "ask-custom-skill-misreading-podcast-default"
        },
        "interfaces": [
          { "type": "AUDIO_PLAYER" }
        ]
      }
    },
    "manifestVersion": "1.0"
  }
}
```

上記を保存したら、以下のコマンドでスキルのみをデプロイ

```terminal
$ ask deploy -t skill
```

## モデルをデプロイ

以下のように `models` 配下にJSONファイルを作成

* `invocationName` はスキルの呼び出し名

```models/ja-JP.json
{
  "interactionModel": {
    "languageModel": {
      "invocationName": "ミスリーディングチャット",
      "intents": [
        {
          "name": "AMAZON.CancelIntent",
          "samples": [
            "キャンセル"
          ]
        },
        {
          "name": "AMAZON.HelpIntent",
          "samples": []
        },
        {
          "name": "AMAZON.StopIntent",
          "samples": []
        },
        {
          "name": "AMAZON.PauseIntent",
          "samples": []
        },
        {
          "name": "AMAZON.ResumeIntent",
          "samples": []
        }
      ]
    }
  }
}
```

デプロイするためには `sample` を1つ以上登録する必要があるらしいので、とりあえず`キャンセル`という発話サンプルだけ登録している。

jsonを保存して以下のようにモデルをデプロイする。

```terminal
$ ask deploy -t model
```

## Lambda 関数のデプロイ設定

Lambda 関数デプロイの準備として以下のように serverless.yml を作成

* `alexaSkill` の値は自分のスキルのIDに置き換える
    * `ask deploy -t skill` の実行結果などからコピペする

```lambda/custom/serverless.yml
service: skill-misreading-podcast

provider:
  name: aws
  region: ap-northeast-1
  runtime: nodejs8.10
  memorySize: 128
  timeout: 30

package:
  include:
    - node_modules/

functions:
  main:
    handler: index.handler
    events:
      - alexaSkill: amzn1.ask.skill.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
        enabled: true
```

## 必要なパッケージのインストール

後述のコードで`request`, `feedparser` を利用するため、以下のようにパッケージをインストールしておく。

```terminal
$ npm install --save request feedparser
```

## Lambda 関数を作成

index.js を以下のように実装。

* `PODCAST_FEED_URL` はポッドキャストのフィードURL
* `PODCAST_NAME`はAlexaがエピソード再生前に読み上げるポッドキャスト名
    * `models/ja-JP.json` に定義した「スキル呼び出し名」とは別の名前を設定してもよい

```lambda/custom/index.js
/* eslint-disable  func-names */
/* eslint-disable  no-console */
const request = require('request')
const FeedParser = require('feedparser')

const PODCAST_FEED_URL = 'https://misreading.chat/category/episodes/feed/'
const PODCAST_NAME = 'ミスリーディングチャット'

function pickSslMediaUrl (enclosures) {
  const sslMedia = enclosures.find(item => item.url.startsWith('https'))
  if (sslMedia) return sslMedia.url

  const nonSslMedia = enclosures[0]
  // Alexa Skill の AudioPlayer は https: で提供されるURLしか対応していないため強引に書き換える
  if (nonSslMedia) return nonSslMedia.url.replace(/^http:/, 'https:')

  throw new Error('Media not found.')
}

function getLatestEpisode () {
  return new Promise(async (resolve, reject) => {
    const feedparser = new FeedParser()

    request.get(PODCAST_FEED_URL).pipe(feedparser)

    feedparser.on('data', (data) => {
      const audioUrl = pickSslMediaUrl(data.enclosures)
      resolve({
        title: data.title,
        url: audioUrl,
        published_at: data.pubDate.toISOString()
      })
    })
  })
}

async function playPodcast (handlerInput) {
  const latestEpisode = await getLatestEpisode()
    const speechText = `${PODCAST_NAME} の最新エピソード ${latestEpisode.title} を再生します`;

    return handlerInput.responseBuilder
      .speak(speechText)
      .withSimpleCard(PODCAST_NAME, speechText)
      .addAudioPlayerPlayDirective('REPLACE_ALL', latestEpisode.url, latestEpisode.url, 0)
      .getResponse();
}

const Alexa = require('ask-sdk-core');

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  async handle(handlerInput) {
    return await playPodcast(handlerInput)
  },
};

const ErrorHandler = {
  canHandle() {
    return true;
  },
  handle(handlerInput, error) {
    console.log(`Error handled: ${error.message}`);

    return handlerInput.responseBuilder
      .speak('ごめんなさい、よくわかりません')
      .reprompt('ごめんなさい、よくわかりません')
      .getResponse();
  },
};

const skillBuilder = Alexa.SkillBuilders.custom();

exports.handler = skillBuilder
  .addRequestHandlers(
    LaunchRequestHandler,
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();
```

index.js を保存したら以下のように Lambda 関数をデプロイ

```terminal
$ sls deploy
```

## スキルのエンドポイントにLambda関数のARNを登録

Lambda 関数をデプロイしたら AWS の管理コンソールでデプロイした Lambda 関数を開き、関数のARNを控える。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/8219/50536b76-8a30-ac82-bc3f-fba909c70d8e.png)

そして`skill.json` を開き以下のように、`manifest.apis.custom.endpoint.uri` の値として Lambda 関数のARNをセットする

```json:skill.json（抜粋）
        "endpoint": {
          "uri": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:skill-misreading-podcast-dev-main"
        },
```

上記を保存した後、スキルをデプロイ

```terminal
$ ask deploy -t skill
```

## スキルの呼び出しをテスト

まず、テストを有効化する

```terminal
$  ask api enable-skill -s amzn1.ask.skill.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx --stage development
```

以下のようにスキルを呼び出してみる、`-t` の後の文字列は、`skill.json` に登録したスキル呼び出し名にする

```terminal
$ ask simulate -l 'ja-JP' -t 'ミスリーディングチャットを開いて'
```

レスポンスで以下のように`AudioPlayer.Play`ディレクティブに、音源(mp3)のURLがセットされていたらOK

```json:実行結果の一部抜粋
{
  "type": "AudioPlayer.Play",
  "playBehavior": "REPLACE_ALL",
  "audioItem": {
    "stream": {
      "token": "https://misreadingchat.files.wordpress.com/2018/11/40.mp3",
      "url": "https://misreadingchat.files.wordpress.com/2018/11/40.mp3",
      "offsetInMilliseconds": 0
    }
  }
}
```

## ベータテスト

シミュレータではAudioPlayerでの音声ファイルの再生は行えないので、音声を再生まで確認したい場合はベータテストとしてスキルを公開する必要がある。

その準備として、`skill.json` を開き以下のように記述を追加

* `smallIconUri`, `largeIconUri` にアイコンのURLを設定
    * アイコンは規定のサイズでないとデプロイでエラーになるので注意
    * 下記の例で示しているアイコン画像はpublicに公開してるので自由に使ってもらってOK
* `privacyAndCompliance' のエントリを追加

最終的に `skill.json` は以下のようになっているはず。

```skill.json
{
  "manifest": {
    "publishingInformation": {
      "locales": {
        "ja-JP": {
          "summary": "Misreading Chat",
          "examplePhrases": [
            "アレクサ、ミスリーディング開いて"
          ],
          "name": "skill-misreading-podcast",
          "description": "Sample Full Description",
          "smallIconUri": "https://s3-ap-northeast-1.amazonaws.com/snaka-public/alexa-icons/Sample_108.png",
          "largeIconUri": "https://s3-ap-northeast-1.amazonaws.com/snaka-public/alexa-icons/Sample_512.png"
        }
      },
      "isAvailableWorldwide": true,
      "testingInstructions": "Sample Testing Instructions.",
      "category": "PODCAST",
      "distributionCountries": []
    },
    "privacyAndCompliance": {
      "allowsPurchases": false,
      "usesPersonalInfo": false,
      "isChildDirected": false,
      "isExportCompliant": true,
      "containsAds": false
    },
    "apis": {
      "custom": {
        "endpoint": {
          "uri": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxx:function:skill-misreading-podcast-dev-main"
        },
        "interfaces": [
          { "type": "AUDIO_PLAYER" }
        ]
      }
    },
    "manifestVersion": "1.0"
  }
}
```

上記を保存したらスキルをデプロイ

```terminal
$ ask deploy -t skill
```

スキルのデプロイが完了したら、開発者ポータル上でベータテストの有効化を行う。
具体的な手順は以下のページなどを参照。

[Alexaスキルのベータテストを行う | Custom Skills](https://developer.amazon.com/ja/docs/custom-skills/skills-beta-testing-for-alexa-skills.html)

ベータテストに参加して、スキルがEchoデバイスで有効になったら「アレクサ、ミスリーディングチャットを開いて」と発話することで Echo デバイスで Misreading Chat の最新エピソードが再生されるはず。

## さいごに

ここで紹介したコードは、必要なインテントの実装が抜けていたりするので、そのままでは審査を通すことはできないのでご注意を。
また、第三者が権利を有するコンテンツをスキルで使用する場合、そのコンテンツを扱うための許諾を受けていることを審査時に提示しなければならない、そのあたりについては以前記事を書いているので下記を参照のこと。

* [Alexaスキルのポリシーガイドライン（第三者の知的財産権）に対応する - Qiita](https://qiita.com/snaka/items/a91b6bdb8bc478030678)

現在、Podcast 対応スキルを最小限のコーディングで実装するためのパッケージを[作成中](https://github.com/snaka/alexa-podcast-skill-builder)なので、いい感じにできてきたら、そのうち Qiita にチュートリアルの記事をアップできたらいいなぁ（モチベーション次第... :sweat_smile:）

### 関連リンク

* [snaka/skill-sample-misreading-chat](https://github.com/snaka/skill-sample-misreading-chat)
* [snaka/alexa-podcast-skill-builder](https://github.com/snaka/alexa-podcast-skill-builder)
* [Misreading Chat – CS の論文読んで話をしよう](https://misreading.chat/)
* [CLIベースの開発で ap-northeast-1 リージョンでスキルを稼働させる手順 - Qiita](https://qiita.com/snaka/items/ea56eed3f4f7b3db565e)
* [Alexaスキルのベータテストを行う | Custom Skills](https://developer.amazon.com/ja/docs/custom-skills/skills-beta-testing-for-alexa-skills.html)