Life is Like a Boat

忘備録や経済、投資、プログラミングに関するメモやtipsなど

Heroku + Puppeteer + SendGridでスクレイピングした結果を自分のメアドに送る

ネタはなんでもいいのですが、日々更新されるサイトのキャプチャを取ってメールで送って欲しいことがあると思います。

例えば、

  1. 日経平均の業種別騰落率がヒートマップになっている画像を取得
  2. 騰落率のベスト・ワースト3をサマリーとして記載
  3. 自分のメールアドレスに送信

と、下記の画像のようなことを実現したいです。

f:id:nerimplo:20181103164628j:plain

1は 日経平均採用銘柄の株価一覧 :株式 :マーケット :日経電子版 のヒートマップ画像です。

方法としては

  1. Puppeteerで上記URLにアクセス。
  2. 画像部分の要素をスクショ
  3. 添付ファイルとする
  4. SendGrid経由で自分のメアドに送信

となります。

ローカルでサクサクっとメール受信までできたので、HerokuにDeployするのは楽勝だろうと思っていましたが、下記の点でハマりました。

HerokuにPuppeteerのBuildpackを充てる

$ heroku buildpacks:add buildpack_nameのコマンドでnodejs, puppeteer, 日本語対応用のパックのbuildpackをインストールします。 Puppeteer buildpackの作者(@jontewks)によるとこの順番が問題になるケースもあるようです。理由はよくわからないが私の環境ではこうなってます。

=== YOUR_APP_NAME Buildpack URLs
1. heroku/nodejs
2. jontewks/puppeteer
3. https://github.com/CoffeeAndCode/puppeteer-heroku-buildpack.git

Heroku Appの環境は以下の通りです。

$ heroku apps:info YOUR_APP_NAME 
=== YOUR_APP_NAME
Auto Cert Mgmt: false
Dynos:          web: 1
Git URL:        https://git.heroku.com/YOUR_APP_NAME.git
Owner:          YOUR_APP_NAME@hogehoge.com
Region:         us
Repo Size:      56 KB
Slug Size:      231 MB
Stack:          heroku-18
Web URL:        https://YOUR_APP_NAME.herokuapp.com/

この後、heroku run node your_puppeteer_app.jsするとエラーが….

Most likely you need to configure your SUID sandbox correctly SUIDってなんやねん….

2018-11-03T06:03:03.423619+00:00 app[web.1]: (node:4) UnhandledPromiseRejectionWarning: Error: Failed to launch chrome!
2018-11-03T06:03:03.423637+00:00 app[web.1]: 
2018-11-03T06:03:03.423639+00:00 app[web.1]: (chrome:21): Gtk-WARNING **: 06:03:03.390: cannot open display:
2018-11-03T06:03:03.423641+00:00 app[web.1]: [1103/060303.407764:ERROR:nacl_helper_linux.cc(310)] NaCl helper process running without a sandbox!
2018-11-03T06:03:03.423643+00:00 app[web.1]: Most likely you need to configure your SUID sandbox correctly
2018-11-03T06:03:03.423645+00:00 app[web.1]: 
2018-11-03T06:03:03.423646+00:00 app[web.1]: 
2018-11-03T06:03:03.423648+00:00 app[web.1]: TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md
2018-11-03T06:03:03.423649+00:00 app[web.1]: 
2018-11-03T06:03:03.423651+00:00 app[web.1]: at onClose (/app/node_modules/puppeteer/lib/Launcher.js:342:14)
2018-11-03T06:03:03.423652+00:00 app[web.1]: at Interface.helper.addEventListener (/app/node_modules/puppeteer/lib/Launcher.js:331:50)
2018-11-03T06:03:03.423654+00:00 app[web.1]: at Interface.emit (events.js:187:15)
2018-11-03T06:03:03.423656+00:00 app[web.1]: at Interface.close (readline.js:379:8)
2018-11-03T06:03:03.423657+00:00 app[web.1]: at Socket.onend (readline.js:157:10)
2018-11-03T06:03:03.423659+00:00 app[web.1]: at Socket.emit (events.js:187:15)
2018-11-03T06:03:03.423660+00:00 app[web.1]: at endReadableNT (_stream_readable.js:1094:12)
2018-11-03T06:03:03.423662+00:00 app[web.1]: at process._tickCallback (internal/process/next_tick.js:63:19)

どうやらpuppeteer起動時の引数が問題なようです。

puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});

この、'--disable-setuid-sandbox’を除いてみるとワークしました!

上記問題の解決に参考にしたリンク

Heroku-16 Failed to launch chrome! error while loading shared libraries: libpng16.so.16 · Issue #14 · jontewks/puppeteer-heroku-buildpack · GitHub

