画面キャプチャーをPuppeteerで取ってみる。

お仕事をしていると時々「作成したWebページ全ての画面キャプチャーを納品してくれ」という要件が出てきます。その都度、人海戦術で取得していたのですが「なんとかならないかなぁ」とGoogleさんに聞きつつウロウロしていたら「Puppeteer」というものを使えば簡単に画面キャプチャーを取れるということで試してみました。

# 私のスペック

- ほぼ10年来のPHPer（もっと昔はperl、もっともっと昔はjava）
- しかしjavascriptは超初心者（実務経験ほぼゼロ）
- Node.js使うの今回が初めて。

以降、お見苦しいコードがあるかもしれませんがお手柔らかにお願いいたします。


# 実装イメージ

コマンドラインのようにキャプチャーを取得するものもありましたが（[alekzonder/puppeteer](https://hub.docker.com/r/alekzonder/puppeteer/)）、
自分が作ったものは下記のように動きます。

 - json形式で取得したいURLの一覧と付随する情報を記載します。
   - PNG形式か、PDF形式か。
   - basic認証
   - 画面サイズはPCで取るか、スマートフォンで取るか。
   - 実際に取るまでに待機する秒数（時々全画面を表示するまでに時間のかかるページがあるので）
 - 取得したいURLを順番に回っていき１枚１枚取得していく。

# 環境
- Node.js
  - v10.13.0
- Puppeteer
  - v1.11.0

# ディレクトリ構成

以下に記載するコードは下記のようなディレクトリ構成で設置されているものとします。

```markdown
/data/
    urllist.json  <-- 取得対象のURLを保持しているjson形式のデータファイル
    capture.js    <-- 取得ロジック
    capimage/     <-- キャプチャーしたデータの設置場所
```

# json形式の取得対象URLデータ

下記のようなjson形式のファイルを作る。

```json:/data/urlist.json
[
    {
        "filetype":"png",
        "filename":"index_pc",
        "url":"https:\/\/www.fork.co.jp\/",
        "device":"pc",
        "waitsec":"3",
        "basic_id":"",
        "basic_pw":""
    },
    {
        "filetype":"png",
        "filename":"index_sp",
        "url":"https:\/\/www.fork.co.jp\/",
        "device":"sp",
        "waitsec":"",
        "basic_id":"",
        "basic_pw":""
    }
]
```
- filetype：取得するキャプチャー種別。`"pdf"`と`"png"`が設定できる。
- filename：保存する際のファイル名のベースになる文字列
- url：キャプチャーを取得したいURL
- device：取得時のアクセス機種。`"pc"`と`"sp"`が設定できる。
- waitsec：キャプチャーを取得するまでの待機時間。未指定の場合は`""`を指定する。
- basic_id：basic認証のID。ない場合は`""`を指定する。
- basic_pw：basic認証のパスワード。ない場合は`""`を指定する。


# 実際にキャプチャーするコード

```javascript:/data/capture.js

// puppeteer読み込み
const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');

// jsonfile読み込み
const fs = require('fs');
var obj  = JSON.parse(fs.readFileSync('/data/urllist.json', 'utf8'));

(async () => {
  var now = await new Date();
  await now.setTime(now.getTime() + 1000*60*60*9);
  await console.log(now + ' START.');

  // ブラウザ生成
  const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});

  // タブ生成？
  const page = await browser.newPage();

  for (i=0; i< obj.length; i++) {
    var v = obj[i];

    // basic認証のid, pwがセットされていた場合は読み出す
    if (v.basic_id !== undefined && v.basic_pw !== undefined) {
      await page.authenticate({username:v.basic_id, password:v.basic_pw});
    }

    // 画面サイズ指定
    if (v.device == 'pc') {
      // PCの画面サイズは1920×1080
      await page.setViewport({width: 1920, height: 1080});
    }else{
      // SPの画面サイズは「iPhone X」を使用。
      await page.emulate(devices['iPhone X']);
    }

    // URLアクセス
    await page.goto(v.url, {waitUntil:'networkidle2'});

    // 末尾迄スクロール
    // https://github.com/GoogleChrome/puppeteer/issues/305#issuecomment-385145048
    const autoScroll = async (page) => {
      await page.evaluate(async () => {
        await new Promise((resolve, reject) => {
          let totalHeight = 0
          let distance = 100
          let timer = setInterval(() => {
            let scrollHeight = document.body.scrollHeight
            window.scrollBy(0, distance)
            totalHeight += distance
            if(totalHeight >= scrollHeight){
              clearInterval(timer)
              resolve()
            }
          }, 100)
        })
      })
    }
    autoScroll(page);

    // 画面キャプチャーを取得するまでの待機秒数が設定されていれば待機する
    if (v.waitsec != '') {
      await page.waitFor(v.waitsec * 1000);
    }

    if (v.filetype == 'png') {
      // 画面キャプチャ（png形式）
      await page.screenshot({path: '/data/capimage/' + v.filename + '.' + v.filetype, fullPage: true});
    }else{
      // 画面キャプチャ（pdf形式でA4の大きさで）
      await page.pdf({path: '/data/capimage/' + v.filename + '.' + v.filetype, fullPage: true, format: 'A4'});
    }
    var now = await new Date();
    await now.setTime(now.getTime() + 1000*60*60*9);
    await console.log(now + ' DONE. '+ v.url +' /data/capimage/' + v.filename + '.' + v.filetype);
  }

  // ブラウザクローズ
  await browser.close();
  var now = await new Date();
  await now.setTime(now.getTime() + 1000*60*60*9);
  await console.log(now + ' FINISH.');
})();
```

# 実行方法

上記のファイル、ディレクトリ構成があると仮定して下記のように実行する

```markdown
$ node /data/capture.js
```

# 工夫したところ

- キャプチャーを取る対象のURLにbasic認証がかかっていることが多いのでデフォルトパラメータとして追加した。
- 表示するまでに何秒か「loading」を挟むページや、動きのあるサイトの場合、取得準備が整った瞬間に取ってしまうと動き途中のキャプチャーになるので「しばらく待機する」機能をつけた。
- 可視範囲になってから初めて表示させるコンテンツを持つページに対応するべく、ページの末尾までスクロールする処理を追加した。
  - ロジック内にも記載しているがスクロールする処理は **[ここ](https://github.com/GoogleChrome/puppeteer/issues/305#issuecomment-385145048)** から拝借しております。
- キャプチャーを取得する度にヘッドレスブラウザのタブを起動するのではなく、一つのタブで順番に取得するようにした。これにより取得処理には時間がかかるがメモリの消費量は少ないものになっている。（はず…）
  - おそらく複数のタブを使用して一気にキャプチャーを取る処理も実装可能だと思われるが、可動しているPCのメモリー使用量がとんでもないことになりそうなのでこのような実装方式にした。

# 改良点

- json形式のファイルの設置パス、取得キャプチャーを保存する場所等がロジックに直書きされているがここを可変に変えれば複数の人が同時に取得処理を実施してもキャプチャーデータがかぶったりせず幸せになれるかもしれない。
- json形式のファイルを手動ではなく、例えばスプレットシートなどの入力画面から入れ、うまいことjson形式にしてこのキャプチャーロジックを動かすことができればもっと使いやすいシステムになると思う。
  - 自分の環境下では、[CakePHP](https://cakephp.org/jp) をユーザインターフェースにしてWeb画面から必要なデータを入力してもらい、それをJson形式に変換してキャプチャーロジックに食わせるという実装をしています。
- 複数のタブを使用して一気にキャプチャーを取る処理を実装する。無制限にタブを開く処理にするとキャプチャー対象によってはメモリーやCPU使用量など、えらいことになりそうなので起動するタブの数を制御する必要があるはず。

# 感想

- javascript超初心者でもここまでいろいろできるものなのかと正直驚いた。
- puppeteerの**[github](https://github.com/GoogleChrome/puppeteer)**の**[API Documentation](https://github.com/GoogleChrome/puppeteer/blob/v1.11.0/docs/api.md)**が非常にわかりやすく調べやすかった。こういうドキュメント、こういうAPI名をつけれるような人になりたい…。