eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

GCPを使って子供関係の通知をLINEに統合する

なにをやったのか

  • 子供が保育園・小学校・学習塾に通っている
  • それぞれ、お知らせがWebに掲載される(日記とかも含む)
  • 個別連絡はメールで届く(学年単位の緊急連絡とか)
  • いちいち学校のウェブサイトで確認したりメールを検索するのが面倒なので、LINEに通知を統合した

LINEの通知はIFTTTを使う

  • もうデファクトみたいな感じのIFTTT
  • IFTTTのWeb Hook -> LINE通知を組み合わせることで簡単に行えるので省略

Web Hookへの流し込み戦略

スクレイピング

  • 保育園や小学校、当然ながらRSSのような便利ソリューションは存在しない
  • なので、Firebase Functionsでポーリングすることにした
  • Firebaseの認証処理がスキップできる(デフォルトでAdmin権限が割り当て)られるので、各種処理が楽になる
  • 基本的に httpトリガー を使って、任意のタイミングで呼び出せるようにする
    • 関数の言語はなんでもいいけど、自分はGolang 1.13を使用した。
    • スクレイピング処理には github.com/PuerkitoBio/goquery を使用。
    • Webサイトの構造は当然サイトごとに異なるので頑張って個別対応。
  • Firebase Functions自体にもスケジューリング機能はついているけど、 Cloud Scheduler でKickするほうが再設定やデバッグが楽なので、スケジューリングは設定していない
  • 学校のCMSに負荷をかけるのは本意ではない(し、そこまでWebページの更新頻度は高くない)ので、cronは1時間に1回行ってる.

キャッシュ処理

  • IFTTTのWebHookにはキャッシュがないので、単純にCron実行すると同じ通知が何度も流れてしまう
  • そのため、 Firestore に通知済みのキャッシュを放り込んで、キャッシュが存在したらHookをスキップするようにする

通知とキャッシュのサンプルコード

  • 通知のタイトル・本文・リンクURLの組み合わせをMD5化して、それをキャッシュキーにする
  • キャッシュヒットしたら、それは通知済みなのでスキップ
var firebaseApp *firebase.App
var firestoreClient *firestore.Client
var webhook iftttWebhook.IFTTT

func init() {
    if app, err := firebase.NewApp(context.Background(), nil); err != nil {
        panic(err)
    } else if fs, err := app.Firestore(context.Background()); err != nil {
        panic(err)
    } else {
        firebaseApp = app
        firestoreClient = fs
    }

    webhook = iftttWebhook.New("割り当てられたAPIキー")
}

func md5sum(s string) string {
    sum := md5.Sum([]byte(s))
    return hex.EncodeToString(sum[:])
}

func notifyWebPageLink(ctx context.Context, title string, body string, link string) {
    uid := md5sum(title + "/" + body + "/" + link)

    ref := firestoreClient.Collection("ifttt").Doc("web-notify").Collection("cache").Doc(uid)
    if _, err := ref.Get(ctx); status.Code(err) != codes.NotFound {
        log.Println(fmt.Sprintf("    * Exist Document: %v", ref.Path))
        return
    }
    log.Println(fmt.Sprintf("    * Put Document: %v", ref.Path))
    log.Println(fmt.Sprintf("    * Title: %v", title))
    log.Println(fmt.Sprintf("    * Body: %v", body))
    if _, err := ref.Create(ctx, map[string]interface{}{
        "created_at": time.Now(),
        "title":      title,
        "body":       body,
        "link":       link,
    }); err != nil {
        log.Println(fmt.Sprintf("Firestore write failed: %v", err))
    }

    webhook.Emit("通知ID", title, body, link)
}
// これをhttpトリガーでPublish
func CronHoikuenWeb(w http.ResponseWriter, r *http.Request) {
        うまいことスクレイピング()...
    w.WriteHeader(http.StatusNoContent)
}

// これをhttpトリガーでPublish
func CronShogakkouWeb(w http.ResponseWriter, r *http.Request) {
        うまいことスクレイピング()...
    w.WriteHeader(http.StatusNoContent)
}

GmailからIFTTTへの通知戦略

  • 塾も入退室時にメールで通知してくれる
  • 保育園や小学校は緊急連絡(遠足延期とか)がメールで来る
  • なので、メールもLINEにGOだ。

GASをcronで呼び出す

  • Firebase Functionsだと、自分のGmailへのアクセスが面倒(認証とか)
  • なので、Google Apps Scriptから通知を飛ばすことにした
  • GASはGmail APIを使うとOAuthが自動的に開始されるので、認証がらく。
    • 認証状態を保持したまま、Cronを走らせられる
    • 設定項目もユーザーフレンドリー(cron構文使わなくてよい)
  • メールはGoogleインフラなので、1分に1回ポーリングしても大丈夫そう
    • リアルタイム性を重視したいならpub/subに流して〜とかあるけど、面倒な割にそこまでする必要はない
  • gmail検索条件をそのまま利用できるので、 from:example@example.com newer_than:1h とかできるし、めちゃくちゃ複雑なクエリも作れる
    • けど俺はシンプルにfromと直近のn時間のみでクエリを投げた
    • LINE通知は嫁さんにも届くので、間違ってもFANZAの課金通知を飛ばすわけにはいかねぇんだよ!!
  • UrlFetchApp.fetch の投げ先は、Functionsで作ったhttpトリガー.
    • POSTリクエストでパラメータを受け取り、それをそのままさっきの notifyWebPageLink() に流すようにした
// Functions側はこう
func WebHookFromGmail(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    bodyBytes, err := ioutil.ReadAll(r.Body)
    if err != nil {
        panic(err)
    } else {
        log.Printf("read body: %v", string(bodyBytes))
    }

    var request map[string]string
    if err = json.Unmarshal(bodyBytes, &request); err != nil {
        panic(err)
    } else {
        log.Printf("parsed: %v", request)
    }

    title := request["title"]
    body := request["body"]
    link := request["link"]

    notifyWebPageLink(ctx, title, body, link)

    // ok
    w.WriteHeader(http.StatusNoContent)
}
// GAS側はこう。
function onCron() {
  notifyGmail("gmailの検索条件", "通知タイトル", "リンクURL")
}

function notifyGmail(query, from, link) {
  GmailApp.search(query).forEach(function(thread){
    thread.getMessages().forEach(function(message) {
      console.log("============================");
      console.log(message.getSubject());
      console.log(message.getBody());
      notifyExternalWebHook(from, message.getSubject() + "  ::  " + message.getBody(), link);
    });
  });
}


function notifyExternalWebHook(title, body, link) {
  var requestBody = JSON.stringify({
    title : title,
    body: body,
    link: link,
  });
  console.log(requestBody);
  var options =
  {
    method  : "POST",
    payload : requestBody,
  };
  UrlFetchApp.fetch("自分のエンドポイントURL", options);
}

最後に

  • セキュリティもくそもない書き捨てコードなので、何かあったら自己責任で頑張ろうね!
  • あと、IFTTTのWebHookにキャッシュつけてくんねかな!!