【① Selenium編】当日の天気を知らせてくれるSlack Botを作ってみた(Python + Selenium + Slack + AWS ECS + Github Actions)


毎日、当日の天気を能動的に調べるのは面倒なので、天気を知らせてくれるSlack Botを作ってみました。
処理の流れは以下のようになっています。

天気予報のWebページを取得 --> 当日の天気予報のスクリーンショットを撮る --> Slackへアップロード

天気予報のAPIから情報を取得・整形してSlackへ通知するようなBotでもよかったのですが、いい感じのAPIがなかったのと、リッチな通知にしたかったのでこのようになりました。
実装からAWS ECSへのデプロイ・スケジューリングまでを技術ごとに分けて、どのように作成したかを説明していこうと思います。今回は【Selenium編】です。

構成

  • ① Selenium編 <-- いまここ
  • ② Slack編
  • ③ Github Actions編
  • ④ AWS ECS編

Seleniumとは?

Seleniumとは様々な言語でWebブラウザの操作を簡単に自動化することができるツール・ライブラリです。スクリーンショットを撮ったり、フォームを送信したり、様々なことを自動化することができます。ただ、それなりに複雑な処理を自動化しようと思うとJavaScriptの知識が必要になります。
現在公式でサポートされている言語はC#Java・Python・JavaScript・Rubyらしいです。今回の開発ではPythonを使用します。

実装

環境

  • Python 3.7
  • Pipenv
  • selenium 3.141.0
  • chrome
  • chrome driver
  • Docker
  • docker-compose

ライブラリのインストール

仮想環境をカレントディレクトリで管理するためにPIPENV_VENV_IN_PROJECTの環境変数を設定しておくのをお勧めします。

export PIPENV_VENV_IN_PROJECT=true
pipenv install selenium

ソースコード

Chromeのオプション設定

Chromeをそのまま起動するのはハードウェアリソースを喰いますし、Pythonから操作する訳なのでヘッドレスモードで起動させます。MacOSやLinux上で起動させるのであれば問題ないのですが、Docker上で起動させる際はChromeの機能の一部を無効にしないと正常起動しませんので注意が必要です。設定するオプションはMacOSであればopen -n -a "Google Chrome"、Linuxであればgoogle-chromeコマンドのオプションで使用出来るものと同じです。
ここでは6個のオプションを設定します。

screenshot.py
from selenium.webdriver.chrome.options import Options

def set_option():
    options = Options()
    options.add_argument('--headless')
    options.add_argument('--incognito')
    options.add_argument('--hide-scrollbars')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-gpu')
    options.add_argument('--no-sandbox')

    return options

Chromeの起動

SeleniumのdriverのChromeクラスに先ほどのオプションを渡してChromeを起動させます。
Chromeの起動にはchrome driverが必要になるので、あらかじめダウンロードし、driverのバイナリファイルをPATHに含まれているディレクトリ下に移動させておく必要があります。PATH以外にある場合は、Chromeクラスにインスタンスを作成する際にexecutable_pathを指定しましょう。

screenshot.py
from selenium.webdriver import Chrome

def main(url, target):
    options = set_option()
    browser = Chrome(options=options)

スクリーンショット撮影

千代田区の3時間天気 - 日本気象協会 tenki.jpid=forecast-point-3h-todayまたはxpath=//*[@id="forecast-point-3h-today"]の要素のスクリーンショットを撮ります。Webページにアクセスすればわかると思いますが、なかなかリッチなUIをしています。
スクリーンショットを撮りたい要素をidとxpathによって指定する2通りの方法を紹介します。お好みでどうぞ。

idによる要素の指定

JavaScriptを使用して要素までスクロールしたあとでスクリーンショットを撮っています。windown.scrollToの引数の中で-50しているのは、ヘッダーが邪魔にならないようにするためです。
browser.find_element_by_id(target).screenshot_as_pngで画像のバイナリデーを取得できるので、save_imageでバイナリデータをファイルに書き込んでいます。

