言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

保育園の新園開設情報ページの更新を監視したい

産休に入って時間があるので、今まで手動でチェックしていた保育園の新園開設情報ページの更新をスクレイピングして監視することにした。1

0. 注意事項

スクレイピングに関しては実行前に一度 こちらを読むことをお勧めします。
今回自分のケースでいうと、事前に以下を確認している。

  • 個人利用であること
  • 週に一度アクセスするだけなので、アクセス対象に負荷をかけないこと
  • アクセス対象のサイトのポリシーを確認し、問題ないこと

また、普段Androidを書いているので微妙なPythonのコードとかあるかもしれないし、AWSの各種サービスの構成も「もっとこうすれば?」みたいなのあるかもしれない。その場合はコメントで教えてください。

1. 概要

f:id:muumuumuumuu:20200311165429p:plain

  1. AWS CloudWatch EventでAWS Lambdaを実行するscheduleのルールを作成
  2. キックされるLambda関数で自治体の新園開設情報ページを見に行き更新日時を取得
  3. AWS S3に前回の更新日時を置いておいて、差分があるかチェック
  4. (差分がある場合) S3のファイルを更新し、Slackに更新があった旨を通知
    (差分がない場合) Slackに更新がなかった旨を通知2

作業の順番としては、2->3->4->1 の順で説明していく。

2. 手順詳細

自治体の新園開設情報ページを見に行き更新日時を取得

今回はPython(3.7.6)を使ってscriptを書いていく。以下使用するlibraryとその理由。

  • boto3 (1.12.16)
    • s3に対するAPI requestをするための公式library
  • requests (2.23.0)
    • API requestを行うためのlibrary. 後述のBeautifulSoup4にrequest情報を渡すだけであればstandard libraryのurllib等でもよかったが、今回はSlack APIを叩く必要もあったためこちらを採用
  • BeautifulSoup (4.8.2)
    • HTMLをparseしてくれる便利な子。CSS selector使えて最高っぽい。

CSS selectorについてはこちらの記事を参考にGoogle Chromeを使って簡単に任意の要素のcssを取得することができる。最高! 自分のケースだと保育園の新園開設情報ページに 更新日:20xx年y月z日 みたいな記載があるのでこの部分のCSS selectorを使用した。

コードはこんな感じ。URL等は適宜置き換えて欲しい。ローカルで試しに動かしたい人は各libraryを実行前にそれぞれpipでinstallしておいてください。

import requests
from bs4 import BeautifulSoup

new_open_page_url = 'page_url_to_check'
new_open_soup = BeautifulSoup(requests.get(new_open_page_url).content, 'html.parser')
latest_update = new_open_soup.select_one("paste_css_selector_here").text

前回の更新日時をおいておくファイルをS3に用意する

AWS S3のバケットに新しいファイルを用意する。(手順は特に説明しないので各自ググって欲しい)
このファイルに前回の更新日時を保存しておき、最新の更新日時と比較することで更新があったかどうかを判断する。

ローカルでこのPython scriptを動かしたい場合はファイルへ読み書き権限を設定したuserの AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY環境変数に設定しておく。

コードはこんな感じ。これでS3に置いたファイルをローカルにdownloadできる

import boto3
s3_bucket_name = 'your_bucket_name'
s3_object_key_name = 'your_object_name'
download_file_path = f'/tmp/{s3_object_key_name}'

s3 = boto3.resource('s3')
s3.Object(s3_bucket_name, s3_object_key_name).download_file(download_file_path)

前回の更新日時と最新の更新日時を比較する

差分があった場合となかった場合にそれぞれslackに通知するmessageを設定する。また、差分があった場合のみ、ローカルに置いたファイル内容を変更する。

with open(download_file_path, 'r+') as last_update_file:
    last_update = last_update_file.read()

    if last_update == latest_update:
        message = 'No new info'
    else:
        message = f'<!channel> News!!!!!!! {new_open_page_url}'

        last_update_file.seek(0)
        last_update_file.truncate()
        last_update_file.write(latest_update)

S3のファイルを更新する

s3.Object(s3_bucket_name, s3_object_key_name).upload_file(download_file_path)

Slackに通知を飛ばす

