Puppeteer on Google Cloud Functions

December 1, 2018

Google Cloud FunctionsがPuppeteerをサポートするようになってからそこそこ時間が経ったが、気になりつつ触れていなかったので触ってみた。特に新しい情報はないがnpm initするところから順に解説していこうと思う。

はじめに

この記事は、特に何かのAdvent Calendarの記事ではありません。(今日出したかったけどちょうどいいカレンダーがなかった)

会社の部活動?でダイエット部というのがあり、特定の日から起算して今日誰が何%くらい痩せたか(つまり元の体重も今の体重もみんなにはわからない)、というのをSlack通知するという面白い活動?をしている。ボルダリングを始めたこともあり体重の管理には敏感になっていて、75kgくらいまで体重を減らしたい僕としてはモチベーション維持の一環としてぜひ参加したいものだった。

だったのだが、Slack通知の裏では参加者各々のWithingsのアカウントからデータを取得しており、Withingsのアカウント自体はハードに関係なく作成できるが、体重などのデータを自動で流し込むためにはWithingsのスケールが必要で、すんなり参加はできなかった。僕はオムロンのスケールからOMRON Connectというあまりイケてないアプリを使ってiOSのヘルスケアでApple watchからのデータなども含めて管理しており、どうしたものかと思案した結果、こちらもちょうど触ってみたかったが触るきっかけがなかったiOSのShortscutを使ってワンタップでWithingsのHealth mateに体重を書き込むというのをやってみることにした。

そもそもなんでPuppeteer?という話だが、WithingsはOAuth2のAPI連携はできるものの自分のアカウントに体重を書き込みたいだけという用途ではtoo muchだと思ったのでPuppeteerでゴリ押しすることにした。

作ったもの

yagihashoo/z-diet

Cloud Functionsの関数をつくる

GCPのコンソールでCloud Functionsの関数をつくる。

01.png 02.png

設定内容はこんな感じ。ソースコードは後でGitHubから自動デプロイするのでなんでもいい。

メモリとタイムアウトは気をつける必要がある。Chromeがけっこうメモリを食うのと、画面遷移の数やWebサイトのRTTにもよるが実行時間もそれなりにかかるのでタイムアウトは長めにしておく必要がある。とりあえず512MB、180秒でうまくいくところまでは確認した。

環境変数はそれぞれ以下の値を設定。

  • secret: 何か適当な乱数値。自分が安全だと思う長さで。これがないリクエストは弾くようにする。
  • username: あとで書くがHeadless Chromeを使ってログイン処理も行うのでユーザネームとパスワードが必要。
  • password: 同上。どうせWithingsでしか使っていないパスワードなので躊躇なく入れておく。自己責任で。

Cloud Build経由で自動デプロイできるようにする

ブラウザのインラインエディタで編集するのも大変なのでGitHubのmasterブランチから自動でデプロイされるようにしたい。こちらを参考に設定する。

Cloud Storageのバケットをつくる

名前とかリージョンとかは適宜。

03.png

Cloud Buildのトリガーをつくる

ソースをGitHubにして、

04.png

リポジトリを選択する。

05.png

masterブランチへのpushをトリガーにして、/cloudbuild.yamlに従ってビルドするようにしておく。無視されるファイルは適宜指定。挙動に関係ないものへの変更を受けてビルドが走ることがないようにしてある。

06.png

GCP側での準備はここまで。

コードを書いていく

npmまわり

npm initして適当に中身を埋めていく。最終的にpackage.jsonはこんな感じにした。functionsはCloud Functionsのローカルエミュレータ。globalでインストールせずにnpm経由で使うようにしてみているが実際あんまり使ったことがない…。今回は環境変数の設定方法を調べるのが億劫でノータッチ。

{
  "name": "z-diet",
  "version": "1.0.0",
  "description": "Cloud func for #z-diet",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "functions:start": "functions start",
    "functions:stop": "functions stop",
    "functions:kill": "functions kill",
    "functions:deploy": "functions deploy main --trigger-http",
    "functions:delete": "functions delete main",
    "functions:logs": "functions logs read",
    "functions": "functions"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/yagihashoo/z-diet.git"
  },
  "author": "yagihashoo",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/yagihashoo/z-diet/issues"
  },
  "homepage": "https://github.com/yagihashoo/z-diet#readme",
  "dependencies": {
    "@google-cloud/functions-emulator": "1.0.0-beta.5",
    "puppeteer": "1.10.0"
  }
}

puppeteer-coreではなくnpm installしたときにChromiumも落としてきてくれるpuppeteerを使っている。試していないが、サポートされているとはいえCloud Functionsの環境はChrome/Chromiumまでインストール済の親切設計ではないと思う、たぶん。

index.jsを書く

Cloud Functionsではデフォルトでindex.jsからエクスポートされた関数をrequire()するらしい。(このへんよくわからないがデフォルトで、というよりpackage.jsonmainを見に行く感じなんだと思う。)

const puppeteer = require('puppeteer')
const url = 'https://account.withings.com/connectionwou/account_login?r=https%3A%2F%2Fhealthmate.withings.com%2F'
const username = process.env.username
const password = process.env.password
const secret = process.env.secret

