この記事は「マイスター・ギルド:夏のアドベントカレンダー2021」6日目の記事です。
こんにちは、マイスター・ギルド開発部のヤマホンです。毎週見ているアニメの前回放送が、重要回かどうかを調べて通知する機能を作り、自動化しようと思います。
いったい、なんのアニメかって?バーロー、そんなこと教えられっかよ。
(本当は、次回の重要度が調べられたらいいんですが、さすがに未放送の回がまとめられることはないので、そこは目をつぶっていただきます・・・)
この記事で行う行為はWebスクレイピングといわれる行為です。スクレイピング自体は違法ではありませんが、著作権にかかわる場合や、当該サイトの利用規約で禁止されている場合など、違法となる場合があります。
方針
- アニメ公式サイトにアクセスし、前回のタイトルを取得
- まとめサイトにアクセスし、重要回のリストを取得
- 上記を照合し、前回放送の重要度(A、B、C)を判定
- 上記の処理を、Node.jsで実行できるようにする
- 処理結果をSlackに送信したい
- cronを使って、毎週決まった時間にNode.jsを実行する
1. アニメ公式サイトにアクセスし、前回のタイトルを取得する
まずはアニメ公式サイトにアクセスし、DOM構造を調べてみましょう。
前回タイトルの表示されているページにアクセス。ううーん、モザイクばっかりでよくわかりません(汗)。
とりあえず欲しいのは、赤で囲った前回のタイトル部分です。その部分のDOM構造を見てみると、class="oa_title"
で指定されています。
では、実際にJavaScriptでタイトルを取得できるか、デベロッパーモードで試してみます。
うん、取得できましたね。
このページは、URLに日付が入っているので、そこをちゃんと処理して、先週の土曜の日付でアクセスするように工夫してやる必要がありそうですし、放送のなかった週はちゃんと404エラーの対処もしてやる必要がありそうです。
2. まとめサイトにアクセスし、重要回のリストを取得
次に、先程取得したタイトルが、まとめサイトで重要度いくらと紹介されているかを確認します。
なんとこのまとめサイト、重要回のタイトル部分だけ全てH4タグで囲われています!しかも、重要度を指す(A、B、C)部分も【】で囲われていて、とても扱いやすそうです!
じゃあ、こちらもデベロッパーモードで試してみましょう。
うんうん、ちゃんと取得できています。
3. 上記を照合し、前回放送の重要度(A、B、C)を判定
では、上記で取得した、前回のタイトルと重要回のリストを使って、前回放送の重要度を判定してみましょう。
ちゃんと判定できているかどうか確かめるため、実際の前回タイトルではなく、重要回じゃない時のタイトルと、重要回のタイトルを使って、それぞれ試してみます。
まずは、重要回じゃない時のタイトル。
重要回じゃない場合は、titleMatch2.length === 0で判定できそうですね。
ちゃんと、判定できていますね。
4. 上記の処理を、Node.jsで実行できるようにする
では、次に上記の処理を組み合わせて、Node.jsで実行できるようにします。
ブラウザ上のJSで行うDOMの処理が、バックエンドのNode.jsで同じように動くとは思えません。なので調べてみたところ、JSDOM というライブラリを使えばDOM操作が行えるようです。
では、早速Node.jsのREPLモードで以下の通り、実行してみましょう。
const axios = require("axios");
const { JSDOM } = require("jsdom");
// アニメ公式サイトから前回のタイトルを取得
const url1 = 'https://●●●';
const response1 = axios.get(url1);
const dom1 = new JSDOM(response1.data);
let prevTitle = dom1.window.document.getElementsByClassName('oa_title')[0].textContent.match(/「(.*)」/)[1];
if (prevTitle.match(/(.*)(/)) prevTitle = prevTitle.match(/(.*)(/)[1]; // (前編)など全角括弧で囲まれた部分を取り除く
if (prevTitle.match(/(.*)\(/)) prevTitle = prevTitle.match(/(.*)\(/)[1]; // 半角括弧で囲まれた補足部分を取り除く
// まとめサイトから、タイトルと重要度が記載された部分を取得
const url2 = 'https://■■■';
const response2 = axios.get(url2);
const dom2 = new JSDOM(response2.data);
const importantTitles = dom2.window.document.getElementsByTagName("h4");
// 前回のタイトルがまとめサイトに記載されているかを確認
const titleMatch = Array.prototype.filter.call(importantTitles, function (el) {
return el.textContent.match(prevTitle);
});
// 前回の放送の重要度を表示
const importance = titleMatch.length === 0 ? "重要回ではありません" : '重要度' + titleMatch[0].textContent.match(/【(.*)】/)[0];
console.log(importance);
> "重要回ではありません"
よしよし、前回の放送は重要回ではないので、ちゃんと動いているようですね。
では、これをjsファイルにして、Node.jsのCLIコマンドで実行してみましょう!
んん??・・・'textContent' of undefined
?
あれれ〜おっかしいぞ〜?
・・・なぜだ、なぜだ、なぜだ?
REPLで実行する場合と、ファイルにして実行する場合だと何か違うのか???
・・・としばらく悩んでしまった僕は、まだまだ素人プログラマーです。
JavaScript初心者が(きっと)みんな陥るであろう、非同期処理の問題でした。
axiosでウェブから情報を取得してくるのは非同期処理、つまり、ウェブに通信してレスポンスが返ってくる前に次の処理に進んでしまいます。Webページの情報がないまま処理を進めても、そりゃエラーメッセージも出るってもんです。
そこで、慣れないasync/awaitの処理(しかもエラー処理をしつつ、2つ同時)に四苦八苦しながら出来上がったのが以下のコードです。
const axios = require("axios");
const { JSDOM } = require("jsdom");
(async () => {
// アニメ公式サイトから前回のタイトルを取得
const url1 = `https://●●●/${getPrevSaturday()}.html`;
axios.get(url1).then(async (response1) => {
const dom1 = new JSDOM(response1.data);
let prevTitle = dom1.window.document.getElementsByClassName('oa_title')[0].textContent.match(/「(.*)」/)[1];
if (prevTitle.match(/(.*)(/)) prevTitle = prevTitle.match(/(.*)(/)[1]; // (前編)など全角括弧で囲まれた部分を取り除く
if (prevTitle.match(/(.*)\(/)) prevTitle = prevTitle.match(/(.*)\(/)[1]; // 半角括弧で囲まれた補足部分を取り除く
// まとめサイトから、タイトルと重要度が記載された部分を取得
const url2 = 'https://■■■';
const response2 = await axios.get(url2);
const dom2 = new JSDOM(response2.data);
const importantTitles = dom2.window.document.getElementsByTagName("h4");
// 前回のタイトルがまとめサイトに記載されているかを確認
const titleMatch = Array.prototype.filter.call(importantTitles, function (el) {
return el.textContent.match(prevTitle);
});
// 前回の放送の重要度を表示
const importance = titleMatch.length === 0 ? "重要回ではありません。" : '重要度' + titleMatch[0].textContent.match(/【(.*)】/)[0];
const message = `先週は${importance}`;
console.log(message);
}).catch((error) => {
const message = '先週は放送されていません。';
console.log(message)
});
})();
function getPrevSaturday() {
const prevSaturday = new Date(); // この時点ではまだtoday
prevSaturday.setDate(prevSaturday.getDate() - prevSaturday.getDay() - 1)
const year = ("0000" + prevSaturday.getFullYear().toString()).slice(-4);
const month = ("00" + (prevSaturday.getMonth() + 1).toString()).slice(-2);
const date = ("00" + prevSaturday.getDate().toString()).slice(-2);
return `${year}${month}${date}`;
}
では、動かしてみましょう!
(1つ目は404の場合、2つ目は重要回ではない場合、3つ目は重要会の場合です。日付の部分を直接いじって、検証しています。)
よし!今度こそちゃんと動きましたよ!
これは使いやすいようにnpmスクリプトにしておきます。(割愛)
5. 処理結果をSlackに送信したい
公式ページを見ながら作業をしていきます。ちょっとわかりづらい部分があるので、こちらも参考に。
手順
- メッセージを送りたいSlackのワークスペースでオリジナルアプリを作る
- そのアプリに
chat:write
のOAuth許可を付与する - Node.jsからメッセージを作成、postで送信する
- tokenなどの重要な情報をenvファイルに格納(dotenvを利用 参考)
こんなに簡単に作れていいんでしょうか・・・(詳細は割愛)
6. cronを使って、毎週決まった時間にNode.jsを実行する
この勢いで、どんどん割愛していきましょう!
では、できたアプリを、サーバーにデプロイします!(割愛)
それから、以下のようにcronをつかって、毎週水曜日のお昼にコマンドが自動実行されるようにします。
今回使ったサーバー(AWS EC2)の場合、以下の通りにします。
$ cd /etc/cron.d
$ sudo vi every-wednesday
10 6 * * 3 root npm run prevAnime --prefix /var/www/AppName
(今回は試しに、水曜日の15:10に届くようにしています。最後の行の10 6
が15:10に対応しています。サーバーは世界標準時刻のため)
この通り、時間になったらちゃんと通知が届きました!やったね!
(前回だから、Nextじゃおかしいって?まあ、そこは生暖かい目で見逃してください)
学んだこと
- async/await、大事
- 自由に使えるサーバーが一つあると、便利でいいね
- Slackの通知、思った以上に簡単でびっくり
- Node.jsはブラウザのJSと比べて、必要なrequireの記述は違うけど、基本的に同じように使える
今回、ネタ系の技術記事を初めて書きましたが、少しでも笑っていただける内容にできたでしょうか?将来は、もっと面白くて、技術的にもすごい記事を書けるようになっていきたいと思います。
最後に Webスクレイピングについて
冒頭でも述べましたが、Webスクレイピングは違法となる場合があります。
Webスクレイピングに関して、詳しい記事がありますので、感謝とともにご紹介させていただきます。
2021/8/22 誤りがあったため更新いたしました
以下の内容が誤っていたため、修正を行いました。
ご迷惑をおかけし、申し訳ございません。
- 前回タイトルの取得方法を変更
- 上記変更に伴い、404エラーが返ってきた時の処理を追記