まずはSlack appを作成する。こちらのページの左上の Create new app のボタンを押下し、適当な名前と通知を飛ばしたいworkspaceを入力する。
appが作られたら次は OAuth & Permissions のメニューから権限のscopeの設定を行う。今回はmessageを投げたいだけなので、 chat:write のscopeのみ設定する。
設定が終わったら同じページの上部にある Install App to Workspaceのボタンをポチッとしてappをinstallする。確認画面に飛ぶので、workspaceとpermissionを確認して Allow をポチる。これでOAuth Access Tokenが発行されるので、このappのWeb APIが叩けるようになる。

Tokenが発行されたのでコードに戻る。SlackにMessageを投げるAPIの仕様はこちら。このAPIapplication/json のparameterを受け付けるとある。が、注意しないといけないのは

  • ここに並んでいるparameterのうちtokenだけはjson parameterではなくheaderに詰めろと書いてある
  • 明示的にheaderにcontent typeでjsonを指定しろと言っている

コードはこんな感じになる。環境変数SLACK_TOKEN としてslackのtokenを設定しておく。

import json
slack_post_url = 'https://slack.com/api/chat.postMessage'
token = os.environ['SLACK_TOKEN']
headers = {
    'content-type': 'application/json',
    'authorization': f'Bearer {token}'
}
payload = {
    'channel': 'your_channel',
    'text': message,
}
requests.post(slack_post_url, data=json.dumps(payload), headers=headers)

このコードを実行する前に、対象channelにあらかじめ作成したappをinviteしておく必要がある。これをやっておかないとapiを叩いてもerrorになる。ちなみにerrorになると言ってもstatus codeは200で返ってくるので混乱する。(messageにerrorが記述されている。)

Lambdaにscriptを登録する

今回はlibraryを使っているので、Python scriptだけでなくlibraryを含めた環境をzipに固めてLambdaにuploadする。

zipに固めてuploadする場合はhandler関数を定義しておく必要があるので、今までのコードをまるっと一つの関数にくくってしまって(ちゃんとしている人は適宜適切なサイズの責務・関数に切り出してください :pray: )、handler関数からこの関数を呼び出すようにする。

# handler関数
def lambda_handler(event, context):
    get_new_open_info()

def get_new_open_info():
    new_open_page_url =  # 以下省略

zipで固める用のdirectoryを用意して、そこに各種Libraryをinstallしておく。

$ mkdir workspace
$ cd workspace
$ pip install beautifulsoup4 -t . // 残りのlibraryも同様にinstall

scriptをこのdirectoryにcopyするのもお忘れなく。

環境が整ったらzipに固める。

$ zip -r upload.zip *

AWS consoleで新しいlambda関数を登録する。適切な関数名・ロール等を設定し、関数を作成。
関数の設定画面でzipファイルをuploadし、handlerに scriptファイル名.handler関数名 を指定する。
関数の設定の下部の環境変数設定メニューでSlackのtokenを環境変数に設定する。(S3アクセス用の環境変数等はロールの方に設定したので今回は特に指定しない。)
また、デフォルトだとtime outの設定が3秒なのでちょっと長めに15秒くらいにした。

各種設定を変更したら設定を保存してテスト実行して問題ないことを確認する。

Cloud Watch Eventで定期実行するようにする

前項で作成したLambda関数設定ページのDesignerメニューからトリガーを追加する。トリガーには Could Watch Events/Event Bridge を選択。
スケジュール式で週に一回実行するように指定する。注意するのは日時設定がGMTになること。日本時間で指定したい場合はJST(+0900)を考慮する必要がある。(我が家の場合は毎週日曜の朝に夫と定例会議をやるので、それに間に合うように日曜朝に実行されるように設定した。3

cron(0 0 ? * SUN *)

cron式の構文についてはこちらの記事を参考にした。

以上。

3. おまけ

上記コードを書いた後に requests_html というLibraryを教えてもらった。こちらはrequestsとbs4のwrapperのようなものっぽい。これからコード書くのであればbs4の代わりにこれ使えばいいと思う。

github.com


  1. 区役所に保育園の相談に行った時に、窓口の方から「新園情報は定期的にチェックしてください!」とアドバイスをいただいたため

  2. 差分がない場合は特に通知しなくてもいいんだけど、ちゃんとscriptが期待通りに動いているか確認するためにこの場合でも通知することにした

  3. お役所のWebページなんだから土日は更新ないんじゃないの?と思ったりしたけど、RSSフィードを購読していると普通に土日でも更新が通知されるのでびっくりする。いつお休みしているのだろう…