Google Cloud Functions と Puppeteer で動的ウェブページを実行してコンテンツを返す API を作る

# はじめに

* ウェブページをスクレイピングして遊んでいたところ JavaScript を使った動的なページが出てきて困ったので、Google Cloud Functions と Puppetter を使ってウェブページのダウンロードと JavaScript の実行をしてコンテンツを返してくれる API を作りました。

# できたもの

* https://github.com/nirasan/cloud-functions-dynamic-page-renderer

# つかいかた

```sh
# ダウンロード
git clone https://github.com/nirasan/cloud-functions-dynamic-page-renderer.git

# 初期化
cd cloud-functions-dynamic-page-renderer
npm install

# ローカルで実行
npm start &
curl -G 'http://localhost:8080' --data-urlencode 'url=http://example.com/' 

# Google Cloud Functions にデプロイ（gcloud の初期化やプロジェクトの作成は済んでいるものとする）
npm run deploy
```

# 解説

* Google Cloud Functions の nodejs8 ランタイムでは Puppetter をサポートしているので超手軽にヘッドレスブラウザを実行することが出来ます。

## コンテンツのダウンロードと JavaScript の実行

* Puppetter でウェブページをダウンロードして実行するのはこんな感じでほぼサンプルみたいなものです。

```index.js
async function run(url) {
    const puppeteer = require('puppeteer');

    const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});
    const page = await browser.newPage();

    await page.goto(url, {waitUntil: 'networkidle0'});
    await autoScroll(page);

    const content = await page.content();
    browser.close();
    return content;
}
```

* `autoScroll()` は画面をスクロールさせることで画像の遅延読み込みや JavaScript の遅延実行が行われるようにしています。

```index.js
async function autoScroll(page) {
    await page.setViewport({
        width: 1200,
        height: 800
    });
    await page.evaluate(async () => {
        await new Promise((resolve, reject) => {
            let totalHeight = 0;
            const distance = 100;
            const timer = setInterval(() => {
                const scrollHeight = document.body.scrollHeight;
                window.scrollBy(0, distance);
                totalHeight += distance;

                if (totalHeight >= scrollHeight) {
                    clearInterval(timer);
                    resolve();
                }
            }, 100);
        });
    });
}
```

## Cloud Functions 関数本体

* Cloud Functions に登録している関数本体は `run()` を実行してコンテンツを返すだけです。

```index.js
exports.dynamicPageRenderer = (req, res) => {
    const url = req.query.url;
    if (!url) {
        return;
    }
    run(url)
        .then((content) => {
            res.status(200).send(content);
        })
        .catch((err) => {
            console.error(err);
            res.status(500).send("error: " + err);
        })
};
```

## 開発サーバー立ち上げ

* ローカル実行は [Functions Framework](https://cloud.google.com/functions/docs/functions-framework?hl=ja) というのが用意されていて簡単に開発用サーバーを立ち上げることが出来ます。

```sh
functions-framework --target=dynamicPageRenderer
```

## デプロイ

* デプロイは gcloud コマンドです。メモリは少ないと実行時に死んじゃうのでとりあえず 1GB を指定します。

```sh
gcloud functions deploy dynamicPageRenderer --runtime nodejs8 --trigger-http --memory=1024MB --timeout=120s
```

## 認証

* Cloud Functions の HTTP 関数はデフォルトで App Engine サービスアカウントなど一部の許可されたユーザーからのみアクセス可能になっています。
* ユーザーの追加や未認証ユーザーからのアクセスを受け付けたい場合は関数の設定画面から設定をします。
* 2019 年 11 月 1 日以前はデフォルトで未認証ユーザーからのアクセスを受け付けていたということで GCP 内で利用する場合はセキュアで便利になったようです。

### NodeJS

* Cloud Functions や App Engine など GCP のサービス間でリクエストを行う場合はメタデータサーバーからアクセストークンを取得して認証されたリクエストを行う
* https://cloud.google.com/functions/docs/securing/authenticating#functions-bearer-token-example-nodejs

### Go

* Cloud Functions や App Engine など GCP のサービス間でリクエストを行う場合や環境変数  GOOGLE_APPLICATION_CREDENTIALS で証明書が指定されサービスアカウントが有効になっている環境では `google.golang.org/api/idtoken` パッケージを使って認証されたリクエストを行う
* https://cloud.google.com/functions/docs/securing/authenticating#functions-bearer-token-example-go