Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

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

  • 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/lambdapuppeteer のバージョンです。
いずれも最新版ではなく、相互で依存するバージョンを指定しているので、これ以外のバージョンでは動作しません。

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 を作成し以下の環境変数を設定します。

.npmrc
puppeteer_skip_chromium_download=true

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

npm install aws-sdk chrome-remote-interface child_process

最終的にはこのような package.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 実行環境と利用できるライブラリ

上記のページに記載があるように 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 として保持しておきます。

rootkey.json

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

index.js を作成する

Lambda のエントリープログラムの index.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 エンドポイントを設定して、パラメタで渡すようにすれば汎用化できると思います。

zyyx-matsushita
フロントエンドエンジニア Happy hacking!
https://www.zyyx.jp/
zyyx
新しい世界の核心に触れるために。我々ジークスは、技術と感性で、テクノロジーがもたらす新しい世界を作り続けます。
https://www.zyyx.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away