【① 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](https://github.com/SeleniumHQ/selenium/tree/master/py)とは様々な言語でWebブラウザの操作を簡単に自動化することができるツール・ライブラリです。スクリーンショットを撮ったり、フォームを送信したり、様々なことを自動化することができます。ただ、それなりに複雑な処理を自動化しようと思うとJavaScriptの知識が必要になります。
現在公式でサポートされている言語はC#Java・Python・JavaScript・Rubyらしいです。今回の開発ではPythonを使用します。

# 実装
## 環境
- Python 3.7
- Pipenv
- selenium 3.141.0
- chrome
- [chrome driver](https://sites.google.com/a/chromium.org/chromedriver/downloads)
- 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](https://sites.google.com/a/chromium.org/chromedriver/downloads)が必要になるので、あらかじめダウンロードし、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.jp](https://tenki.jp/forecast/3/16/4410/13101/3hours.html)の`id=forecast-point-3h-today`または`xpath=//*[@id="forecast-point-3h-today"]`の要素のスクリーンショットを撮ります。Webページにアクセスすればわかると思いますが、なかなかリッチなUIをしています。
スクリーンショットを撮りたい要素をidとxpathによって指定する２通りの方法を紹介します。お好みでどうぞ。

#### 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](https://developer.mozilla.org/ja/docs/Web/API/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へアップロードする方法を説明します。