【Firebase+Node.js+LINE SDK】230行でコロナ通知サービスを開発した話 (ソース付) #27

Firebase,Node.js等で開発中

コロナ感染者数を追いかけた1年

コロナに始まりコロナに終わった2020年、気づけば毎日、東京都の新規コロナ感染者数をチェックするのが日課になっていた。経営者たるものいち早く社会情勢をキャッチし舵取りをしなければならない。

毎日、15時頃に発表される新規感染者数の数値を、ニュースサイトをリロードしながら待つ。なぜか発表が15時より遅れる日もあって、やきもきしてしまう。

1年に1度、長期休暇中に一人ハッカソンを開催して何かしら開発してきた僕にとって、今年のテーマは迷いなく決定。この感染者数確認ルーチンの自動化だ。

毎日5分このルーチンにかかるとしたら1年間で実に30時間。これは自動化しなければ!

かくして年末年始、「東京都の新規コロナ感染者数を毎日毎分チェックし、新着感染者ニュースを検知したらいち早くLINEで速報を通知するサービス」を開発することにした。

一人ハッカソンの目的は新しい技術の学習でもある。今回は初めてFirebaseをつかってサービスを開発してみることに。

結果として、Firebaseの生産性はめざましく、コア部分はわずか3日で開発完了。ソースコードにして230行程度だ。当初想定の半分ぐらいの時間で完了したのだった。以下に開発・リリースまでの大まかな流れをまとめた。何かの参考になれば幸いだ。

コロナ感染者数通知ロボット「コロッチβ」

開発したサービスはその名も「コロッチβ」。サービス紹介サイト作った。

コロッチβ 〜東京都のコロナ感染者数通知サービス〜

無料で使えるので試しに利用登録してみて欲しい。(コロナ収束次第終了予定)

利用登録は下記QRをLINEで友達追加するだけ。とってもカンタン。

コロッチβがやってくれることは・・・

①15時頃に起動
②主要メディアを毎分チェックし東京都×コロナ関連記事を探す
③新着記事を発見したらLINEで知らせる
④2件通知したら本日分は終了
⑤たま〜にboketeのボケをランダムにLINEしてくる笑

という感じだ。

ちなみにサービスサイトはSTUDIOというWeb構築サービスで半日で構築。便利。

Firebaseの良いところ3つを解説

まだ未経験の人のために、Firebaseのナイスなポイントを3つ紹介しよう

①サーバ構築が不要
②よく使う機能が揃ってる
③負荷対策もバッチリ

①サーバ構築が不要

なんといってもこれにつきる。サーバレス開発。もともと「スマホアプリ側は開発するけどサーバ側はシンプルにデータ保存だけだから、いちいちサーバ構築&開発しなくて済ませたい」という怠惰系ニーズに応える思想で構築されているのがFirebase。サーバ構築にかかるストレスをとにかく削減できる。

例えばこれまで必須だった、サーバOS、Web、DB、バッチ、テスト、ログ、分析などの構築や設定が全部不要になるのだ。そしてもう1つのメリットとして、環境にまつわる設定をソースコードに記述することで、管理がシンプルでトラブルが避けられる。

これまでは、プログラムと環境構築がバラバラにやっていたので、「ソースの移行はカンタンだけど、環境構築どうやったか忘れちゃった」みたいなことがよくあった。

たとえば今回のコロッチβでのバッチとテスト環境の設定は下記のようにソースコードに書いて済ませた。DBの定義なんかもソース内で書いた。(今まではDBも事前構築が必要)

このソースをdeployコマンドで反映するだけでバッチ処理の設定が完了する(下記の例は、本番は13:00-19:00まで毎分実行、テストは1日中、毎分実行するという設定)

サーバレスなサービスの中でもFirebaseは特にシンプルで使いやすいと感じる。

②よく使う機能が揃ってる

アプリ開発者がよく思う、「こんな機能がもともとあれば楽だな〜」がガシガシ機能実装されていっているから、開発工程が大きく削減できる。

下記に提供されている機能をざっくりまとめてみた。

