Puppeteer on AWS Lambda で日本語対応したキャプチャを撮影してS3にアップロードするまでの設定


# はじめに
この投稿では以下の内容を説明しています。

- AWS Lambda に Headless Chrome をインストールし、キャプチャした画像を S3 にアップロードします。
- 自動化スクリプトは Puppeteer を使用します。
- 何もしないと日本語が文字化けして豆腐が表示されるので日本語のフォントが Lambda 実行環境の Headless Chrome で表示できるようにします。

# 前提条件
- Lambda 実行環境で Node v8.10 が実行できること
- S3 バケットと accessKeyId, secretAccessKey

# 使用するパッケージ
- [@serverless-chrome/lambda@1.0.0-38](https://github.com/adieuadieu/serverless-chrome/tree/master/packages/lambda)
- [puppeteer@1.2.0](https://github.com/GoogleChrome/puppeteer)
- [aws-sdk@2.304.0](https://github.com/aws/aws-sdk-js)

# プロジェクトの作成

```bash

mkdir puppeteer_lambda
cd puppeteer_lambda
npm init -y
```

## パッケージをインストール

必要なパッケージをインストールしていきます。

## serverless-chrome と puppeteer のインストール

serverless-chrome　をインストールします。
ここで注意してもらいたいのが、`@serverless-chrome/lambda` と `puppeteer` のバージョンです。
いずれも最新版ではなく、相互で依存するバージョンを指定しているので、これ以外のバージョンでは動作しません。

```bash

npm install @serverless-chrome/lambda@1.0.0-38
npm install puppeteer@1.2.0
```

また、Lambda にはデプロイのファイル容量制限があります。
詳細はよくわかっていませんが、圧縮したソースコードなら50MBまでという制限があり、Puppeteer でインストールされる Chromium のバイナリを含めると 170MB になってしまいます。

公式では `npm i puppeteer-core` でバイナリを除くコアファイルのみをインストールする方法も紹介されていますが、今回は環境変数で標準の Chromium をダウンロードしないように設定します。



プロジェクトルートに `.npmrc` を作成し以下の環境変数を設定します。

```config:.npmrc
puppeteer_skip_chromium_download=true
```

その他にも依存するパッケージをインストールします。

```bash
npm install aws-sdk chrome-remote-interface child_process
```

最終的にはこのような package.json になります。

```json:package.json
{
  "name": "puppeteer_lambda",
  "main": "index.js",
  "dependencies": {
    "@serverless-chrome/lambda": "1.0.0-38",
    "aws-sdk": "^2.304.0",
    "chrome-launcher": "^0.10.2",
    "chrome-remote-interface": "0.25.5",
    "puppeteer": "1.2.0"
  }
}
```

# 日本語フォント設定

Lambda で動作するヘッドレスブラウザはそのままだと日本語が文字化けしたり、豆腐が表示されたりします。
`@serverless-chrome/lambda` リポジトリでも既知の問題みたいですが、現状では公式な対応はサれていないようです。
日本語が文字化けするなら日本語フォントをインストールすればいい・・・
ですが、Lambda はサーバレスなので root でログインする環境はありません。

[Lambda 実行環境と利用できるライブラリ](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/current-supported-versions.html)

上記のページに記載があるように Lambda 実行環境は　`Amazon Linux` をベースに構築されているようで、`fontconfig` も導入されているので、日本語フォントはこの `fontconfig` を使用して、フォントの設定をリビルドして利用します。

```js
/**
 * Fontconfig 設定
 *
 * @returns {Promise<any>}
 */
function setFontConfig() {
    return new Promise((resolve, reject) => {
        process.env.HOME = process.env.LAMBDA_TASK_ROOT
        const command = `fc-cache -v ${process.env.HOME}.fonts`
        exec(command, (error, stdout, stderr) => {
            if (error) {
                return reject(error)
            }
            resolve();
        })
    });
}
```

`fc-cache` コマンドを実行する関数を実装しておきます。コマンド実行後に後の処理をしたいので `Promise` で実装しておきます。

プロジェクトルートでは `.fonts` ディレクトリを作成し、インストールしたい日本語フォントを格納しておきます。
日本語のフォントファイルはサイズが大きいので、Puppeteer インストール時に Chromium のバイナリをインストールしないように設定しましたが、日本語フォントファイルで50MBはゆうに超えてしまいますので、Lambda へデプロイする際には S3 経由でデプロイすることになります。


```js
./fonts/
├── NotoSansCJKjp-Bold.otf
├── NotoSansCJKjp-Regular.otf
├── ipag.ttf
└── ipagp.ttf
```

- [IPAフォント](https://ipafont.ipa.go.jp/old/ipafont/download.html)
- [Google Noto Fonts](https://www.google.com/get/noto/)


# S3 の接続情報ファイル

S3 への接続情報を外部ファイル化して `rootkey.json` として保持しておきます。

```json:rootkey.json

{
    "accessKeyId": "XXXXXXXXXX",
    "secretAccessKey": "XXXXXXXXXXXXXXXXXXXX"
}

```


# index.js を作成する

Lambda のエントリープログラムの index.js を作成します。

```js:index.js

const AWS = require('aws-sdk');
const launchChrome = require('@serverless-chrome/lambda');
const CDP = require('chrome-remote-interface');
const puppeteer = require('puppeteer');
const exec = require('child_process').exec;

// パラメタ
const SAVE_BUCKET_NAME = 'xxxxxxxxx';
const BUCKET_REGION = 'ap-northeast-1';
const LOGIN_URL = 'https://example.com/login';
const URL = 'https://example.com/target/url';
const LOGIN_ID = 'id';
const PASSWORD = 'password';

/**
 * Fontconfig 設定
 *
 * @returns {Promise<any>}
 */
function setFontConfig() {
    return new Promise((resolve, reject) => {
        process.env.HOME = process.env.LAMBDA_TASK_ROOT
        const command = `fc-cache -v ${process.env.HOME}.fonts`
        exec(command, (error, stdout, stderr) => {
            if (error) {
                return reject(error)
            }
            resolve();
        })
    });
}

/**
 * Lambdaハンドラ
 * @param {Object} event Lambdaイベントデータ
 * @param {Object} context Contextオブジェクト
 * @param {function} callback コールバックオプション
 */  
// exports.handler = async (event, context, callback) => {
exports.handler = async (event, context) => {

    await setFontConfig()

    let slsChrome = null;
    let browser = null;
    let page = null;

    try {
        // 前処理
        // serverless-chrome を起動し、Puppeteer から Web Socket で接続する
        slsChrome = await launchChrome({
            flags: [
                '--headless',
                // '--disable-gpu',
                // '--no-sandbox',
                // '--single-process',
                '--window-size=1048,743',
                // '--user-data-dir=/tmp/user-data',
                '--hide-scrollbars',
                '--enable-logging',
                // '--log-level=0',
                // '--v=99',
                // '--data-path=/tmp/data-path',
                '--ignore-certificate-errors',
                // '--homedir=/tmp',
                // '--disk-cache-dir=/tmp/cache-dir',
                '--disable-setuid-sandbox',
                // '--remote-debugging-port=9222',
                ]
        });
        browser = await puppeteer.connect({ 
            ignoreHTTPSErrors: true,
            browserWSEndpoint: (await CDP.Version()).webSocketDebuggerUrl 
        });
        page = await browser.newPage();
        page.setDefaultNavigationTimeout(60000)
        page.setViewport({
            // width: 842, // A4 72dpi
            // height: 595
            width: 1048,
            height: 743
        })

        

        // ブラウザ操作
        await page.goto(LOGIN_URL, {
            // waitUntil: 'domcontentloaded'
            // waitUntil: 'networkidle2',
            timeout: 0
        });
        await page.type('[name="loginId"]', LOGIN_ID);
        await page.type('[name="password"]', PASSWORD);
        const buttonElement = await page.$('#submit-form');
        await buttonElement.click();
        await page.waitFor(5000);
        await page.goto(URL);

        
        await page.waitFor(5000);
        const jpgBuf = await page.screenshot({ fullPage: true, type: 'jpeg' });
        
        // S3に保存
        AWS.config.loadFromPath('./rootkey.json');
        AWS.config.update({region: BUCKET_REGION});

        const s3 = new AWS.S3();
        const now = new Date();
        now.setHours(now.getHours() + 9);
        const nowStr = '' +
            now.getFullYear() + '-' +
            (now.getMonth() + 1 + '').padStart(2, '0') + '-' +
            (now.getDate() + '').padStart(2, '0') + ' ' + 
            (now.getHours() + '').padStart(2, '0') + ':' +
            (now.getMinutes() + '').padStart(2, '0') + ':' + 
            (now.getSeconds() + '').padStart(2, '0');
        const fileName = nowStr.replace(/[\-:]/g, '_').replace(/\s/g, '__');
        let s3Param = {
            Bucket: SAVE_BUCKET_NAME,
            Key: null,
            Body: null
        };

        s3Param.Key = fileName + '.jpg';
        s3Param.Body =  jpgBuf;
        await s3.putObject(s3Param).promise();

        console.log({ result: 'OK', createDate: nowStr });
        // return callback(null, JSON.stringify({ result: 'OK', createDate: nowStr }));
    } catch (err) {
        console.error(err);
        console.log({ result: 'NG' });
        // return callback(null, JSON.stringify({ result: 'NG' }));
    } finally {
        if (page) {
            await page.close();
        }

        if (browser) {
            await browser.disconnect();
        }

        if (slsChrome) { 
            await slsChrome.kill();
        }
    }
};
```

オレオレ証明書を使用しているサイトへのアクセスでエラーがでるので以下のオプションを有効にしています

```
--ignore-certificate-errors
```

これで Lambda で動作するヘッドレスブラウザは完成です。

Puppeteer の処理はログインが必要なウェブページを想定しています。


# まとめ
今回は Lambda の実行環境で実行しましたが、URLやID/PWなどは API Gateway で API エンドポイントを設定して、パラメタで渡すようにすれば汎用化できると思います。