screenshot.py
import os
import sys

def main(url, target):
    ...
    browser.get(url)
    take_by_id(browser, target)

def save_image(data):
    with open('tmp.png', 'wb') as f:
        f.write(data)

def take_by_id(browser, target):
    script = f'''
        const ele = document.getElementById({repr(target)});
        window.scrollTo(0, ele.getBoundingClientRect().top - 50);
    '''

    browser.execute_script(script)
    img = browser.find_element_by_id(target).screenshot_as_png
    save_image(img)

xpathによる要素の指定

基本的な流れはidと同じですが、要素までスクロールするためのJacaScriptを工夫する必要があります。idのようにgetElementByIdがないので、document.evaluateを使います。詳しくはDocument.evaluateを参照してください。

screenshot.py
import os
import sys

def main(url, target):
    ...
    browser.get(url)
    take_by_xpath(browser, target)

def take_by_xpath(browser, target):
    script = f'''
        const ele = document.evaluate(
            {repr(target)},
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null
        ).singleNodeValue;
        window.scrollTo(0, ele.getBoundingClientRect().top - 50);
    '''

    browser.execute_script(script)
    img = browser.find_element_by_xpath(target).screenshot_as_png
    save_image(img)

完成形

screenshot.py
import os
import sys
from selenium.webdriver import Chrome
from selenium.webdriver.chrome.options import Options


def main(url, target):
    options = set_option()
    browser = Chrome(options=options)
    browser.get(url)
    take_by_id(browser, target)

def set_option():
    """Configure Chrome options"""

    options = Options()
    options.add_argument('--headless')
    options.add_argument('--incognito')
    options.add_argument('--hide-scrollbars')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-gpu')
    options.add_argument('--no-sandbox')

    return options

def save_image(data):
    with open('tmp.png', 'wb') as f:
        f.write(data)

def take_by_id(browser, target):
    script = f'''
        const ele = document.getElementById({repr(target)});
        window.scrollTo(0, ele.getBoundingClientRect().top - 50);
    '''

    browser.execute_script(script)
    img = browser.find_element_by_id(target).screenshot_as_png
    save_image(img)


if __name__ == '__main__':
    try:
        url = os.environ['URL']
        target = os.environ['TARGET']
    except KeyError:
        sys.exit('URLとTARGET環境変数を設定してください')

    main(url, target)

Dockerfile

スクショした画像が文字化けしないようにするためにIPAfontをダウンロードします。Noto Serif CJK JPを使用して日本語対応している記事がありましたが、今回はうまくいかなかったのでIPAfontを使用しています。

FROM python:3.7-alpine
WORKDIR /tmp
RUN apk add --no-cache \
      fontconfig \
      chromium \
      chromium-chromedriver && \
    wget --no-cache -nv https://oscdl.ipa.go.jp/IPAfont/ipag00303.zip && \
    mkdir -p /usr/share/fonts/ipa && \
    unzip ipag00303.zip -x *.txt -d /usr/share/fonts/ipa && \
    fc-cache -f && \
    apk del --no-cache --purge fontconfig && \
    rm -rf /tmp/* && \
    addgroup executor && \
    adduser -D -G executor executor && \
    mkdir -p /home/executor/app && \
    pip3 install --no-cache-dir pipenv
WORKDIR /home/executor
COPY --chown=executor:executor Pipfile* /home/executor/
RUN pipenv install --system --deploy --clear
COPY --chown=executor:executor src /home/executor/app
USER executor
CMD ["python3", "app/screenshot.py"]

docker-compose

version: '3'
services:
  app:
    build: .
    image: app
    container_name: app_dev
    env_file: .env

.env

URL=https://tenki.jp/forecast/3/16/4410/13101/3hours.html
TARGET=forecast-point-3h-today

終わりに

次は【slack編】を書きます。今回撮ったスクショをSlackへアップロードする方法を説明します。