Docker + docker-compose + puppeteer でスクレイピングしてみた

こんにちは。
初投稿になります。Web・iOSアプリエンジニアの三浦です。

エンジニアとしてある程度経験を積んで余裕ができてきたので（あとGWが予想以上に暇になったので）、せっかくだからと思いQiitaを始めてみました。
よろしくお願いします！

# はじめに
さて、今回はタイトルにもある通り、 Docker や docker-compose 、 puppeteer を使ったスクレイピングについての話になります。
なぜこんな内容になったかといいますと、会社でこんなやり取りがあったんです（なお、内容は脚色しています）。

> :man_tone1: （上司） < 競合分析って重要だよね…。スクレイピングとかで、検索結果からいろいろと情報欲しいな。
> :boy_tone1: （僕） < そうですね〜（スクレイピングやったことないけど）
> :man_tone1: （上司） < だよねだよね！まあスクレイピングくらい、Webエンジニアなら学生時代とかにもやったことあるだろうしね。じゃあそういうわけでよろしく！
> :boy_tone1: （僕） < ！！？

…実は僕、学生時代はWeb周りの技術に全く触らず会社に入り、業務でもスクレイピングなどはやったことがなかったため、知識がなかったんです。
幸いこの会話はGW直前。
そういうわけで、このGWで最低限知識をつけることにしました。

まあただ、仮にも今までWeb技術の知識はつけてきましたし、単純に基礎知識をつけるだけなら正直どうとでもなりそうでした。
そういうわけで、興味はあったもののなんだかんだ触っていなかった Docker も使ってみることにしたわけです。

# 技術選定
さて、スクレイピングと言っても、やり方はいろいろあります。
頑張って直接サイトのデータを取得することもできますし、何らかのライブラリを使うという手もあるでしょう。

今回は、事前に同僚から「headless chrome というのがいいよ」というのを聞いていたので、せっかくだからと headless chrome に関係するライブラリを選んでみることにしました。
まずは何も考えずにgithubで検索してみると…

<img width="1385" alt="スクリーンショット 2019-04-29 16.41.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407637/29ca07c8-ce1f-4927-e985-8207740b9804.png">

一番上に puppeteer の文字が。

調べてみると良さげだったので、これを使うことにしました。

# 技術に触ってみる
お目当てのコードを書く前に、まずは今回使おうと決めた技術を見ていきます。

## Docker
Docker とは、どうやら調べてみると、

1. Dockerfile にほしい環境を書く
2. コマンドを実行する
3. コンテナ（指定した環境が入ったハコ）完成！

