LINEオウム返しbotに、Node.jsでWebスクレイピングしたデータを追加する

Node.jsの勉強をする中で、[LINE Messaging APIの公式チュートリアル](https://developers.line.biz/ja/docs/messaging-api/nodejs-sample/)に触れる機会がありました。
このチュートリアルはBotへの投稿に対して特定の返事をするだけですが、せっかくなので以下の機能を追加してみました。

+ テキストが投稿されたらオウム返しする。テキスト以外が投稿されたら「テキストを入力してください」と返す。
+ オウム返しするときに、ついでにWebスクレイピングしたデータも返す。


#オウム返しBotの作成

[LINE Messaging APIの公式チュートリアル](https://developers.line.biz/ja/docs/messaging-api/nodejs-sample/)の通りに進めたうえで、Webhookイベントオブジェクトのプロパティを確認し、テキストが送信されたときには`req.body.events[0].message.text`（送信されたテキストそのもの）を、それ以外が送信されたときは特定の文字列を返すようにしました。

下記で、コメントを入れているのが変更部分です。

```javascript:index.js
const https = require('https');
const express = require('express');
const app = express();

const PORT = process.env.PORT || 3000;
const TOKEN = process.env.LINE_ACCESS_TOKEN;

app.use(express.json());
app.use(express.urlencoded({
    extended : true
}));

app.get("/", (req, res) => {
    res.sendStatus(200);
});

app.post("/webhook", (req, res, next) => {
    res.send("HTTP POST request sent to the webhook URL!");

    const replyMessages = [];

    if (req.body.events[0].type === "message"){

        //req.body.events[0].message.typeがtextかどうかで分岐
        if (req.body.events[0].message.type === "text"){
            //textだった場合は、その内容をそのまま返す
            replyMessages.push({
                    "type" : "text",
                    "text" : req.body.events[0].message.text
            }); 
        }else{
            //textじゃない場合は、固定の文字列を返す
            replyMessages.push({
                "type" : "text",
                "text" : "テキストを入力してください"
            })
        };

        const dataString = JSON.stringify({
            replyToken: req.body.events[0].replyToken,
            messages: replyMessages
        }); 

        const headers = {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + TOKEN
        };

        const webhookOption = {
            "hostname": "api.line.me",
            "path": "/v2/bot/message/reply",
            "method": "POST",
            "headers": headers,
            "body": dataString
        }

        const request = https.request(webhookOption, (res) => {
            res.on("data", (d) => {
                process.stdout.write(d);
            });
        });

        request.on("error", (err) => {
            console.error(err);
        });

        request.write(dataString);
        request.end();
    }
});

app.listen(PORT, () => {
    console.log(`Example app listening at http://localhost:${PORT}`);
});
```

チュートリアルにあるとおりherokuにアップロードしてテストしたところ、きちんと動いてくれました！　ちゃんと動くところを見れると嬉しい。

<img width="300" alt="linebot1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/820066/c89be51e-e383-c020-c18d-53de9d718b30.png">


現時点でのフォルダ構成は、以下の通りです。シンプル。

heroku-sample-app
└─node_modules
│   └─express
├─.gitignore
├─index.js
├─package-lock.json
└─package.json


#Webスクレイピングしたデータの追加

LINE Botが動いたのは良いのですが、それだけだとそんなに勉強にならないので、[こちらのサイト](https://baseball.yahoo.co.jp/npb/schedule/)から、プロ野球のDeNA戦データを引っ張ってくることにしました。
（ちなみに、Webスクレイピングを禁止するような文言が利用規約に含まれていないことは、先んじて確認しました。結構そのあたりが問題になるため、Webスクレイピングは要注意のようです。）

Webスクレイピングを選んだのは、70%は趣味、30%は非同期処理（Promiseとかasync/awaitとか）を学べるかなと思ったのが理由です。

サイトのhtmlデータ取得には`axios`、取得したデータの解析には`cheerio`を使いますので、これらのモジュールのインストールから進めていきます。

###①追加モジュールのインストール

チュートリアル通りに作った`heroku-sample-app`配下で、以下のモジュールを追加でインストールします。

```
> npm i axios cheerio
> npm ls -depth 0
+-- axios@0.21.1
+-- cheerio@1.0.0-rc.10
`-- express@4.17.1
```

チュートリアルでインストールしたexpressに加え、2つモジュールが追加されたことが確認できました。


###②Webスクレイピング（getBaseballData.js）のコーディング

Webスクレイピングするプログラムを書いていきます。
ちなみに、プロジェクトフォルダ配下の構成は以下の通りです。

heroku-sample-app
└─node_modules
│   ├─axios
│   ├─cheerio
│   └─express
├─.gitignore
├─getBaseballData.js
├─index.js
├─package-lock.json
└─package.json


まずは大枠を書きます。

