Node.jsの勉強をする中で、LINE Messaging APIの公式チュートリアルに触れる機会がありました。
このチュートリアルはBotへの投稿に対して特定の返事をするだけですが、せっかくなので以下の機能を追加してみました。
- テキストが投稿されたらオウム返しする。テキスト以外が投稿されたら「テキストを入力してください」と返す。
- オウム返しするときに、ついでにWebスクレイピングしたデータも返す。
オウム返しBotの作成
LINE Messaging APIの公式チュートリアルの通りに進めたうえで、Webhookイベントオブジェクトのプロパティを確認し、テキストが送信されたときにはreq.body.events[0].message.text
(送信されたテキストそのもの)を、それ以外が送信されたときは特定の文字列を返すようにしました。
下記で、コメントを入れているのが変更部分です。
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にアップロードしてテストしたところ、きちんと動いてくれました! ちゃんと動くところを見れると嬉しい。

現時点でのフォルダ構成は、以下の通りです。シンプル。
heroku-sample-app
└─node_modules
│ └─express
├─.gitignore
├─index.js
├─package-lock.json
└─package.json
Webスクレイピングしたデータの追加
LINE Botが動いたのは良いのですが、それだけだとそんなに勉強にならないので、こちらのサイトから、プロ野球の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
まずは大枠を書きます。
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形式で入れていきます。
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
は完成です!)
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側で読み込んでいきます。
変更箇所にはコメントを入れています。
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.
終わりに
Webスクレイピングは思ったより簡単にできたのですが、Promiseやasync/awaitの動きを把握するのは相当難しかったです。もっと使いこなせるよう、引き続き頑張ろうと思います。
(あと、DeNA戦の情報を返してくれるのはいいけど、普通にWebサイトに情報を見にいった方が早いので、これだけだと実用面ではイマイチだと作ってから思いました・・・)