Failed to launch chrome · Issue #807 · GoogleChrome/puppeteer · GitHub

SendGridを使って自分のメアドに送る

このブログが参考になりました。 やはり頼りになるのは公式ブログ。

sendgrid.com

肝となるのは、Puppeteerで取得した画像ファイルをbase64でエンコードしてattachmentsに渡す部分だと思います。 attachmentを複数形にしていなくて30分くらい公式のリファレンスとにらめっこしてました。

こんな感じで送信する内容を作ります。

const msg = {
        to: 'YOUR_EMAIL_ADDRESS',
        from: 'test@example.com',
        subject: `${png_file}`,
        html: `<html><body><pre>${emailBody}</pre><img src="cid:myimagecid"/></body></html>`,
        attachments: [
            {
                filename: png_file,
                contenType: 'image/png',
                content: base64str,
                content_id: 'myimagecid'
            }
        ]
    };

ソースコード

package.json

{
  "name": "nikkeiheatmap",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start" : "node run.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@sendgrid/mail": "^6.3.1",
    "moment": "^2.20.1",
    "puppeteer": "^1.4.0",
    "csv": "^3.1.0",
    "csv-writer": "^1.0.0",
    "dotenv": "^6.1.0"
  }
}

.env

SENDGRID_APIKEY=YOUR_SENDGRID_APIKEY
DYNO=false

your_puppeteer_app.js

const puppeteer = require('puppeteer');
const moment = require('moment');
const sgMail = require('@sendgrid/mail');
require('dotenv').config()
const NikkeiData = require('./nikkeidata.js');
const fs = require("fs");

const FAKE_USERAGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0.1 Safari/604.3.5';
const NIKKEI_HEATMAP_URL = 'https://www.nikkei.com/markets/kabu/nidxprice/';
const HEATMAP_ELEM = 'div#CONTENTS_MARROW div.m-article';
const LAUNCH_OPTION = process.env.DYNO ? {args: ['--no-sandbox']} : {headless: false};
const TEMP_DIR = process.env.DYNO ? '/tmp/' : ''
sgMail.setApiKey(process.env.SENDGRID_APIKEY);


async function scrape_data_table(page) {
    const data = await page.evaluate(() => {
        const d = Array.from(document.querySelectorAll('div#CONTENTS_MARROW div.m-article div.highcharts-data-labels.highcharts-series-0 div span')).map((e) => {
            return e.innerText.split('\n')
        });
        return d;
    },);
    return data;
}

function to_email_body(dx) {

    const data = dx.map((e) => {
        var d = new NikkeiData(e[0].split('(')[0], Number(e[1].replace('円', '')))
        return d
    }).sort((a, b) => (a.delta < b.delta) ? 1 : ((b.delta < a.delta) ? -1 : 0));

    // get top 3 elements
    const top3 = data.slice(0, 3)

    // get bottom 3 elements
    const bottom3 = data.slice(-3).reverse()

    return `[上昇]\n${top3.reduce((p, c) => {
        return p + '\n' + c
    })} \n\n [下落]\n${bottom3.reduce((p, c) => {
        return p + '\n' + c
    })}`
}

async function base64_encode(file) {
    var bitmap = fs.readFileSync(file);
    return new Buffer(bitmap).toString("base64");
}


(async () => {
    const browser = await puppeteer.launch(LAUNCH_OPTION);
    const page = await browser.newPage();
    await page.setUserAgent(FAKE_USERAGENT);
    await page.setViewport({width: 1000, height: 1000, deviceScaleFactor: 1});
    await page.goto(NIKKEI_HEATMAP_URL, {waitUntil: 'networkidle2'});
    await page.waitFor(1000);
    const element = await page.$(HEATMAP_ELEM);
    const png_file = await `nikkei_heatmap_${moment().format('YY-MM-DD hh:mm')}.png`
    const png_path = await `${TEMP_DIR}${png_file}`
    await element.screenshot({
        path: png_path
    });

    const data = await scrape_data_table(page);
    const emailBody = to_email_body(data);

    const base64str = await base64_encode(png_path);

    const msg = {
        to: 'YOUR_EMAIL_ADDRESS',
        from: 'test@example.com',
        subject: `${png_file}`,
        html: `<html><body><pre>${emailBody}</pre><img src="cid:myimagecid"/></body></html>`,
        attachments: [
            {
                filename: png_file,
                contenType: 'image/png',
                content: base64str,
                content_id: 'myimagecid'
            }
        ]
    };
    await sgMail.send(msg);

    await browser.close();
})();

この前新宿の本屋にいったらPuppeteer本が平積みされていました!

Puppeteer入門 スクレイピング+Web操作自動処理プログラミング

Puppeteer入門 スクレイピング+Web操作自動処理プログラミング