```javascript:getBaseballData.js
const axios = require('axios');
const cheerio = require('cheerio');

//axiosでgetしたデータの解析が終わってからreturnするよう、async()とする
const getBaseballData = async () => {

    //axiosでgetしたデータの解析が終わるのを待つよう、awaitを入れる
    const resData = await axios.get("https://baseball.yahoo.co.jp/npb/schedule/")
    .then((response) => {   //axiosはPromiseを返すので、.thenで渡せる

        const html = response.data;
        const $ = cheerio.load(html); //cheerioでloadすることで、htmlタグやid,classで解析できる
        
        /*-- ここにLoadしたhtmlデータを解析する処理を入れていく --*/

    })
    .catch( e => console.error(e));　//axiosに対するcatch文

    console.log(resData);

    //index.jsから非同期で呼び出すことを想定し、Promiseを返す
    return new Promise( resolve => resolve(resData));　
};

//モジュールのエクスポート
module.exports = getBaseballData;

```

まずは大枠はこんな感じで書いてみました。
`axios`と`cheerio`をロードした上で、`getBaseballData`関数を書いていきます。
自分なりに意識したポイントもはコメントで入れていますが、スクレイピングデータの解析が終わる前にreturnしないよう、async/awaitを使っています。

また、`getBaseballData`は`index.js`から非同期で呼び出す想定なので、Promiseを返すようにします。

次に、Webサイトから取得したデータの解析を進めます。
DeNA戦情報を取得しますが、タイミングによって試合前・試合中・試合後のどれかがかわってくるので、それぞれを意識してデータを`gData`にJSON形式で入れていきます。

```javascript:getBaseballData.js
const axios = require('axios');
const cheerio = require('cheerio');

const getBaseballData = async () => {
    const resData = await axios.get("https://baseball.yahoo.co.jp/npb/schedule/")
    .then((response) => {

        const html = response.data;
        const $ = cheerio.load(html);

        //ここから追加
        const gData = {};   //DeNA戦情報を入れるJSON
        let homeTeam = "";  //DeNA戦を探すための変数、letで宣言
        let awayTeam = "";  //DeNA戦を探すための変数、letで宣言

        //$で、".bb-score__content"というidを持つタグを探索し、elemに返す。（その日に開催される試合情報が埋め込まれている）
        //eachを使って、DeNA戦を探す。
        $('.bb-score__content' ).each((i, elem) => {
            //チーム名を取得し、homeTeam/awayTeamに代入   
            homeTeam = $('.bb-score__homeLogo' , elem).text()     
            awayTeam = $('.bb-score__awayLogo' , elem).text()   

            //DeNA戦が見つかったら、チーム名、先発（試合前のみ取得可能）、
            //勝ち投手負け投手（試合後のみ取得可能）、
            //スコア（試合中もしくは試合後に取得可能）、
            //ステータス（試合が何回まで進んでいるか、
            //もしくは試合終了しているか）をhtmlタグに付与されたidから取得 
            if(homeTeam === "DeNA" || awayTeam === "DeNA"){
                gData.home  = { "team" : homeTeam};
                gData.away  = { "team" : awayTeam};
                gData.home.senpatsu = $('.bb-score__player--probable', '.bb-score__home'  , elem).text();
                gData.home.pitcher  = $('.bb-score__player          ', '.bb-score__home'  , elem).text();
                gData.away.senpatsu = $('.bb-score__player--probable', '.bb-score__away'  , elem).text();
                gData.away.pitcher  = $('.bb-score__player'          , '.bb-score__away'  , elem).text();
                gData.home.score    = $('.bb-score__score--left'     , '.bb-score__detail', elem).text();
                gData.away.score    = $('.bb-score__score--right'    , '.bb-score__detail', elem).text();
                gData.status        = $('.bb-score__link'            , '.bb-score__detail', elem).text();

            };
        });

    //追加ここまで

    /*-- この後、ここでgDataの中身を使って返り値を作っていきます。 --*/

    })
    .catch( e => console.error(e));

    console.log(resData);
    return new Promise( resolve => resolve(resData));
};

module.exports = getBaseballData;
```


プロ野球は1日に複数の試合が開催されているので、その中でDeNA戦を探索し、idから返り値を組み立てるのに必要な情報を取得しています。


最後に、取得したデータから試合前・試合中・試合後のどれかを判別し、返り値を作ります。
（これで
`getBaseballData.js`は完成です！）