Cloud Firestore カンタン高速なDB
Cloud Functions APIやバッチ処理開発環境
Authentication ログイン・認証機構
Hosting WEBページ配信
Cloud Storage 画像や動画を保存・配信
Crashlytics 障害レポート・管理
Performance Monitoring パフォーマンス状況の表示
Test Lab 実機テストの代行
Google Analytics アクセス解析
Predictions ユーザ行動の予測・セグメント分析
Cloud Messaging プッシュ通知の配信
Remote Config 設定ファイルの管理・更新
Dynamic Links アプリ内部やDL用URLへのリンク
ML Kit 機械学習
A/B Testing A/Bテストの実行・分析

③負荷対策もバッチリ

サーバで意外と大変なのが負荷対策だ。事前のテストで本番当日の大量アクセスを再現するのが難しかったりするし、そもそも負荷の高いサービス運営経験が豊富な人は少ない。サーバを増やせば増やすほど構造が複雑になり、大企業のような様相を呈してくるのだ。

「シンプルな設計で作っておけば、自動でサーバを増設してさばいてくれる」

というFirebaseの設計思想は、まるでコンビニチェーンのように一気に店舗数を増やして大量の客(リクエスト)に対応できるのだ。

FirebaseのDBサービスである「Firestore」のドキュメントによれば、

完全な自動スケーリングに対応しています。現在のところ、約100万件の同時接続と、毎秒10,000回の書き込みまでのスケーリング制限があります

とのことだ。つまり大抵のサービスは負荷の心配がなくなる。これはアプリ開発者にとっては非常に嬉しいだろう。気になる料金も、下記の通り1週間程度で1万リクエストをさばいても14円程度だった。他のサーバ環境のサービスよりも、全然安く済んでしまう。

Puppeteerでスクレイピング&LINEで通知

Node.jsで提供されているスクレイピング用ライブラリのPuppeteerは、ライブラリ名がわかりにくいという点を除けば、簡単にスクレイピングができてとっても便利。また、LINEが提供しているNode.js向けのMessaging SDKも、予想よりも全然簡単に利用できた。

下記のソースを見てほしい。

「ボケてのピックアップにアクセスしてランダムで1ボケ拾ってLINE通知」

という処理が、たったこれだけのコードでかけてしまったのだ。

こちらはLINEの開発者向けリファレンス。日本人らしい丁寧なドキュメント

LINEとの連携設定。さすがUI/UXに定評のあるLINE。迷うことなく直感的に完了できた。

Firestoreにデータを記録

1日にLINE通知は2件も送れば十分。(あまり頻繁にくるとブロックされる)。LINEを送りすぎないようにするため&メディアへのアクセス回数も最低限にするためにも、新着記事の取得を記録しておき、本日のLINE通知が終了したらこれ以上、メディアへアクセスしないという制御をしておく。

Firestoreへのデータ保存のコードはこんな感じで簡単。NoSQLなのでDB構造もソースコード中で指定してひたすら保存していく。

保存されたデータは下記の感じ。covid_titlesというコレクションの中に1日1ドキュメントずつ追加していく。

ドキュメントの中身。メディアごとにドキュメント内コレクションを作成し、その中に記事取得の詳細を記録していく。

コロッチβの仕組みの図解

今回の仕組み図を解説すると下記のような感じだ。

ローカルPCにFirebase、Node.jsの環境を整備し、index.jsをプログラミングするだけで、環境設置が完了してしまうもだ。これまでLinuxサーバの設定やミドルウェア構築で大変だった時代と比較しても、カンタンになったものだ。。

ソースコードはコチラ!

indes.jsのソースコードを掲載。(LINEアカウント関連は伏せた)。ご参考に!


const functions = require('firebase-functions');
const puppeteer = require('puppeteer');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
const firestore = admin.firestore();
const line = require("@line/bot-sdk");

var isDEBUG = false;           //デバッグモード時はtrueとなり、各処理がデバッグモードで実行
var colName = "covid_titles";  //使用するfirestoreのコレクション名

