はじめに
この投稿では以下の内容を説明しています。
- AWS Lambda に Headless Chrome をインストールし、キャプチャした画像を S3 にアップロードします。
 - 自動化スクリプトは Puppeteer を使用します。
 - 何もしないと日本語が文字化けして豆腐が表示されるので日本語のフォントが Lambda 実行環境の Headless Chrome で表示できるようにします。
 
前提条件
- Lambda 実行環境で Node v8.10 が実行できること
 - S3 バケットと accessKeyId, secretAccessKey
 
使用するパッケージ
プロジェクトの作成
mkdir puppeteer_lambda
cd puppeteer_lambda
npm init -y
パッケージをインストール
必要なパッケージをインストールしていきます。
serverless-chrome と puppeteer のインストール
serverless-chrome をインストールします。
ここで注意してもらいたいのが、@serverless-chrome/lambda と puppeteer のバージョンです。
いずれも最新版ではなく、相互で依存するバージョンを指定しているので、これ以外のバージョンでは動作しません。
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 を作成し以下の環境変数を設定します。
puppeteer_skip_chromium_download=true
その他にも依存するパッケージをインストールします。
npm install aws-sdk chrome-remote-interface child_process
最終的にはこのような 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 実行環境は Amazon Linux をベースに構築されているようで、fontconfig も導入されているので、日本語フォントはこの fontconfig を使用して、フォントの設定をリビルドして利用します。
/**
 * 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 経由でデプロイすることになります。
./fonts/
├── NotoSansCJKjp-Bold.otf
├── NotoSansCJKjp-Regular.otf
├── ipag.ttf
└── ipagp.ttf
S3 の接続情報ファイル
S3 への接続情報を外部ファイル化して rootkey.json として保持しておきます。
{
    "accessKeyId": "XXXXXXXXXX",
    "secretAccessKey": "XXXXXXXXXXXXXXXXXXXX"
}
index.js を作成する
Lambda のエントリープログラムの 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 エンドポイントを設定して、パラメタで渡すようにすれば汎用化できると思います。