```javascript:getBaseballData.js

const axios = require('axios');
const cheerio = require('cheerio');

const getBaseballData = async () => {
    const resData = await axios.get("https://baseball.yahoo.co.jp/npb/schedule/")
    .then((response) => {

        const html = response.data;
        const $ = cheerio.load(html);
        const gData = {};
        let homeTeam = "";
        let awayTeam = "";

        $('.bb-score__content' ).each((i, elem) => {   
            homeTeam = $('.bb-score__homeLogo' , elem).text()     
            awayTeam = $('.bb-score__awayLogo' , elem).text()     
            if(homeTeam === "DeNA" || awayTeam === "DeNA"){
                gData.home  = { "team" : homeTeam};
                gData.away  = { "team" : awayTeam};
                gData.home.senpatsu = $('.bb-score__player--probable', '.bb-score__home'  , elem).text();
                gData.home.pitcher  = $('.bb-score__player          ', '.bb-score__home'  , elem).text();
                gData.away.senpatsu = $('.bb-score__player--probable', '.bb-score__away'  , elem).text();
                gData.away.pitcher  = $('.bb-score__player'          , '.bb-score__away'  , elem).text();
                gData.home.score    = $('.bb-score__score--left'     , '.bb-score__detail', elem).text();
                gData.away.score    = $('.bb-score__score--right'    , '.bb-score__detail', elem).text();
                gData.status        = $('.bb-score__link'            , '.bb-score__detail', elem).text();

            };
        });

        //ここから追加
        //statusを元に、試合の有無、有る場合の情報をまとめる関数を宣言。
        const game_info = (home, away, status) =>{
            if( !status ){  //試合が無い場合
                return "ありません。応援はお休みです。";
            }else{
                if( status === "見どころ" ){  //試合前。予告先発を表示。
                    return "<対戦> " 
                    + home.team + "(" + home.senpatsu + ") vs " 
                    + away.team+ "(" + away.senpatsu + ")";
                }else if( status === "試合終了" ){ //試合後。スコアと勝ち投手負け投手を表示。
                    return `<${status}>`  
                    + `${home.team}(${home.pitcher}) ${home.score}` + "-" 
                    + `${away.score} ${away.team}(${away.pitcher})`;
                }else {  //試合中。何回まで進んでいるかとスコアを表示。
                    return `<${status}>` 
                    + `${home.team} ${home.score}` + "-" 
                    + `${away.score} ${away.team}`;
                }
            };
        }
        
        //上記で宣言した関数を呼び出し、返り値を作成
        const resData = game_info(gData.home, gData.away, gData.status);

        //return
        return resData;
        //追加ここまで

    })
    .catch( e => console.error(e));

    console.log(resData);
    return new Promise( resolve => resolve(resData));
};

module.exports = getBaseballData;`
```


###③index.js側でのロード、herokuへのデプロイ

`getBaseballData.js`が無事に出来ました！
あとは、これをindex.js側で読み込んでいきます。
変更箇所にはコメントを入れています。

```javascript:index.js
const https = require('https');
const express = require('express');
//getBaseballDataの読み込み
const getBaseballData = require('./getBaseballData');
const app = express();

const PORT = process.env.PORT || 3000;
const TOKEN = process.env.LINE_ACCESS_TOKEN;

app.use(express.json());
app.use(express.urlencoded({
    extended : true
}));

app.get("/", (req, res) => {
    res.sendStatus(200);
});

//asyncの追加
app.post("/webhook", async (req, res, next) => {
    res.send("HTTP POST request sent to the webhook URL!");

    const replyMessages = [];

    if (req.body.events[0].type === "message"){
        if (req.body.events[0].message.type === "text"){

            //awaitで非同期にgetBaseballDataを呼び出し。
            const baseBallData = await getBaseballData().catch( e => {
                console.error(e); 
                return '確認中・・・'
            });

            replyMessages.push({
                    "type" : "text",
                    "text" : req.body.events[0].message.text
                },{
                    //テキストを1文追加
                    "type" : "text",
                    "text" : "本日のDeNA戦は・・・"
                },{
                    //getBaseballDataで取得したデータを追加
                    "type" : "text",
                    "text" : baseBallData
            }); 


        }else{
            replyMessages.push({
                "type" : "text",
                "text" : "テキストを入力してください"
            })
        };

        const dataString = JSON.stringify({
            replyToken: req.body.events[0].replyToken,
            messages: replyMessages
        }); 

        const headers = {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + TOKEN
        };

        const webhookOption = {
            "hostname": "api.line.me",
            "path": "/v2/bot/message/reply",
            "method": "POST",
            "headers": headers,
            "body": dataString
        }

        const request = https.request(webhookOption, (res) => {
            res.on("data", (d) => {
                process.stdout.write(d);
            });
        });

        request.on("error", (err) => {
            console.error(err);
        });

        request.write(dataString);
        request.end();
    }
});



app.listen(PORT, () => {
    console.log(`Example app listening at http://localhost:${PORT}`);
});
```

下記のようにherokuにデプロイします。

```
> git add .
> git commit -m "add baseballdata"
> git push heroku master
…
…
remote: Verifying deploy... done.

```

完成です。無事に動いていることが確認できました！
<img width="300" alt="linebot2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/820066/fb653084-4b9a-b382-ea60-b86ef3707704.png">


#終わりに

Webスクレイピングは思ったより簡単にできたのですが、Promiseやasync/awaitの動きを把握するのは相当難しかったです。もっと使いこなせるよう、引き続き頑張ろうと思います。
（あと、DeNA戦の情報を返してくれるのはいいけど、普通にWebサイトに情報を見にいった方が早いので、これだけだと実用面ではイマイチだと作ってから思いました・・・）