API初心者向け「さけのわAPIで日本酒お勧めLINEボット」を作って苦戦した、APIレスポンスの処理解説

過去2回の記事では、まずはAPIを使ってみよう！ということで色々触ってきましたが、そろそろコンソール表示だけでは物足りなくなってきました。そこで、今回はAPIと連携したLINEボットの作成に挑戦します。やっぱりインターフェースがあるとテンション上がりますよね。

##この記事の概要

最初に完成したボットの動作イメージを付けておきます。日本酒にやたら詳しいワンコですね('ω')
<img width="30%" alt="Image from Gyazo" src="https://i.gyazo.com/73966605cce3c50ddbd7d495c79852f1.gif">

ユーザーがクリックしたメニューボタンに応じて、呼び出すAPIを切り替え、別の応答を返すLINEボットになります。

###使用しているAPI

このLINEボットでは以下3種類のAPIを使用しています。

- Advice Slip JSON API（[https://api.adviceslip.com/#top](https://api.adviceslip.com/#top)）
- さけのわデータAPI（[https://muro.sakenowa.com/sakenowa-data/](https://muro.sakenowa.com/sakenowa-data/)）
- Azure Translator API（[https://azure.microsoft.com/ja-jp/services/cognitive-services/translator/](https://azure.microsoft.com/ja-jp/services/cognitive-services/translator/)）

###動作の流れ

ざっくりと以下の流れでAPI連携した結果を返すLINEボットにしました。

- 「アドバイス」を要求されたら、Advice Slip JSON APIからランダムなアドバイス文章を返答する
 - APIから取得したアドバイス（英文）を、Azure Translator APIで日本語に翻訳する
 - 翻訳された日本語のアドバイス文章を返答する

- 「お勧めの日本酒」を要求されたら、さけのわデータAPIからお勧めの銘柄情報を返答する
 - 取得した銘柄一覧の要素数を確認して、ランダムな一銘柄を抽出する
 - 続けて、さけのわデータAPIからフレーバー情報を取得する
 - 抽出した銘柄にフレーバー情報が存在するか確認する（存在しない銘柄もある）
 - 銘柄名とフレーバー情報（存在する場合）を返答する

### 環境

|Module|Version|
|----|-----|
|Node.js|15.13.0|
|npm|7.7.6|
|axios|0.21.1|
|request|2.88.2|
|uuid|8.3.2|

##処理の解説（主にAPI周りを中心に）

この記事では**APIのレスポンスをうまく？処理する辺りに重点を置いて書きたい**ので、LINEボット周りの説明は薄くなりますが、あらかじめご了承ください。最初にコード全文を付けておきますが、長いので折りたたみセクションにしておきます。

<details><summary>コード全文はこちら</summary><div>

```js:line-bot-proto.js
// パッケージを使用します
const express = require('express');
const line = require('@line/bot-sdk');
const axios = require('axios');
const request = require('request');
const uuidv4 = require('uuid/v4');

// ローカル（自分のPC）でサーバーを公開するときのポート番号です
const PORT = process.env.PORT || 3000;

// Messaging APIで利用するクレデンシャル（秘匿情報）です。
const config = {
    channelSecret: 'YourChannelSecret',
    channelAccessToken: 'YourChannelAccessToken'
};

let pushText = '';

// サンプル関数
const botFunction = async (event) => {
    const userText = event.message.text;
    // ユーザーメッセージの識別（2種類なので、「アドバイスほしいなー」か否か）
    if (userText === 'アドバイス欲しいなー') {
        // 「リプライ」を使って先に返事しておきます
        await client.replyMessage(event.replyToken, {
            type: 'text',
            text: '調べています……'
        });
        try {
            // APIでランダムなアドバイスを取得する
            const response = await axios.get('https://api.adviceslip.com/advice');
            const jsonData = response.data;
            const SourceText = jsonData.slip.advice;

            // データ翻訳用の関数translateTextを呼び出す
            translateText(SourceText,);

            //非同期がうまくいかないので、とりあえず2秒待つ//
            const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
            await _sleep(2000);

        } catch (error) {
            // APIからエラーが返ってきたらターミナルに表示する
            pushText = '検索中にエラーが発生しました。ごめんね。';
            console.error(error);
        }

        // 「プッシュ」で後からユーザーに通知します
        return await client.pushMessage(event.source.userId, {
            type: 'text',
            text: pushText,
        });
    
      } else {
        // 「リプライ」を使って先に返事しておきます
        await client.replyMessage(event.replyToken, {
            type: 'text',
            text: '調べています……'
        });
        
        try {
            // さけのわデータAPIから「銘柄一覧」を取得する
            response = await axios.get('https://muro.sakenowa.com/sakenowa-data/api/brands');
            const jsonData = response.data;

            // アイテム数を確認し、ランダムに一つの銘柄を抽出する    
            const itemCount = jsonData['brands'].length;
            const ran_key = Math.floor(Math.random() * itemCount -1) + 1;
            const brandName = jsonData['brands'][ran_key]['name'];
            const brandId = jsonData['brands'][ran_key]['id'];
            
            // さけのわデータAPIから「フレーバーチャート」を取得する
            response2 = await axios.get('https://muro.sakenowa.com/sakenowa-data/api/flavor-charts');
            const jsonData2 = response2.data;

            // 取得したJSONから、対象銘柄のデータを検索する（存在しない場合がある）
            flavorData = jsonData2['flavorCharts'].find((v) => v.brandId === brandId);
            
            // もしフレーバーチャートが存在しない場合は銘柄名のみ、存在する場合はチャート付きで表示する
            if( typeof flavorData === 'undefined'){
                pushText = '【お勧め銘柄】' + brandName;
            }else{
                pushText = '【お勧め銘柄】' + brandName + '\n';
                pushText += '華やかさ：' + flavorData['f1'] + '\n';
                pushText += '芳醇：' + flavorData['f2'] + '\n';
                pushText += '重厚：' + flavorData['f3'] + '\n';
                pushText += '穏やか：' + flavorData['f4'] + '\n';
                pushText += 'ドライ：' + flavorData['f5'] + '\n';
                pushText += '軽快：' + flavorData['f6'];
            }

        } catch (error) {
            // APIからエラーが返ってきたらターミナルに表示する
            pushText = '検索中にエラーが発生しました。ごめんね。';
            console.error(error);
        }

        // 「プッシュ」で後からユーザーに通知します
        return client.pushMessage(event.source.userId, {
            type: 'text',
            text: pushText,
        });
    }
};

// 英文をTranslator APIにPOSTメソッドで渡し、日本語翻訳を行う
function translateText(SourceText){
    let options = {
        method: 'POST',
        baseUrl: 'https://api.cognitive.microsofttranslator.com/',
        url: 'translate',
        qs: {
          'api-version': '3.0',
          'from': ['en'],
          'to': ['ja']
        },
        headers: {
          'Ocp-Apim-Subscription-Key': 'YourSubscriptionKey',
          'Ocp-Apim-Subscription-Region': 'YourServiceRegion',
          'Content-type': 'application/json',
          'X-ClientTraceId': uuidv4().toString()
        },
        body: [{
              'text': SourceText
        }],
        json: true,
        async: true,
    };
  
    // レスポンスのJSON配列から、翻訳テキスト部分を取得
    request(options, function(err, res, body){
          pushText = body[0]['translations'][0]['text'];
    });
  };

// ########################################
//  LINEサーバーからのWebhookデータを処理する部分
// ########################################

// LINE SDKを初期化します
const client = new line.Client(config);

// LINEサーバーからWebhookがあると「サーバー部分」から以下の "handleEvent" という関数が呼び出されます
async function handleEvent(event) {
    // 受信したWebhookが「テキストメッセージ以外」であればnullを返すことで無視します
    if (event.type !== 'message' || event.message.type !== 'text') {
        return Promise.resolve(null);
    }
    // サンプル関数を実行します
    return botFunction(event);
}

// ########################################
//          Expressによるサーバー部分
// ########################################

// expressを初期化します
const app = express();

// HTTP POSTによって '/webhook' のパスにアクセスがあったら、POSTされた内容に応じて様々な処理をします
app.post('/webhook', line.middleware(config), (req, res) => {
  
    // 検証ボタンをクリックしたときに飛んできたWebhookを受信したときのみ以下のif文内を実行
    if (req.body.events.length === 0) {
        res.send('Hello LINE BOT! (HTTP POST)'); // LINEサーバーに返答します（なくてもよい）
        console.log('検証イベントを受信しました！'); // ターミナルに表示します
        return; // これより下は実行されません
    } else {
        // 通常のメッセージなど … Webhookの中身を確認用にターミナルに表示します
        console.log('受信しました:', req.body.events);
    }

    // あらかじめ宣言しておいた "handleEvent" 関数にWebhookの中身を渡して処理してもらい、
    // 関数から戻ってきたデータをそのままLINEサーバーに「レスポンス」として返します
    Promise.all(req.body.events.map(handleEvent)).then((result) => res.json(result));
});

// 最初に決めたポート番号でサーバーをPC内だけに公開します
// （環境によってはローカルネットワーク内にも公開されます）
app.listen(PORT);
console.log(`ポート${PORT}番でExpressサーバーを実行中です…`);
```

</div></details>

それでは、私が苦戦した箇所を中心に解説していきます('ω')　ほぼほぼ全部ですが･･

###Advice Slip JSON APIの利用

まずはココですね。axiosでAPIのGETメソッドを投げて、レスポンスを受け取ります。

```js
const response = await axios.get('https://api.adviceslip.com/advice');
```

[公式ページ](https://api.adviceslip.com/#top)で仕様を確認すると、以下の形式でslipオブジェクトを受け取るようです。

```json
{
  "slip": {
    "slip_id": "2",
    "advice": "Smile and the world smiles with you. Frown and you're on your own."
  }
}
```

私が今回欲しいのは、`advice`に入っているアドバイス文章そのものですので、以下の通り取り出します。

```js
const jsonData = response.data;
const SourceText = jsonData.slip.advice;
```

これで問題なく、`advice`にセットされているテキストが取り出しできました。これは私でもすんなり行けた珍しいケースですね。この後に取り出した英文のテキストを翻訳するためにAzure Translator APIを呼び出しています。

###Azure Translator APIの利用

こちらは地味に苦戦しました。苦戦したので単品の記事にしてあります。よければ初心者っぷりを見てあげてください。

https://qiita.com/ranchi1977/items/842dd4bb5ed56091327a

今回の記事向けに、レスポンスの形式と、そこからの取り出し部分だけ抜粋しておきます。

```json
[
  {
    "detectedLanguage": {
        "language": "en",
        "score": 1
    },
    "translations": [
        {
          "text": "疑わしい場合は、次の小さな一歩を踏み出してください。",
          "to": "ja"
      }
    ]
  }
]
```

このレスポンスに対して、欲しい翻訳結果（`translations`の`text`）の取り出しは、以下のコードになります。

```js
  request(options, function(err, res, body){

    console.log(body[0]['translations'][0]['text']);

  });
```

日本語にすると「配列の最初の要素の`translations`プロパティが持つ配列の最初の要素の`text`プロパティの値」という感じでしょうか。これで、無事にテキストの取り出しが成功しますが、大分しんどい感じです。（ごめんなさい、このレベルの初心者と思って優しい目で見てください）

###さけのわデータAPIの利用

それでは、本命のさけのわデータAPIの処理に入ります！本当は色々味の好みとか入力するとお勧めの銘柄を紹介してくれる、さけのわアプリみたいな恰好良い機能を作ってみたいものですが、今の私では叶わないので、以下の機能に留まっています。

- さけのわデータAPIから銘柄一覧情報を取得する（3,000銘柄以上登録されてました･･スゴイ･･）
- 銘柄一覧から、本日のお勧め銘柄を一つランダムに決定する
- その銘柄にフレーバー情報がある場合は、合わせて返答してあげる

まずは銘柄情報の取得からです。

```js
// さけのわデータAPIから「銘柄一覧」を取得する
response = await axios.get('https://muro.sakenowa.com/sakenowa-data/api/brands');
const jsonData = response.data;
```

こちらも[公式ページ](https://muro.sakenowa.com/sakenowa-data/)で仕様を確認すると、以下の形式でレスポンスを受け取るようです。

```json
{
  "brands": [
    {
      "id": 4558,
      "name": "越の一",
      "breweryId": 1832
    },
    {
      "id": 4611,
      "name": "吉の川",
      "breweryId": 1128
    }
  ]
}
```

なるほど、`brands`プロパティが大量の銘柄の配列を持つ形みたいですね。ここから配列内の適当な位置の`name`を取り出して終了でも良いのですが、後でフレーバー情報があるか確認しに行くために`id`情報も必要になります。そこで、［配列の要素数の確認］→［要素数内でランダムな数字を作成］→［その位置にいる銘柄の`name`と`id`を取得］という流れで処理をしていきます。

```js
// アイテム数を確認し、ランダムに一つの銘柄を抽出する    
const itemCount = jsonData['brands'].length;
const ran_key = Math.floor(Math.random() * itemCount -1) + 1;
const brandName = jsonData['brands'][ran_key]['name'];
const brandId = jsonData['brands'][ran_key]['id'];
```

続けてフレーバー情報を取得します。ここで先ほど取得した銘柄の`id`に一致するフレーバー情報があるか検索（`find`）を掛けています。この結果一致するアイテムが無い場合には、JavaScriptでお馴染みの`undefined`（何もセットされていない）となるので、それを判定に使って銘柄情報のみかフレーバー情報付きかを分岐させることができました。

```js
// さけのわデータAPIから「フレーバーチャート」を取得する
response2 = await axios.get('https://muro.sakenowa.com/sakenowa-data/api/flavor-charts');
const jsonData2 = response2.data;

// 取得したJSONから、対象銘柄のデータを検索する（存在しない場合がある）
flavorData = jsonData2['flavorCharts'].find((v) => v.brandId === brandId);
            
// もしフレーバーチャートが存在しない場合は銘柄名のみ、存在する場合はチャート付きで表示する
if( typeof flavorData === 'undefined'){
  pushText = '【お勧め銘柄】' + brandName;
}else{
  pushText = '【お勧め銘柄】' + brandName + '\n';
  pushText += '華やかさ：' + flavorData['f1'] + '\n';
```

ここも、そもそも「フレーバー情報が登録されていない銘柄がある」という視点が抜けていて大分ハマってしまいました。やっぱり仕様の確認に加え、実際に取得できているデータの中身をしっかり確認するのは大事ですね。（基本動作ができていない）

##おまけ（LINE Bot関連）

もし、この記事を見て「実際に動かしてみたいぜ」な人がいた場合に、LINE Bot関連の情報に全く触れていないので、少しだけ補足します。

- バックエンドサーバは、ローカルPCでNode.jsで動かしています（コード全文にも含んでいます）
- トンネリングサービスのngrokを利用して、公開用URLを取得しています
- LINE DevelopersのMessaging APIの設定、WebhookURLの設定を行い、LINEサーバと連携させています

詳細はこちらの記事が分かり易かったので、紹介させてください！

https://qiita.com/mintak21/items/fe9234d4b6a98bfc841a

ngrokは起動するたびに公開用URLが変わるので、WebhookURLの修正が必要だったりと注意は必要ですが、ローカルPCでこんなに手軽にLINEボットのお試し操作ができるなんて、凄いですよね。色々試してみたくなりました('ω')

##終わりに

今回の記事は非常に長文になってしまい･･、最後までお付き合い頂いた方は本当にありがとうございます。今回の記事を読んで頂きたいターゲットは以下くらいを想定して書いてみました。

- APIを使い始めたけど、レスポンスの処理がよく分からず苦戦している人（私と同じくらいのJavaScript初心者）
- とにかく色んなAPIの利用方法やソースコード例を見てみたい人
- とりあえず、タイトルに「日本酒」とあったから覗きに来た人

JavaScriptとかAPIとか触り始めたての初心者向けではありますが、私も色々調べて上記内容を何とか動かすことができましたし、きっと何らかのニーズはあるに違いない！と信じて投稿させて頂きます。なるべく期待を裏切らない、変な迷い込みを起こさないタイトル付けを意識しないといけないですね('ω')

さて、次は何ネタにしようかな･･。