Node.js,Dialogflow,YoutubeDataAPIでVtuberの配信予定を確認できるLINEBOTをつくった

#はじめに
LINEBOTをつくってみました。
<img width="400" alt="vsche_demo_compressed.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/612354/66aebf1f-9a69-768e-2442-cac8d8f9215b.gif">

Vtuberの名前を入力すると今日の配信予定時刻とリンク先を返してくれます。（現在ホロライブ所属タレントのみ対応）
Dialogflowという自然言語処理APIを用いて、正式名称でなくニックネームが入力されても対応できるようにしています。

主な技術構成は以下のようになっています。

1. JavaScriot
2. Node.js (Express)
3. Dialogflow
4. YoutubeDataAPIv3
5. Heroku

#コード
コードの紹介もしておきます。

```javascript:livers.js
const livers = {
  'ときのそら': { channelId: 'UCp6993wxpyDPHUpavwDFqgg' },
  'AZKi': { channelId: 'UC0TXe_LYZ4scaW2XMyi5_kw' },
  'ロボ子さん': { channelId: 'UCDqI2jOz0weumE8s7paEk6g' },
  'さくらみこ': { channelId: 'UC-hM6YJuNYVAmUWxeIr9FeA' },
  '白上フブキ': { channelId: 'UCdn5BQ06XqgXoAxIhbqw5Rg' },
  '夏色まつり': { channelId: 'UCQ0UDLQCjY0rmuxCDE38FGg' },
  '夜空メル': { channelId: 'UCD8HOxPs4Xvsm8H0ZxXGiBw' },
  '赤井はあと': { channelId: 'UC1CfXB_kRs3C-zaeTG3oGyg' },
  'アキ・ローゼンタール': { channelId: 'UCFTLzh12_nrtzqBPsTCqenA' },
  '湊あくあ': { channelId: 'UC1opHUrw8rvnsadT-iGp7Cg' },
  '癒月ちょこ': { channelId: 'UC1suqwovbL1kzsoaZgFZLKg' },
  '百鬼あやめ': { channelId: 'UC7fk0CB07ly8oSl0aqKkqFg' },
  '紫咲シオン': { channelId: 'UCXTpFs_3PqI41qX2d9tL2Rw' },
  '大空スバル': { channelId: 'UCvzGlP9oQwU--Y0r9id_jnA' },
  '大神ミオ': { channelId: 'UCp-5t9SrOQwXMU7iIjQfARg' },
  '猫又おかゆ': { channelId: 'UCvaTdHTWBGv3MKj3KVqJVCw' },
  '戌神ころね': { channelId: 'UChAnqc_AY5_I3Px5dig3X1Q' },
  '不知火フレア': { channelId: 'UCvInZx9h3jC2JzsIzoOebWg' },
  '白銀ノエル': { channelId: 'UCdyqAaZDKHXg4Ahi7VENThQ' },
  '宝鐘マリン': { channelId: 'UCCzUftO8KOVkV4wQG1vkUvg' },
  '兎田ぺこら': { channelId: 'UC1DCedRgGHBdm81E1llLhOQ' },
  '潤羽るしあ': { channelId: 'UCl_gCybOJRIgOXw6Qb4qJzQ' },
  '星街すいせい': { channelId: 'UC5CwaMl1eIgY8h02uZw7u8A' },
  '天音かなた': { channelId: 'UCZlDXzGoo7d44bwdNObFacg' },
  '桐生ココ': { channelId: 'UCS9uQI-jC3DE0L4IpXyvr6w' },
  '角巻わため': { channelId: 'UCqm3BQLlJfvkTsX_hvm0UmA' },
  '常闇トワ': { channelId: 'UC1uv2Oq6kNxgATlCiez59hw' },
  '姫森ルーナ': { channelId: 'UCa9Y57gfeY0Zro_noHRVrnw' },
  '雪花ラミィ': { channelId: 'UCFKOVgVbGmX65RxO3EtH3iw' },
  '桃鈴ねね': { channelId: 'UCAWSyEs_Io8MtpY3m-zqILA' },
  '獅白ぼたん': { channelId: 'UCUKD-uaobj9jiqB-VXt71mA' },
  '尾丸ポルカ': { channelId: 'UCK9V2B22uJYu3N7eR_BT9QA' }
};

module.exports = livers;
```

上記コードはホロライブ所属Vtuberの名前とYoutubeのチャンネルIDをオブジェクトに放り込んだものになります。これを他のファイルから読み込んでAPIを叩いていきます。

