Selenium + Headless Chrome + Jupyter Notebook + Docker

GWに調べたことのメモです。
Jupyter Notebook上でSeleniumを動かすことのできるDockerイメージを作成します。
Seleniumがどんなものか手っ取り早く試してみるための環境です。

# 参考記事
本記事の作成には、以下の記事を参考に作成させていただきました。

- [【2019年版】Ubuntu18.04 にChromeとSeleniumをインストール](https://www.mahirokazuko.com/entry/2019/08/16/202008)
- [Rails + postgres + chromedriverのdocker-compose環境を作る](https://qiita.com/mh4gf/items/e6e4551bcae0fb745ee8)

# Dockerイメージ作成
まずは、Dockerfileの全体です。

```dockerfile:Dockerfile
FROM jupyter/scipy-notebook:latest

USER root
WORKDIR /tmp

# 必要なツールとIPAフォントをインストールします
RUN apt-get update && apt-get install -y gnupg curl fonts-ipafont fonts-ipaexfont
# Google Chromeをインストールします
RUN wget https://dl.google.com/linux/linux_signing_key.pub \
 && apt-key add linux_signing_key.pub \
 && echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list \
 && apt-get update \
 && apt-get install -y google-chrome-stable \
 && google-chrome --version

# Chrome Driverをインストールします
RUN CHROMEDRIVER_VERSION=`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE` \
 && curl -sS -o /tmp/chromedriver_linux64.zip http://chromedriver.storage.googleapis.com/$CHROMEDRIVER_VERSION/chromedriver_linux64.zip \
 && unzip /tmp/chromedriver_linux64.zip \
 && mv chromedriver /usr/local/bin/

# PermissionError: [Errno 13] Permission denied: '/home/jovyan/.local/share/jupyter'
# となぜか怒られるのでとりあえず所有者を変更
RUN chown -R jovyan /home/jovyan/.local

USER jovyan
# selenium導入
RUN pip install selenium
WORKDIR /home/jovyan
```

上記を使って、`jupyter-selenium`というイメージを作成します。

```console
$ docker build -t jupyter-selenium .
```

## Dockerfileポイント説明
上のDockerfileのポイントをいくつか説明していきます。
Dockerのベースイメージには、[jupyter/scipy-notebook](https://hub.docker.com/r/jupyter/scipy-notebook)を使っています。

### Google Chromeインストール
[【2019年版】Ubuntu18.04 にChromeとSeleniumをインストール](https://www.mahirokazuko.com/entry/2019/08/16/202008)の記事を基にGoogle Chromeをインストールします。

```docker
RUN wget https://dl.google.com/linux/linux_signing_key.pub \
 && apt-key add linux_signing_key.pub \
 && echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list \
 && apt-get update \
 && apt-get install -y google-chrome-stable \
 && google-chrome --version
```

### Chrome Driver導入
https://chromedriver.storage.googleapis.com/LATEST_RELEASE というところで、最新のChrome Driverのバージョンが取れるようなので、それを利用します。
解凍したChrome Driverをパスの通った場所に配置します。

```docker
RUN CHROMEDRIVER_VERSION=`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE` \
 && curl -sS -o /tmp/chromedriver_linux64.zip http://chromedriver.storage.googleapis.com/$CHROMEDRIVER_VERSION/chromedriver_linux64.zip \
 && unzip /tmp/chromedriver_linux64.zip \
 && mv chromedriver /usr/local/bin/
```

# Jupyter Notebook起動
作成したイメージを使って、Jupyter Notebookを起動します。
カレントディレクトリに`work`というディレクトリを作業用に作成し、マウントしておきます。

```console
$ docker run -d -v $(pwd)/work:/home/jovyan/work -p 8888:8888 --name jupyter-selenium jupyter-selenium
```

コンテナに入って、rootユーザで色々作業するときは、以下のように起動します。
参考: https://jupyter-docker-stacks.readthedocs.io/en/latest/using/common.html#docker-options

```console
$ docker run -u root -e GRANT_SUDO=yes -d -v $(pwd)/work:/home/jovyan/work -p 8888:8888 --name jupyter-selenium jupyter-selenium
```

ブラウザでアクセスするためのURLは以下で取得します。
`0.0.0.0`の部分を`localhost`等に変えて、`http://localhost:8888/?token=(トークン)`で、Jupyter Notebookにアクセスします。
（手元のMac環境では、`0.0.0.0`そのままでも動きました。）

```console
$ docker exec -it jupyter-selenium jupyter notebook list
Currently running servers:
http://0.0.0.0:8888/?token=(トークン) :: /home/jovyan
```

Jupyter Notebookを開いたら、Python 3のノートブックを作成します。

# Seleniumコード
ChromeをHeadlessモードで起動して、Google検索するためのコードを書きます。
Pythonは初心者なので、おかしなところはご容赦ください。

## 初期化
ChromeをHeadlessモードで起動するためのオプションを指定して、Web Driverを初期化します。

```python
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from IPython.display import Image

# WebDriverが既に起動したら閉じておく(Pythonでこんな書き方で良いかは自信はない)
try:
    driver.quit()
    print("driver is closed.")
except NameError:
    print("driver is not initialized.")

options = Options()
options.binary_location = '/usr/bin/google-chrome'
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-gpu')
options.add_argument('--window-size=1280,1024')

driver = webdriver.Chrome('chromedriver', options=options)
```

## Google検索
Googleのページを開きます。
スクリーンショットを取得して、保存します。
また、`IPython.display`を使って、ノートブック上に画像を表示します。
参考: [JupyterNotebook上に画像を表示する２つの方法](https://ymgsapo.com/2019/07/21/jupyternotebook-image-show/)

```python
driver.get('https://www.google.com/')
print(driver.title)
assert driver.title == "Google"
driver.save_screenshot('screenshot_google.png')
Image("screenshot_google.png")
```

次に検索ボックスに「Selenide」と入力し、検索します。
ちなみに[Selenide](https://selenide.org/)は、SeleniumのJavaラッパーのテストフレームワークです。

```python
search_box = driver.find_element_by_name("q")
search_box.send_keys('Selenide')
search_box.submit()
print(driver.title)
driver.save_screenshot('screenshot_search.png')
Image("screenshot_search.png")
```

最後に、Web Driverを閉じます。

```python
driver.close()
driver.quit()
```

## 備考: 要素クリック直前・直後の処理を定義
要素をクリックする直前・直後に行う処理を定義する場合は、初期化時のコードをおそらく以下のようにすれば良さそうです。
待機処理やスクリーンショットの取得をクリック毎に行うような場合に使います。
使っているフレームワークや対象サイトの特性に応じて、上手に活用するとSeleniumの苦手なAjax処理の待機をうまくできたりします。

参考: [【Python】before_click/after_click・・・要素がクリックされる直前/直後の処理を実施する](https://www.seleniumqref.com/api/python/event/Python_after_click.html)

```python
from selenium.webdriver.support.events import EventFiringWebDriver, AbstractEventListener
# ・・・
class MyListener(AbstractEventListener):
    def before_click(self, element, driver):
        #要素をクリックする直前の処理
        print("before_click:" + driver.current_url)
    def after_click(self, element, driver):
        #要素をクリックした直後の処理
        print("after_click:" + driver.current_url)
        
base_driver = webdriver.Chrome('chromedriver', options=options)
driver = EventFiringWebDriver(base_driver, MyListener())
```