というものらしいです。
（[Dockerの開発環境構築 (Mac + Docker + PHP + Apache)](https://qiita.com/Ryooota/items/65ff0c32a5d93f225d7a)を参考にさせていただきました。ありがとうございました！）

結構簡単に書ける上、Dockerfileさえ作ってしまえば安定的な環境を作れそうです！
プロダクションで使うにはさらにいろいろ考える必要があるかもしれないですが、開発環境で使う分だけなら相当便利になりそうですね。
いろいろなところで名前を聞くわけが分かりました。

## docker-compose
通常 Dockerfile を作った後、コマンドを実行してコンテナの作成をしていくわけですが、例えばWebサーバ・DBサーバ・プロキシサーバなど複数のコンテナで一つのアプリケーションができる場合、それらをうまく組み合わせられるようコマンド実行する必要があります。
そういうときに docker-compose を使うことで、コンテナ作成のコマンド自体を制御できるようです。

1. docker-compose.yml に、コンテナ作成のコマンド実行方法を書く
2. 実行する
3. すべてのコンテナがつなぎ合わされたアプリケーション完成！

（[【初心者向け】Dockerで手軽にNode.js開発環境構築 (2)](https://qiita.com/yukin01/items/4f54496fd2f577c56b1d)を参考にさせていただきました。ありがとうございました！）

いろいろサイトなどを見ていくと、よく見られた利点は「複数のコンテナ作成コマンドを一つのファイルの記述でまとめられる」というものでした。
ただ個人的には、単体のコンテナ作成についても、複雑なコマンドを記述しておくことができ、実際のコマンドは簡素にできる（ `docker-compose up` など）ため、基本的に Docker を使う際は docker-compose も一緒に使うのが良さそうに感じました。

## puppeteer
puppeteer は、 headless chrome （UIを持たない chrome ブラウザ） を使用し、CLIでブラウザ操作をできるようにするライブラリです。
https://pptr.dev/

使用方法は簡単で、例えば指定サイトのタイトルを取得したい場合は、

```javascript
const pptr = require('puppeteer');

async function run() {
    const browser = await pptr.launch({});

    // create page
    const page = await browser.newPage();

    // fix screen size
    await page.setViewport({ width: 720, height: 600 })

    // select url
    await page.goto('https://www.google.co.jp/');

    console.log('https://www.google.co.jp/ is…');
    console.log('-----get title dynamic!-----');
    console.log(await page.title());
    console.log('----------');

    // close browser
    await browser.close()
}

run();
```

このように、直感的に書くことができます！
（[Puppeteerがクローリングに使えそう](https://gist.github.com/sys9kdr/477d4c44b51c722331951c3f4b0b0c13)を参考にさせていただきました。ありがとうございました！）

その他、フォームへの入力やクリック、指定タグの値取得から、スクリーンショットの取得やpdf化など、いろいろできるようです。

# 実際に組み合わせてみる
お待たせしました！それでは実際に、これらの技術を組み合わせてみましょう！

## 自己流
### Docker
まずは自己流で puppeteer 用の Dockerfile を書いてみると…。

```dockerfile
# node.js で puppeteer を使用します
FROM node:10.15.3-alpine

RUN yarn add puppeteer
```

まあこんな感じでしょうか。
簡単ですね！

### docker-compose
続いて docker-compose.yml は、

```yml

version: '3.7'

services:
  puppeteer:
    build: puppeteer_test
    image: puppeteer_test_image
    container_name: puppeteer_test_container
    tty: true
    volumes:
      - /path/to/puppeteer/app:/app
    working_dir: /app
```
これで良さそうです。

### puppeteer
スクレイピング内容としては、今回は簡単に、先程例示したものとします。

```javascript
const pptr = require('puppeteer');

async function run() {
    const browser = await pptr.launch({});

    // create page
    const page = await browser.newPage();

    // fix screen size
    await page.setViewport({ width: 720, height: 600 })

    // select url
    await page.goto('https://www.google.co.jp/');

    console.log('https://www.google.co.jp/ is…');
    console.log('-----get title dynamic!-----');
    console.log(await page.title());
    console.log('----------');

    // close browser
    await browser.close()
}

run();
```

googleのトップページのタイトルを取得するコードですね。
`main.js` として保存しておきます。

### 実行！
では、実行してみましょう！

```bash
# docker-compose.yml をもとに Dockerfile を実行して…
docker-compose up -d

# コンテナIDを確認して…
docker ps

# docker 内に入り…
docker exec -it {コンテナID} /bin/sh

# 実行すれば…
node main.js
```

できるはず！

```bash
(node:50) UnhandledPromiseRejectionWarning: Error: Failed to launch chrome! spawn /node_modules/puppeteer/.local-chromium/linux-650583/chrome-linux/chrome ENOENT


TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md

    at onClose (/node_modules/puppeteer/lib/Launcher.js:342:14)
    at ChildProcess.helper.addEventListener.error (/node_modules/puppeteer/lib/Launcher.js:333:64)
    at ChildProcess.emit (events.js:189:13)
    at Process.ChildProcess._handle.onexit (internal/child_process.js:246:12)
    at onErrorNT (internal/child_process.js:415:16)
    at process._tickCallback (internal/process/next_tick.js:63:19)
(node:50) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:50) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
```

おや…？

## 正しい方法
わけも分からず [trouble shooting](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md) を見てみると、

> Getting headless Chrome up and running in Docker can be tricky. The bundled Chromium that Puppeteer installs is missing the necessary shared library dependencies.

どうやら Docker で puppeteer を使うには、各種認証やフォントなどを入れる必要があるみたいでした。
というかなにげにこの trouble shooting にいろいろ書いてくれてますね…。

というわけで、以下のような構成にしました。

### Docker
trouble shooting で記述してくれている Dockerfile をそのまま使います。

```dockerfile
FROM node:10-slim

# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer
# installs, work.
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \
      --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

# If running Docker >= 1.13.0 use docker run's --init arg to reap zombie processes, otherwise
# uncomment the following lines to have `dumb-init` as PID 1
# ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init
# RUN chmod +x /usr/local/bin/dumb-init
# ENTRYPOINT ["dumb-init", "--"]

# Uncomment to skip the chromium download when installing puppeteer. If you do,
# you'll need to launch puppeteer with:
#     browser.launch({executablePath: 'google-chrome-unstable'})
# ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true

# Install puppeteer so it's available in the container.
RUN npm i puppeteer \
    # Add user so we don't need --no-sandbox.
    # same layer as npm install to keep re-chowned files from using up several hundred MBs more space
    && groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
    && mkdir -p /home/pptruser/Downloads \
    && chown -R pptruser:pptruser /home/pptruser \
    && chown -R pptruser:pptruser /node_modules

# Run everything after as non-privileged user.
USER pptruser

CMD ["google-chrome-unstable"]
```

一旦そのまま、指示されている通り実行すると…

```bash
# ビルドして 
docker build -t puppeteer-chrome-linux .

# 実行
docker run -i --init --rm --cap-add=SYS_ADMIN \
   --name puppeteer-chrome puppeteer-chrome-linux \
   node -e "`cat main.js`"
```

出ました！

```
https://www.google.co.jp/ is…
-----get title dynamic!-----
Google
----------
```

とりあえず、 Docker で puppeteer 環境を作ることはできましたね！
…ただ、ここまで来たらやはり docker-compose でできるようにしたいですよね？

### docker-compose
いろいろやってみていた所、ドンピシャな記事が。
[Dockerで日本語対応のHeadless Chrome + puppeteerを立ち上げ](https://morizyun.github.io/docker/docker-compose-chrome-puppeteer-headless.html)

ありがたくこちらを参考にさせてもらい、先程の実行コマンドを以下のように docker-compose.yml に落とし込んでみました。

```yml
version: '3.7'

services:
  puppeteer:
    build: puppeteer
    image: puppeteer-chrome-linux
    init: true
    cap_add:
      - SYS_ADMIN
    container_name: puppeteer-chrome
    tty: true
    stdin_open: true
    volumes:
      - /path/to/puppeteer/app:/app
    working_dir: /app
```

### 実行！
上記の Dockerfile 、 docker-compose.yml を使用して、

```bash
# 今回は一度のみ実行・直接 docker に入りたいので docker-compose run を実行
docker-compose run --rm --entrypoint /bin/sh puppeteer

# docker 内で実行
node main.js
```

こちらも大丈夫そうです！

```
https://www.google.co.jp/ is…
-----get title dynamic!-----
Google
----------
```

# おわりに
というわけで、なんとか表題の通り `Docker + docker-compose + puppeteer でスクレイピングしてみた` ができました。
スクレイピングと言ってもここでは指定サイトのタイトルを取得しただけですが、やりようによってはいろいろできそうです！

今回のコードは、以下のリポジトリに入れてあります。
https://github.com/takayuki-miura0203/docker-puppeteer-sample
良ければ参考にしてください。

初投稿でまだまだ至らぬ点があると思いますが、今後ともよろしくお願いします！

# 参考文献
以下のサイトを参考にさせていただきました。
ありがとうございました！

[Dockerの開発環境構築 (Mac + Docker + PHP + Apache)](https://qiita.com/Ryooota/items/65ff0c32a5d93f225d7a)
[【初心者向け】Dockerで手軽にNode.js開発環境構築 (2)](https://qiita.com/yukin01/items/4f54496fd2f577c56b1d)
[Puppeteer v1.15.0](https://pptr.dev/)
[Puppeteerがクローリングに使えそう](https://gist.github.com/sys9kdr/477d4c44b51c722331951c3f4b0b0c13)
[puppeteer/troubleshooting.md at master · GoogleChrome/puppeteer](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md)
[Dockerで日本語対応のHeadless Chrome + puppeteerを立ち上げ](https://morizyun.github.io/docker/docker-compose-chrome-puppeteer-headless.html)