const updateWeight = async (weight) => {
    const opt = {
        headless: true,
        args: [
            '--no-sandbox',
            '--disable-background-networking',
            '--disable-default-apps',
            '--disable-extensions',
            '--disable-gpu',
            '--disable-sync',
            '--disable-translate',
            '--hide-scrollbars',
            '--metrics-recording-only',
            '--mute-audio',
            '--no-first-run',
            '--safebrowsing-disable-auto-update',
        ],
    }

    const browser = await puppeteer.launch(opt)
    const page = await browser.newPage()

    await page.goto(url, {
        waitUntil: 'networkidle2',
    })

    // Login
    await page.click('body > div.cookieBar.active > div > button')
    await page.type('#signin > div > div.col-xs-12.col-sm-6.col-md-4.col-lg-3.sidebar > div.contentForm > form > div:nth-child(1) > input', username)
    await page.type('#signin > div > div.col-xs-12.col-sm-6.col-md-4.col-lg-3.sidebar > div.contentForm > form > div:nth-child(2) > input', password)
    await page.click('#signin > div > div.col-xs-12.col-sm-6.col-md-4.col-lg-3.sidebar > div.contentForm > form > div.createButton > button')

    // Open dialog to input weight log
    await page.waitFor('#timeline-content > div > div.addbutton.menu > div:nth-child(1)')
    await page.click('#timeline-content > div > div.addbutton.menu > div:nth-child(1)')
    await page.waitFor('#timeline-content > div > div.addbutton.menu.active > ul > li:nth-child(5) > div.icon')
    await page.click('#timeline-content > div > div.addbutton.menu.active > ul > li:nth-child(5) > div.icon')

    // Input weight and save
    await page.waitFor('#weight-add > form > div.weight-and-mass > div:nth-child(1) > div.value.focus-weight > input')
    await page.type('#weight-add > form > div.weight-and-mass > div:nth-child(1) > div.value.focus-weight > input', weight)
    await page.click('#sidepanel-wrapper-header > div > ul > li > span')

    // Wait for update to be sure
    await page.waitFor(1000)

    await browser.close()
}

/**
 * Responds to any HTTP request.
 *
 * @param {!express:Request} req HTTP request context.
 * @param {!express:Response} res HTTP response context.
 */
exports.main = (req, res) => {
    if (req.headers['x-secret'] !== secret) {
        res.status(403).send('forbidden')
    }

    let weight = req.query.w || null
    if (weight === null) {
        res.status(400).send('bad request')
    }

    updateWeight(weight).then(() => {
        res.status(200).send('ok')
    }).catch(() => {
        res.status(500).send('internal server error')
    })
}

特に何も見ずに書き始めたのでいろいろとつまづいたが、root権限で動かすらしく--no-sandboxが必須なところがポイントかもしれない。一応あとで確認したらここにも書いてあった。

簡易認証について

そこそこ実行時間を食うのもあってx-secretヘッダの値で簡易的に認証をおこなっている。ここでコケれば即落ちるので仮にイタズラされてもそこまでやばくはならないはず。あと普通に好き勝手に体重書き足されるのも困るのでこれは必要。理想をいえばCloud Functions for FirebaseでFirebaseへのアクションを起点にするとか、Cloud Pub/Subへのpublishを起点にするとかしたいところが、今回はiOSのショートカットから動かす必要があって、SSH経由でのスクリプト実行が面倒だったので簡単に実装してみた。個人でさくっと作る分には十分だと思う。

x-secretヘッダの値は適当な乱数値を適当な長さで作って、先の手順でCloud Functionsの関数の環境変数に仕込んである。もう1箇所、iOSのショートカットからHTTPのリクエストを送る際にも、HTTPリクエストヘッダに仕込むようにしてある。iOSのショートカットでLastPassとか1Passwordから値を取得できたら最高だなーと作りながら思った。

cloudbuild.yamlをつくる

Cloud Buildの設定の通り、/cloudbuild.yamlの中身を見ながらビルドがおこなわれる。あんまよくわかっていないがgcloudコマンドを使ってCloud Storageに展開したリポジトリの内容をCloud Functionsにデプロイする、みたいな感じ(だと思う)。先のサイトを参考にこんなふうにした。

steps:
  - name: 'gcr.io/cloud-builders/gcloud'
    args:
    - beta
    - functions
    - deploy
    - hogefuga # Cloud Functionsの関数名
    - --stage-bucket=hogefuga-src # Cloud Storageのバケット名

ここまでやって、masterにpushすると自動でCloud Functionsにデプロイされる。デプロイはけっこう時間がかかるのと、コンソール上完了していても実際に動いているコードは別のもの、というのがけっこうあってこの辺のタイムラグは悩ましい感じだった。

iOSのショートカットをつくる

書くの飽きてきたので省略。サンプルは共有してあるので見てもらえればわかるはず。以上。

まとめ

PuppeteerがCloud Functionsで動くのは本当に便利で、そのうち1000本ノックとかもこの辺を活用して楽したいところ。次に書く記事はFirebaseで1000本ノックのPoCをやってみた記事になる予定。