```javascript:index.js
// モジュールのインポート
const server = require("express")();
const line = require("@line/bot-sdk");
const dialogflow = require("dialogflow");
const format = require('date-fns/format');
const utcToZonedTime = require('date-fns-tz/utcToZonedTime');
const axios = require('axios');
const livers = require('./livers');

// 関数
// 配信予定枠のVideoIdを取得
function fetchStreamingInfo(channelId) {
  const apiUrl = "https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=" + channelId + "&key=" + YOUTUBE_API_KEY + "&eventType=upcoming&type=video";
  return axios.get(apiUrl)
    .then(response => {
      if (!response) {
        return Promise.reject(new Error(`fetchStreamingSummary ${response.status}: ${response.statusText}`));
      } else {
        return response;
      }
    })
};

// 配信予定時刻を取得
function fetchStreamingScheduledInfo(videoId) {
  const apiUrl = "https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=" + videoId + "&key=" + YOUTUBE_API_KEY;
  return axios.get(apiUrl)
    .then(response => {
      if (!response) {
        return Promise.reject(new Error(`fetchStreamingSchedule ${response.status}: ${response.statusText}`));
      } else {
        return response;
      }
    })
};

// 配信予定時刻を日本時間に変換
function utcToJapanDate(utcDate) {
  const timeZone = 'Asia/Tokyo';
  const japanDate = utcToZonedTime(utcDate, timeZone);
  const pattern = 'HH時mm分';
  const formatedDate = format(japanDate, pattern, { timeZone: timeZone })
  return formatedDate;
};

async function createReplyMessage(liverName) {
	try {
    const streamingInfo = await fetchStreamingInfo(livers[liverName]["channelId"]);
    if (streamingInfo.data.items[0]) {
      const videoId = streamingInfo.data.items[0].id.videoId;
      const streamingScheduledInfo = await fetchStreamingScheduledInfo(videoId);
      const scheduledStartTime = streamingScheduledInfo.data.items[0].liveStreamingDetails['scheduledStartTime'];
      const streamingUrl = "https://www.youtube.com/watch?v=" + videoId;
      return `${liverName}は${utcToJapanDate(scheduledStartTime)}から配信予定です！\n${streamingUrl}`;
    }
    return `いまのところ${liverName}の配信予定は無いようです。\nまた後で聞いてみてくださいね！`;
	} catch (error) {
    console.log(`エラーが発生しました:${error}`);
  };
};

// LINEBOTにリプライメッセージを送信させる
function lineBotReplyMessage(token, replyMessage) {
  bot.replyMessage(token, {
    type: "text",
    text: replyMessage
  });
}

// 環境変数設定
const line_config = {
	channelAccessToken: process.env.LINE_ACCESS_TOKEN,
	channelSecret: process.env.LINE_CHANNEL_SECRET
};

// Youtube Data API Key
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY;

// Webサーバー設定
server.listen(process.env.PORT || 3000);

// APIコールのためのクライアントインスタンスを作成
const bot = new line.Client(line_config);

// Dialogflowのクライアントインスタンスを作成
const session_client = new dialogflow.SessionsClient({
	project_id: process.env.GOOGLE_PROJECT_ID,
	credentials: {
		client_email: process.env.GOOGLE_CLIENT_EMAIL,
		private_key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, "\n")
	}
});

// ルーター設定
server.post('/bot/webhook', line.middleware(line_config), (req, res, next) => {
	// 先行してLINE側にステータスコード200でレスポンスする。
	res.sendStatus(200);

	// すべてのイベント処理のプロミスを格納する配列。
	let events_processed = [];

	// イベントオブジェクトを順次処理
	req.body.events.forEach((event) => {
		// この処理の対象をイベントタイプがメッセージで、かつ、テキストタイプだった場合に限定。
		if (event.type == "message" && event.message.type == "text"){
			events_processed.push(
				session_client.detectIntent({
					session: session_client.sessionPath(process.env.GOOGLE_PROJECT_ID, event.source.userId),
					queryInput: {
						text: {
							text: event.message.text,
							languageCode: "ja",
						}
					}
				}).then((responses) => {
					if (responses[0].queryResult && responses[0].queryResult.action == "get-liver-name"){
						const liverName = responses[0].queryResult.parameters.fields.livers.stringValue;
						createReplyMessage(liverName).then(replyMessage => {
							lineBotReplyMessage(event.replyToken, replyMessage);
						});
					} else {
						const replyMessage = '申し訳ございません。\nその配信者もしくはニックネームは登録されていません。例えば以下のように入力してみてください！\n\nときのそら\nそらちゃん\n兎田ぺこら\nぺこら';
						lineBotReplyMessage(event.replyToken, replyMessage);
					};
				}).catch(error => {
					console.log(error)
				})
			);
		}
	});
});
```

主な処理が上記コードになります。今回このコードを書くにあたって以下の記事を大いに参考にさせていただきましたm(_ _)m

