学校の休講補講情報をSlackに送ってみた話

こんばんは.nanaminです.

今回はプログラミングネタです.

読み返したら英単語とかカタカナの登場率がすごくて引いてる.でも固有名詞とか用語とかだからしょうがないです.

事の経緯

休講や補講の情報は学校のHPに掲載されるのですが,いちいち見に行くのも面倒.さらに教授が口頭でお知らせする場合もあるので,休講や補講の把握はかなり億劫でした.

そこで,休講補講情報をSlackに通知するWebアプリケーションを作ろうとなりました.あと最近Railsをやり始めてなにか作ってみたかったというのもあります.

結果

べんり〜

f:id:Nanami0634:20190529005627p:plain
休講補講情報がSlackにpostされた様子
f:id:Nanami0634:20190529101152p:plain
補講情報が前日にリマインドされる様子

材料

開発に使ったものたち

  • Web Application FrameworkとしてRuby on Rails (5.2)
  • Web ScrapingするためにNokogiri
  • 全角半角変換用にNKF
  • RubyでSlack APIを扱えるSlack Ruby Client
  • 公開用サーバーとしてHeroku
  • Heroku上で定期実行するためのHeroku Scheduler

実装方法

まずはリポジトリへのリンクを貼っておきます(結論優先オタク)

github.com

概要

とりあえず,処理の流れから

  1. 休講補講情報が載っているページをNokogiriでScrapingする
  2. データベースへ保存して,前回Scrapingしたときとの差異をチェック
  3. 差異部分をSlackのチャンネルにpost
  4. また休講or補講日の前日にもリマインド用としてSlackにpost

定期実行するため, 1〜3と4をrakeタスクとしてまとめておきます

Scraping部分

ここが一番肝だったりする.なぜなら半角と全角が混在しているから...

僕の学校の休講補講情報は,1つのtable要素で1つの休講or補講情報を表示していました.そのため,ページにある全table要素に対して,全てのtd要素の内容を取得しています.Nokogiriくんべんり.

doc = Nokogiri::HTML(open(scrape_url))
doc.css('table').each { |node|
  table = Nokogiri::HTML(node.to_xhtml)
  table_values = table.css('td').map { |node| node.content }
}

また,休講補講情報のフォーマットがガバガバ 多様性にあふれ全角数字と半角数字が混在していたので,NKFを使って半角に変換します.

# 全角英数字を半角英数字,全角スペースを半角スペース,半角カナを全角カナに変換
# さらに先頭と末尾の空白を除去
converted = NKF.nkf('-wZ1X', scraped_str).strip

あとはいつも通りデータベースとやり取りするだけです.

Slack用delivery_methodの実装

Slackへメッセージを送るには,Slack APIchat_postMessageメソッドを使います. Scrapingする処理層でメッセージを送る処理を書いてもいいですが,それだと芸がないのでActionMailerを通して行うようにしてみます.

実装にあたって,以下のサイトがとても参考になりました.ありがとうございます. tech.unifa-e.com

delivery_methodは,ActionMailerのなかでmailメソッドを使ったときに呼ばれるものです. ここでメッセージの内容を受け取ってSlackに送信する処理を記述します.

class SlackMessageDeliveryMethod
  def initialize(value)
    self.settings = value
  end

  def deliver!(message)
    attachments = JSON.parse(message.body.to_s)
    channel = message['channel']&.value || self.settings[:default_channel]
    
    client = Slack::Web::Client.new(token: self.settings[:api_token])
    client.chat_postMessage(
      channel: channel,
      attachments: [attachments],
      as_user: true
    )
  end
end

Slack用delivery_methodを登録

実装したdelivery_methodをActionMailer::Baseに登録します. ちなみに登録するとxxx_settingsというメソッドが自動で定義されるので,そこで初期化時のパラメータ(トークンなど)を渡しておきます. トークンを公開すると誰でもSlackにメッセージを送れるようになってしまうので,環境変数を使って隠蔽しましょう.

ActionMailer::Base.add_delivery_method(:slack_message, SlackMessageDeliveryMethod)

ActionMailer::Base.slack_message_settings = {
  api_token: ENV['SLACK_API_TOKEN'],
  default_channel: ENV['SLACK_DEFAULT_CHANNEL'],
}

送りたいメッセージを作成する

あとは,送りたいメッセージの内容を作成してmailメソッドを呼び出すだけです. 僕はruby側でjson形式のデータを作成し,それをviewに埋め込むだけにしています.

class LectureMailer < ApplicationMailer
  default delivery_method: :slack_message

  def new_info(lecture)
    @attachments_json = {
      fallback: '新しい休講補講情報です',
      color: 'good',
      pretext: '新しい休講補講情報です',
      fields: to_attachment_fields(lecture),
    }.to_json

    mail
  end

  def to_attachment_fields(lecture)
    # 講義情報をSlack APIの引数のフォーマットに変換する
  end
end

rakeタスクを作成

Heroku Schedulerで定期実行してもらうために,

  • Scrapingして変更があった情報をSlackに通知
  • 休講or補講日の前日にSlackにリマインド

の2つの処理を行うrakeタスクを作成します.

実装した2つのrakeタスクはこんなかんじ.

namespace :lecture do
  task scrape_and_send: :environment do
    before_time = Time.zone.now
    url = ENV['SCRAPE_URL']
    class_name = ENV['TARGET_CLASS_NAME']

    service = ScrapeLecture::WithUrlService.new(url)
    service.execute!

    created = Lecture.where(
      class_name: class_name, created_at: before_time..Time.zone.now
    )
    updated = Lecture.where(
      class_name: class_name, updated_at: before_time..Time.zone.now
    )
    updated = updated.reject { |i|
      i.created_at == i.updated_at
    }

    # slackのメッセージレイアウトの都合上,1件ずつ送信
    created.each { |info| LectureMailer.new_info(info).deliver_now }
    updated.each { |info| LectureMailer.update_info(info).deliver_now }
  end

  task send_reminders: :environment do
    now = Time.zone.now
    class_name = ENV['TARGET_CLASS_NAME']

    canceled = Lecture.where(
      class_name: class_name, canceled_from: now..now.since(1.days)
    )
    supplemented = Lecture.where(
      class_name: class_name, supplemented_from: now..now.since(1.days)
    )

    canceled.each { |info| 
      LectureMailer.canceled_reminder(info).deliver_now
    }
    supplemented.each { |info|
      LectureMailer.supplemented_reminder(info).deliver_now
    }
  end
end

Heroku Schedulerで定期実行

HerokuではアドオンとしてHeroku Schedulerというものが用意されています. これを使うことで,指定した時間ごとに設定したコマンドを実行してくれます.

インストールやrakeタスクの登録方法はこちらで紹介されています. インストールにはクレジットカード登録済みのアカウントが必要ですが,基本的に無料で使えます.とてもべんり devcenter.heroku.com

さっきのrakeタスクを登録するとこのような画面になりました.

f:id:Nanami0634:20190530145059p:plain
Heroku Schedulerでrakeタスクを登録したところ

感想

Heroku Schedulerたすかる

Webアプリケーションって手軽にそれっぽいものつくれて公開できるから良い.