//対象サイトドメイン, 記事一覧ページのパス, 記事タイトルのCSSクラス名, タイトルタグからAタグ位置のXPath記述, の配列
var mediaURLs = [
  ['www.fnn.jp'         ,'/category/news/',   '.m-article-item-info__ttl', './parent::div/parent::a'],
  ['www3.nhk.or.jp'     ,'/news/catnew.html', '.title',                    './parent::a'],
  ['www.nikkei.com'     ,'/news/category/',   '.m-miM09_titleL',           './parent::a'],
  ['www.asahi.com'      ,'/news/',            '.SW',                       './self::a'],
  ['news.tv-asahi.co.jp','/news_society/',    '.list-text',                './parent::a']
];

//LINEメッセージ配信用の設定
const config = {
  channelAccessToken: '**************',
  channelSecret:'**************'
};

//LINE関連 デバッグ用の別アカウント
const config_DEBUG = {
  channelAccessToken: '**************',
  channelSecret: '**************'
};

//firebaseのタイムアウトと使用メモリの設定
const runtimeOpts = {
  timeoutSeconds: 50,
  memory: '2GB'
}

/**
  コロナ記事チェック定期処理を本番モードで開始するよう登録する。13:00-19:00の間で毎分処理
 */
exports.covidCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes from 13:00 to 19:00').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = false;
   console.log("本番モードで定期処理を実行します");
   await covid19Check();
});

/**
  コロナ記事チェック定期処理をデバッグモードで開始するよう登録する
 */
exports.covidCheck_DEBUG = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = true;
   console.log("デバッグモードで定期処理を実行します");
   await covid19Check();
});

/**
  対象サイトに対してクローリングリクエストを送信していく
  */
async function covid19Check(){

  //クロール対象メディアすべてに対して調査
  for(let media of mediaURLs) {

    //YYYYMMDDをキーとし、ドキュメントを取得 > 更にサブコレでドメインをキーとして、本日すでに記事保存済みか判断
    const col = (!isDEBUG ? colName : colName+"_DEBUG");
    var docRef = firestore.collection(col).doc(YYYYMMDD()).collection("media").doc(media[0]);
    var snapShot = await docRef.get();

    //まだ今日firestoreに記事が登録されていないなら、記事を取得する
    if(snapShot.exists){
      console.log("既に記事が保存済みです:"+docRef.id);
    }
    else{
      await mediaRequest(media[0],media[1],media[2],media[3]); // スクレイピング
    }
  }
}

/**
  メディアへスクレイピングURLを送りコロナ記事を判定
  @param domain     記事掲載サイトのURL,IDとしても使う
  @param titlePath  記事一覧ページのパス
  @param titleClass 記事タイトルのCSSクラス名
  @param pathA      タイトルからAタグ位置のXPath記述
 */
async function mediaRequest(domain, titlePath, titleClass, pathA) {

  console.log("mediaRequest:%s,%s,%s,%s", domain, titlePath, titleClass, pathA);

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();
  await page.goto('https://'+domain+titlePath); //記事を取得するためメディアサイトへ接続

  console.log('接続しました:%s',await page.title());

  const titleLists = await page.$$(titleClass);  //記事タイトルのクラス名を指定して全タイトル取得

  //全記事タイトルについて、東京コロナ感染関連の記事があるか判定
  var maxTitle = titleLists.length;

  //(本番時は)上から10件の最新記事のみ記事取得をする。デバッグ時は全件取得。記事が10件無いケースを想定してmin
  if(!isDEBUG){ maxTitle = Math.min(titleLists.length,10); }

  for (let i = 0; i < maxTitle; i++) {  //昨日の記事を拾うことがあるので、上から10記事のみチェック

    const temp = await titleLists[i].$x("./text()"); //子要素も含んでいるので、本要素のtextのみ切り出し
    const titleProp = await temp[0].getProperty('textContent'); //記事タイトルタグのテキスト取得
    var titleText = await titleProp.jsonValue(); //テキストへ変換
    titleText = titleText.trim(); //前後の余白を除去

    //記事タイトルの特定文字(東京とか)から、対象記事かどうかを判定
    if(/東京/.test(titleText)&/感染/.test(titleText)&/人/.test(titleText)){

      console.log("title=%s",titleText);
      const parentA = await titleLists[i].$x(pathA);  //タイトルのタグからAタグへの相対距離をXPathで指定

      //Aタグが存在すれば(必ず存在するはずではある) タイトル保存&プッシュ送信へ
      if (parentA.length > 0) {
        const hrefProp = await parentA[0].getProperty('href'); //AタグよりURL取得
        const hrefText = await hrefProp.jsonValue();
        console.log("hrefText=%s",titleText,hrefText);
        await covidTitleAction(domain,titleText,hrefText);  //コロナ記事発見時の処理
        break;// 1メディア1記事配信した時点で終了する
      }
    }
  }
  return await browser.close();
}