[LINEのBot開発 超入門（前編） ゼロから応答ができるまで](https://qiita.com/nkjm/items/38808bbc97d6927837cd)
[LINEのBot開発 超入門（後編） メッセージの内容と文脈を意識した会話を実現する](https://qiita.com/nkjm/items/4de41988969e6f17adcb)

処理の簡単な流れは以下のようになっています。

1. LINEから入力を受け取る
1. Dialogflowと通信して入力されたVtuberの正式名称を明らかにする
1. YoutubeDataAPIと通信し、入力されたVtuberの配信情報を取得する
1. 受け取った情報をLINE上に表示する

もう少し細かくコードを説明していきます。

```javascript
// モジュールのインポート
const server = require("express")();
const line = require("@line/bot-sdk");
const dialogflow = require("dialogflow");
const format = require('date-fns/format');
const utcToZonedTime = require('date-fns-tz/utcToZonedTime');
const axios = require('axios');
const livers = require('./livers');
```
今回使うモジュールをインポートしています。
formatとutcToZonedTimeはYoutubeDataAPIから取得してきたVtuberの配信予定時刻を日本時間に変換するために用いています。
axiosは各種APIと通信するために用います。お馴染みですね。

```javascript
// 配信予定枠のVideoIdを取得
function fetchStreamingInfo(channelId) {
  const today = new Date(new Date().setHours(0, 0, 0, 0));
  const apiUrl = "https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=" + channelId + "&key=" + YOUTUBE_API_KEY + "&eventType=upcoming&publishedAfter=" + today.toISOString() + "&type=video";
  return axios.get(apiUrl)
    .then(response => {
      if (!response) {
        return Promise.reject(new Error(`fetchStreamingSummary ${response.status}: ${response.statusText}`));
      } else {
        return response;
      }
    })
};
```

この関数でVtuberの配信予定枠のVideoIdを取得しています。配信先のURLは``"https://www.youtube.com/watch?v={VideoId}"``になるためこの処理が必要です。また取得したVideoIdを用いて次の関数で配信予定時刻を取得します。

```javascript
// 配信予定時刻を取得
function fetchStreamingScheduledInfo(videoId) {
  const apiUrl = "https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=" + videoId + "&key=" + YOUTUBE_API_KEY;
  return axios.get(apiUrl)
    .then(response => {
      if (!response) {
        return Promise.reject(new Error(`fetchStreamingSchedule ${response.status}: ${response.statusText}`));
      } else {
        return response;
      }
    })
};
```

取得してきた配信予定時刻はUTCになっているので下記関数で日本時間に変換し、○時○分という形にフォーマットしています。今回日付処理には[date-fns](https://date-fns.org/)を使ってみました。

```javascript
// 配信予定時刻を日本時間に変換
function utcToJapanDate(utcDate) {
  const timeZone = 'Asia/Tokyo';
  const japanDate = utcToZonedTime(utcDate, timeZone);
  const pattern = 'HH時mm分';
  const formatedDate = format(japanDate, pattern, { timeZone: timeZone })
  return formatedDate;
};
```

最後に取得してきた情報をまとめて、LINEBOTのメッセージを作成します。

```javascript
async function createReplyMessage(liverName) {
	try {
    const streamingInfo = await fetchStreamingInfo(livers[liverName]["channelId"]);
    if (streamingInfo.data.items[0]) {
      const videoId = streamingInfo.data.items[0].id.videoId;
      const streamingScheduledInfo = await fetchStreamingScheduledInfo(videoId);
      const scheduledStartTime = streamingScheduledInfo.data.items[0].liveStreamingDetails['scheduledStartTime'];
      const streamingUrl = "https://www.youtube.com/watch?v=" + videoId;
      return `${liverName}は${utcToJapanDate(scheduledStartTime)}から配信予定です！\n${streamingUrl}`;
    }
    return `いまのところ${liverName}の配信予定は無いようです。\nまた後で聞いてみてくださいね！`;
	} catch (error) {
    console.log(`エラーが発生しました:${error}`);
  };
};
```

#今後改善していきたいこと
**①デプロイ先の再検討**
Herokuは30分間隔でdynoがスリープしてしまいます。dynoがスリープ状態のときにチャットを投げると、dynoの起動に時間がかかり返答が遅くなってしまいます。課金するかデプロイ先を変えることで対処できないか考えています。

**②取得データのキャッシュ**
YoutubeDataAPIはAPIを叩ける回数がかなり少なく制限されています。今使っているのは主に自分と友人だけのため問題ありませんが、利用者が増えるとすぐに制限に引っかかってしまいます。データのキャッシュをおこなってAPIを叩く回数を抑える工夫が今後必要になってきます。

#おわりに
本アプリのコードはGithubで公開しています。少しでも良いと思ったら、スターいただけると励みになりますm(_ _)m

https://github.com/crypt0box/Vsche