Dockerの中のPuppeteerをスクショしてResizeし、AWS S3にアップロード

# はじめに
[Puppeteer](https://github.com/GoogleChrome/puppeteer)はGoogleが開発したHeadless版のChromeであり、フロントエンドのテストやクローリングに活用されています。我々はPuppeteerが作成したスクリーンショートを整形して、AWS S3に送信することで、テストの省力化を図っています。

# Node.jsでAWS S3に接続
## アクセスキーの作成
まず、IAMで `AmazonS3FullAccess` の権限をもつユーザーを作る。当然、`FullAccess`はセキュリティホールなので、本番運用は細かく調整してください。
そして、そのアカウントのアクセスキーを作成して、保存します。
詳しい設定の仕方は検索してください。

## S3バケツの作成
ここも割愛します。作成したバケツの`BUCKET_NAME`をメモしてください。

## `.env`ファイルの作成
上記手順で作られてキー等を`.env`ファイルを記入します。
Dockerで運用するのを想定したので、直接で`.env`を利用しません。

```.env
AWS_ACCESS_KEY_ID = xxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY = xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_REGION = xxxxxxxx
AWS_BUCKET_NAME = xxxxxxxxxxxxxx
```

## S3アダプターの作成
今回はTypeScriptで作成しているが、JSで使いたい場合、適当にJSに書き換えてください。
そしてパッケージは各自でinstallしてください。


```aws-s3.ts
import * as AWS from 'aws-sdk'

const awsLoginParams = {
  accessKeyId: process.env['AWS_ACCESS_KEY_ID'],
  secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'],
  region: process.env['AWS_REGION'],
}

export default class S3 extends AWS.S3 {
  constructor() {
    AWS.config.update(awsLoginParams)
    super()
  }
}
```

こうすれば、下記の手順で記述でS3にアクセスできます。

```mock.ts
import S3 from './aws-s3'

const s3 = new S3()

s3.listBuckets((err, res) => console.log(res))
```

`.env`ファイルを導入して実行：

```
eval $(cat .env) $(npm bin)/ts-node mock.ts
```

上記コードでバケツの一覧が出されるとOKです。

## Puppeteerでキャプチャーを取る

```screenshot.ts
import * as puppeteer from 'puppeteer'

const puppeteerOption = {
  // for Docker
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
  ],
  defaultViewport: {
    width: 1024,
    height: 768,
    isLandscape: true,
  },
}

export default class Screenshot {
  public opt: puppeteer.LaunchOptions
  public browser: puppeteer.Browser
  public pages: puppeteer.Page[]

  constructor() {
    this.opt = { ...puppeteerOption }
    if (process.env['CHROME_BIN']) {
      // for Docker
      this.opt.executablePath = process.env['CHROME_BIN']
    }
  }

  public async init() {
    this.browser = await puppeteer.launch(this.opt)
  }

  public async printScreen(url, options = {}) {
    const page = await this.browser.newPage()
    await page.goto(url)
    const picBuffer = await page.screenshot({ ...options })
    await page.close()
    return picBuffer
  }

  public async close() {
    await this.browser.close()
  }
}
```

オフィシャルのサンプルコードのロジックに毛が生えた程度のものでした。

再びmockを作って動かしてみます。

```mock.ts
import Screenshot from './screenshot'
const ss = new Screenshot()
ss.init().then()
ss.printScreen('https://google.com', { path: 'google.png' }).then()
ss.close().then()
```

`google.png`というスクショが取られているはず。

## Puppeteerで撮ったキャプチャーをリサイズします
Puppeteerの `page.screenshot()` は `Promise<Buffer>` でキャプチャーを返すので、それを処理できるのは[sharp](https://github.com/lovell/sharp)というライブラリがあリます。

早速`mock.ts`を改造します

```mock.ts
import Screenshot from './screenshot'
import * as sharp from 'sharp'

(async () => {
  const ss = new Screenshot()
  await ss.init()
  const picBuffer = await ss.printScreen('https://google.com')
  await sharp(picBuffer).resize(320, 240).toFile('google.png')
  await ss.close()
})()
```

動かしてみれば、リサイズされた`google.png`が出力されます。

## リサイズしたスクショをS3にアップ

### まず、Bufferをアップロードするロジックの実装

`putImage()`を`class S3`に実装します。

```aws-s3.ts
export default class S3 extends AWS.S3 {
　/* その他コードを省略 */

  public async putImage(buffer: Buffer, filePath: string, options = {}) {
    const params = {
      Bucket: process.env['AWS_BUCKET_NAME'],
      Key: filePath,
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
      ...options,
    }
    return new Promise((resolve, reject) => {
      this.putObject(params, (err, data) => {
        if (err) {
          reject(err)
        } else {
          resolve(data)
        }
      })
    })
  }
}
```

ここの `ACL: 'public-read'` が重要です。
デフォルトだと、アップロードしたファイルにアクセスすると下記のエラーが出ます。

```xml

<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
<RequestId>XXXXXXXXXXXXXX</RequestId>
<HostId>
 XXXXXXXXXXXXXX=
</HostId>
</Error>
```

### そして、つい、`index.ts`を実装できるようになります

```index.ts
import S3 from './aws-s3'
import Screenshot from './screenshot'
import * as sharp from 'sharp'

(async () => {
  const ss = new Screenshot()

  const s3 = new S3()

  await ss.init()
  const picBuffer = await ss.printScreen('https://google.com')
  const resBuffer = await sharp(picBuffer).resize(320, 240).toBuffer()

  const res = await s3.putImage(resBuffer, 'test_dir/google.png')
  console.log(res)
  await ss.close()
})()

```

`eval $(cat .env) $(npm bin)/ts-node index.ts`で実行すると、S3のバケツに`test_dir/google.png`がアップされます。

<img width="472" alt="google_png__320×240_.png" src="https://qiita-image-store.s3.amazonaws.com/0/87438/ac2dd7ae-e494-491d-1038-fd454eec4391.png">


## Dockerの中に動かしてみる

できるだけ小さいImageで動きたいので、`alpine`を利用します。
依存環境のセットアップは[こちら](https://github.com/GoogleChrome/puppeteer/issues/1793#issuecomment-442730223)を参考しています

```Dockerfile
FROM node:10.15.3-alpine
LABEL maintainer="Iuliana Augusta <iuliana.augusta@gmail.com>"

WORKDIR /usr/local/src/app

ENV CHROME_BIN="/usr/bin/chromium-browser"
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD="true"

RUN set -x \
  && apk update \
  && apk upgrade \
  && apk add --no-cache \
  dumb-init \
  udev \
  ttf-freefont \
  chromium

# Cleanup
RUN apk del --no-cache make gcc g++ python binutils-gold gnupg libstdc++ \
  && rm -rf /usr/include \
  && rm -rf /var/cache/apk/* /root/.node-gyp /usr/share/man /tmp/* \
  && echo

ENV AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_REGION AWS_BUCKET_NAME

ADD ./package.json /usr/local/src/app/package.json
RUN npm install

ADD ./index.ts /usr/local/src/app/index.ts
ADD ./screenshot.ts /usr/local/src/app/screenshot.ts
ADD ./aws-s3.ts /usr/local/src/app/aws-s3.ts

```

APP起動用コマンドを追記


```package.json
// その他コードは省略
  "scripts": {
    "start": "ts-node index.ts",
  },
```

環境変数を`docker-compose.yml`に設定

```docker-compose.yml
version: '3.7'
services:
  puppeteer-s3:
    container_name: puppeteer-s3
    build:
      dockerfile: ./Dockerfile
    environment:
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      AWS_REGION: ${AWS_REGION}
      AWS_BUCKET_NAME: ${AWS_BUCKET_NAME}
    command: /bin/sh -c "npm start"
```

下記のファイル構成になっているかを確認

```
puppeteer-s3/
　├ Dockerfile
　├ docker-compose.yml
　├ package.json
　├ index.ts
　├ screenshot.ts
　├ aws-s3.ts
　└ .env
```

問題なければ、`docker-compose build` と `docker-compose up`で起動すれば、AWS S3の`test_dir/google.png`が更新されるはずです

```sh
$ docker-compose build
...
Successfully built 67ea322b9239
Successfully tagged puppeteer-s3_puppeteer-s3:latest

$ docker-compose up
...
puppeteer-s3    | > node-app@1.0.0 start /usr/local/src/app
puppeteer-s3    | > ts-node index.ts
puppeteer-s3    | 
puppeteer-s3    | { ETag: '"144bca147e1b5dda7f537dc1275153f8"' }
```

## おまけ：スクショの並列作成

わざと、エラーが発生するURL `https://error.error/` をいれました。

```index.ts
import S3 from './aws-s3'
import Screenshot from './screenshot'
import * as sharp from 'sharp'

const urlList = {
  'apple': 'https://www.apple.com/jp/',
  'bitbucket': 'https://bitbucket.org/',
  'error': 'https://error.error/',
  'example': 'https://example.com/',
  'github': 'https://github.com/',
  'google': 'https://www.google.com/',
  'hacker_news': 'https://news.ycombinator.com/',
  'yahoo': 'https://www.yahoo.co.jp/',
}

const errors = {}

;(async () => {
  const ss = new Screenshot()
  const s3 = new S3()
  await ss.init()

  const printAndUp = async (url: string, filePath: string) => {
    const picBuffer = await ss.printScreen(url)
    const resBuffer = await sharp(picBuffer).resize(320, 240).toBuffer()
    const res = await s3.putImage(resBuffer, filePath)
    return res
  }

  const chunks = chunkArray(Object.keys(urlList), 3)
  for (const chunk of chunks) {
    await Promise.all(chunk.map(async label => {
      const url = urlList[label]
      try {
        const res = await printAndUp(url, `test_dir/${label}.png`)
        res['url'] = url
        console.log(res)
      } catch (err) {
        errors[label] = err
        console.error(`Error!: ${url}`)
      }
    }))
  }

  console.log(errors)
  await ss.close()
})()

function chunkArray<T>(array: T[], chunkSize: number): T[][] {
  const arrayLength = array.length
  let tempArray: T[][] = []
  for (let i = 0; i < arrayLength; i += chunkSize) {
    const chunk = array.slice(i, i + chunkSize)
    tempArray.push(chunk)
  }
  return tempArray
}
```

実行結果(3つずつ並列実行なので順不同)

```sh
...
Recreating puppeteer-s3 ... done
Attaching to puppeteer-s3
puppeteer-s3    | 
puppeteer-s3    | > node-app@1.0.0 start /usr/local/src/app
puppeteer-s3    | > ts-node index.ts
puppeteer-s3    | 
puppeteer-s3    | Error!: https://error.error/
puppeteer-s3    | { ETag: '"63da3d9dbf43941f1f3d8f6de5b05cc7"',
puppeteer-s3    |   url: 'https://www.apple.com/jp/' }
puppeteer-s3    | { ETag: '"db80b122603c928257b2d26526f5afb7"',
puppeteer-s3    |   url: 'https://bitbucket.org/' }
puppeteer-s3    | { ETag: '"dfb172873ebaaba23de179a822c7d714"',
puppeteer-s3    |   url: 'https://example.com/' }
puppeteer-s3    | { ETag: '"5fd6cbcaa3c7904385c387e929070a05"',
puppeteer-s3    |   url: 'https://www.google.com/' }
puppeteer-s3    | { ETag: '"dfdadf521b41c68c37958ddbe643de99"',
puppeteer-s3    |   url: 'https://github.com/' }
puppeteer-s3    | { ETag: '"e5266fc05b86a9d4caecb27e248f2c8e"',
puppeteer-s3    |   url: 'https://www.yahoo.co.jp/' }
puppeteer-s3    | { ETag: '"0cf84cf5eb0b7e0e78e3830f50e96a36"',
puppeteer-s3    |   url: 'https://news.ycombinator.com/' }
puppeteer-s3    | { error:
puppeteer-s3    |    Error: net::ERR_NAME_NOT_RESOLVED at https://error.error/
...
```

問題なければ、AWS S3の`test_dir`に上記サイト(error.errorを除いて)のスクリーンショットがアップロードされています。

![S3_Management_Console.png](https://qiita-image-store.s3.amazonaws.com/0/87438/638f6d9e-37c1-6f97-4480-4eed7015c369.png)

# 2019-03-19 UPDATE

環境によって、 `class S3 extends AWS.S3`は下記エラーが発生する可能性があります。

```
> import S3 from './lib/aws-s3'
{}
> const s3 = new S3('')
/?????/aws-sdk/lib/util.js:601
          return klass.apply(this, arguments);
                       ^

TypeError: Class constructor S3 cannot be invoked without 'new'
```

それはTypeScriptコンパイラが余計なことをして、Classをコンパイルしてしまったのが原因です。
[本家のtsconfig.json](https://github.com/TypeStrong/ts-node/blob/master/tsconfig.json)を調べたあと、下記の `tsconfig.json` であれば問題ないでした。

```tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["es6"],
    "target": "es5",
    "types": ["node", "mocha"]
  },
  "include": ["lib"]
}
``` 


# 2019-03-28 UPDATE
## 日本語が□(tofu)になる件
一部のサイトをスクショとる時、日本語が表示できず、`□□□□`になっている現象がありました。

それを解消するには[Google Noto Fonts](https://www.google.com/get/noto/)を入れる必要があります。

簡単な手順：

```sh
# フォントの置き場所を作る
$ export FONT_DIR=/usr/share/fonts/opentype/noto
$ mkdir -p $FONT_DIR

# フォントをダウンロード
$ curl -LsS https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip > $FONT_DIR/font.zip

# ダウンロードしたフォントを解凍し、関係のないファイルを削除
$ unzip $FONT_DIR/font.zip -d $FONT_DIR
$ find $FONT_DIR -type f -not -name '*otf' -and -not -name '*otc' | xargs rm -r

# フォントキャッシュを更新する
$ chmod 644 -R $FONT_DIR && fc-cache -fv
```


# 参考
https://bitsofco.de/how-to-upload-a-screenshot-from-puppeteer-to-cloudinary/
## AWS S3
https://qiita.com/kaoryuuu/items/0e312c6b89bf03619578
https://qiita.com/hashito/items/dff9adc37fb683e6c738
https://blog.ukai-mnap.co.jp/2018/02/05/node-upload-s3/
https://hotchpotchj37.wordpress.com/2017/03/08/aws-sdk-node-js-で-s3-putobjectするときに気をつけること/
https://stackoverflow.com/a/20909045

## DockerにPuppeteer
http://shohu.tech/dcoker/post-395.html
https://github.com/GoogleChrome/puppeteer/issues/1793#issuecomment-442730223
https://github.com/GoogleChrome/puppeteer/issues/1425#issuecomment-359663629

## Array Chunk
https://ourcodeworld.com/articles/read/278/how-to-split-an-array-into-chunks-of-the-same-size-easily-in-javascript

## Noto
https://www.google.com/get/noto/help/install/
https://qiita.com/dd511805/items/dfe03c5486bf1421875a