/**
  コロナ記事発見時の処理
  @param domain   記事掲載サイトのURL,IDとしても使う
  @param title    記事のタイトル
  @param titleURL 記事のURL
 */
async function covidTitleAction(domain,title,titleURL){

  console.log("ドキュメントを登録します %s,%s:",title,titleURL);

  var titleCount = 1;  //本日の記事とロク数を保管

  //YYYYMMDDをドキュメントキーとする
  const col = (!isDEBUG ? colName : colName+"_DEBUG");
  var docRef = firestore.collection(col).doc(YYYYMMDD());

  //本日何件記事保存しているか(3件以上は送らない制御用)
  await docRef.get().then(doc => { titleCount = doc.data().count+1;})
                    .catch(err => { console.log('ドキュメント読み込みエラー', err); });

  docRef.set({
              updated : new Date(),
              count   : titleCount  //今日の記事登録件数を保存
            });

  docRef.collection("media").doc(domain).set({  //1記事ごとにサブコレとして登録
        title    : title,
        url      : titleURL,
        created  : new Date()
       }
  );

  //本日配信がまだ2件未満なら、記事をLineメッセージで全員へ配信
  console.log("Lineメッセージで配信します %s,%s:",title,titleURL);

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //(本番時は)本日最初の2記事のみプッシュ通知を送る。デバッグ時は全件送る
  if(titleCount <= 2 || isDEBUG){
      await client.broadcast({
        type:"text",
        text:title+"\n"+titleURL
      });
  }
}

/**
  今日の日付をYYYYMMDDで返す
  */
function YYYYMMDD(){
  var dt = new Date();
  return dt.getFullYear()
             +(('00' + (dt.getMonth()+1)).slice(-2))
             +(('00' +  dt.getDate())    .slice(-2));
}

/**
  ボケてチェック処理を開始するよう登録する
  */
exports.boketeCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 hours from 9:00 to 23:00').timeZone('Asia/Tokyo').onRun(async(context) => {

  console.log("ボケて配信処理を実行します");
  isDEBUG = false;

  const RATE = 30; //何分の1で発動するかの設定。 2日に1件程度の確率に設定
  var seed = Math.floor( Math.random() * RATE );

  if(seed == 0){ await boketeCheck(); }
});

/**
   ボケてのピックアップをチェックする
  */
async function boketeCheck(){

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();

  await page.goto('https://bokete.jp/boke/pickup'); //記事を取得するためボケてへ接続

  console.log('接続しました:%s',await page.title());

  const bokeLists = await page.$$(".boke-text");  //ピックアップの全ボケ取得
  var idx = Math.floor( Math.random() * bokeLists.length ); //全ボケからランダムで1つ選ぶ

  const odaiHref = await bokeLists[idx].getProperty('href'); //記事タイトルタグのテキスト取得
  const hrefText = await odaiHref.jsonValue();

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //ボケをLineメッセージで全員へ配信
  console.log("Lineメッセージでボケを配信します:%s",hrefText);

  //ボケのLine配信
  await client.broadcast({
           type:"text",
           text:hrefText
  });

  return await browser.close();
}

菅澤 英司
bravesoft CEO&CTO@つよつよエンジニア社長です