Lambda + puppeteer でサーバーレススクレイピング

# はじめに

node.jsもLambdaも使ったことなかったけど、Headless Chromeを使ってスクレイピングする必要が発生したので、両方に初挑戦してみました

# 参考にしたURL

https://qiita.com/sho7/items/6651a07971bf07dea919
https://qiita.com/chimame/items/04c9b45d8467cf32892f
https://www.sambaiz.net/article/132/

# sampleを動かすまで

### run on local

初心者すぎて[Startar Kit](https://github.com/sambaiz/puppeteer-lambda-starter-kit)に書いてあることが理解できないけど、その通りやってみました

ただし、初めてのことが多すぎるので、今回はServerless Frameworkは使わないことにします

```terminal
$ git clone -o starter-kit https://github.com/sambaiz/puppeteer-lambda-starter-kit.git
$ cd puppeteer-lambda-starter-kit
```

続けて`npm run local`したらエラーになりました。`npm install`の実行が必要でした

```terminal
$ npm install
（略）
$ npm run local

> puppeteer-lambda-starter-kit@1.1.2 local /Users/xxx/git/test
> npm run babel && cp -r node_modules dist && node dist/starter-kit/local.js

> puppeteer-lambda-starter-kit@1.1.2 babel /Users/xxx/git/test
> rm -rf dist && mkdir dist && ./node_modules/.bin/babel src --out-dir dist

src/index.js -> dist/index.js
src/starter-kit/config.js -> dist/starter-kit/config.js
src/starter-kit/local.js -> dist/starter-kit/local.js
src/starter-kit/setup.js -> dist/starter-kit/setup.js

（中略）
done
```

### run on Lambda

local動いたので、これをLambdaで動かします

#### 1. roleの作成

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3-example-create-iam-role.html
を参考に、Lambdaを実行できて、S3を読み書きできる`lambda-s3-execution-role`を作成する

#### 2. Lambda関数の作成

- 関数名：puppeteer-test
- ランタイム：Node.js 8.10
- ロール：lambda-s3-execution-role

でLambda関数を作成する
画面はそのまま

#### 3. packageの作成

サンプルそのままだと、clickがうまく動作しないので修正する

```diff
$ git diff
diff --git a/src/index.js b/src/index.js
index d77ebe1..915d41d 100755
--- a/src/index.js
+++ b/src/index.js
@@ -27,7 +27,10 @@ exports.run = async (browser) => {
     // avoid to
     // 'Cannot find context with specified id undefined' for localStorage
     page.waitForNavigation(),
-    page.click('[name=btnK]'),
+    // page.click('[name=btnK]'),
+    page.evaluate(() => {
+      document.querySelector('input[name=btnK]').click();
+    }),
   ]);

 /* screenshot
```

```
$ npm run package-nochrome

> puppeteer-lambda-starter-kit@1.1.2 package-nochrome /Users/xxx/git/test
> npm run package-prepare && cd dist && zip -rq ../package.zip .

> puppeteer-lambda-starter-kit@1.1.2 package-prepare /Users/xxx/git/test
> npm run lint && npm run babel && cp -r package.json dist && cd dist && PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm install --production

> puppeteer-lambda-starter-kit@1.1.2 lint /Users/xxx/git/test
> eslint src

> puppeteer-lambda-starter-kit@1.1.2 babel /Users/xxx/git/test
> rm -rf dist && mkdir dist && ./node_modules/.bin/babel src --out-dir dist

src/index.js -> dist/index.js
src/starter-kit/config.js -> dist/starter-kit/config.js
src/starter-kit/local.js -> dist/starter-kit/local.js
src/starter-kit/setup.js -> dist/starter-kit/setup.js

> puppeteer@1.10.0 install /Users/xxx/git/test/dist/node_modules/puppeteer
> node install.js

**INFO** Skipping Chromium download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" environment variable was found.
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN puppeteer-lambda-starter-kit@1.1.2 No repository field.
npm WARN puppeteer-lambda-starter-kit@1.1.2 No license field.

added 48 packages in 2.296s
```

生成されたheadless_shell.tar.gzとpackage.zipをS3に置く

```
$ aws s3 cp chrome/headless_shell.tar.gz s3://s3-bucket-name/
upload: ./headless_shell.tar.gz to s3://s3-bucket-name/headless_shell.tar.gz
$ aws s3 cp package.zip s3://s3-bucket-name/
upload: ./package.zip to s3://s3-bucket-name/package.zip
```

#### 4. Lambda関数の修正

コードエントリタイプを「Amazon S3からのファイルのアップロード」を選んで、URLに`s3://s3-bucket-name/package.zip`と入力し、「保存」

環境変数欄で

- キー：CHROME_BUCKET
- 値：s3-bucket-name

と入力

基本設定欄で

- メモリ：384MB
- タイムアウト：3分

として、再度「保存」

#### 5. テスト実行

「テスト」を押すとテストイベントの設定が出てくるので

- イベント名：test
- 内容：`{}`

として、「作成」。
もう一度「テスト」を押すと、Lambda関数の実行され、成功と表示されました。

### わかったこと

- Lambdaで実行すると、exports.handlerが実行される
- `npm run local`すると、src/starter-kit/local.jsからexports.handlerが呼ばれる。ブラウザは普通のChromeが使われる
- Lambda関数をローカルで実行するには、[こんな感じで](https://qiita.com/imaifactory/items/092e32b4fc0f69a94061)起動用のjsを作るのが主流っぽいけど、local.jsがそれをやってくれる
- ただし、`npm run local`はやたら時間がかかるので、通常は`npm src/starter-kit/local.js`の方が良い

# 実際のスクレイピング処理を開発する

## 要件

- ログインが必要なページをスクレイピング
- ログインに成功した場合、CookieをS3に保存しておいて次回以降はそれを使う
- 前回のスクレイピングと変更があったらChatworkに通知

## コード

### chatworkに通知する用のJS

httpsを使用するサンプルが多いですが、Node.js 8.10だと動作しない？
https://qiita.com/tenbo07/items/5c7da35c7d0984b5350a を参考にrequestを使います
`npm install request-promise-native`が必要です

```javascript:src/chatwork.js
const request = require('request-promise-native');

exports.post = async (params) => {
  let options = {
    url: 'https://api.chatwork.com/v2/rooms/' + params['roomId'] + '/messages',
    headers: {
      'X-ChatWorkToken': params['token'],
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    form: {body: params['message']},
    useQuerystring: true,
  };

  await request.post(options)
    .then((body) => {
      console.log('chatwork response: ' + body);
    })
    .catch((error) => {
      console.log('chatwork error: ' + error);
    });
};
```

### S3を読み書きする用のJS

```javascript:src/s3.js
const aws = require('aws-sdk');
const s3 = new aws.S3({apiVersion: '2006-03-01'});

exports.get = async (key) => {
  let params = {
    // headless_shell.tar.gzの置き場と同じものを使う
    Bucket: process.env.CHROME_BUCKET,
    Key: key,
  };
  try {
    const response = await s3.getObject(params).promise();
    return JSON.parse(response.Body.toString());
  } catch (e) {
    console.log('get error: ' + e);
    return [];
  }
};

exports.put = async (key, body) => {
  let params = {
    Bucket: process.env.CHROME_BUCKET,
    Key: key,
    Body: body,
  };
  await s3.putObject(params, (err, data) => {
    if (err) {
      console.log('put error: ', err);
    }
  });
};
```

### index.js

#### cookieを使ってアクセスする処理

```javascript:src/index.js
  let isLogin = false;
  const cookies = await s3.get('cookie.json');
  for (let cookie of cookies) {
    await page.setCookie(cookie);
  }
  await page.goto('https://example.com/',
    {waitUntil: ['domcontentloaded', 'networkidle0']}
  );
  // #passwordがあればログインできてない
  if (await page.$('#password')) {
    isLogin = false;
  } else {
    isLogin = true;
  }
```

#### ログインする処理

```javascript:src/index.js
  if (!isLogin) {
    await page.goto('https://example.com/login',
      {waitUntil: ['domcontentloaded', 'networkidle0']}
    );
    await page.type('input[name="id"]', process.env.LOGIN_ID);
    await page.type('input[name="password"]', process.env.LOGIN_PW);
    await page.evaluate(() => {
      document.querySelector('input[type="submit"]').click();
    });
    const cookies = await page.cookies();
    console.log('cookie.json', JSON.stringify(cookies));
    // cookieを保存する
    await s3.put('cookie.json', JSON.stringify(cookies));
    await page.waitForNavigation();
  }
```

#### 前回のコンテンツと比較する処理

https://qiita.com/go_sagawa/items/85f97deab7ccfdce53ea を参考にしました

```javascript:src/index.js
  const saved = await s3.get('contents.json');
  console.log('saved.json', saved);
  const rows = await page.$$('table > tbody > tr');
  const contents = [];
  for (const row of rows) {
    const timeCol = await row.$('td > time');
    const titleDiv = await row.$('td > div.titles > a');
    const data = {
      time: await (await timeCol.getProperty('textContent')).jsonValue(),
      title: await (await titleDiv.getProperty('textContent')).jsonValue(),
      href: await (await titleDiv.getProperty('href')).jsonValue(),
    };
    contents.push(data);
  }
  console.log('contents.json', JSON.stringify(contents));
  // コンテンツを保存する
  await s3.put('contents.json', JSON.stringify(contents));
```

#### 差があったらchatworkで通知する処理

```javascript:src/index.js
  if (差があるか確認する処理) {
    const chatwork = require('./chatwork');
    await chatwork.post({
      roomId: process.env.ROOMID,
      token: process.env.TOKEN,
      message: contents[0]['title'] + '\n' + contents[0]['href'],
    });
  }
```

### local.js

index.js内でprocess.envを使っているので、localでも取得できるようlocal.jsで設定してあげる

```javascript:src/starterk-kit/local.js

    process.env['CHROME_BUCKET'] = 's3-bucket-name';
    process.env['ROOMID'] = '12345678901234567890';
    process.env['TOKEN'] = 'hogehoge';
    process.env['LOGIN_ID'] = 'foo';
    process.env['LOGIN_PW'] = 'bar';
```

# CloudWatch Eventsから定期実行

CloudWatch Eventsで`every-5-minutes`というイベントを作り、5分おきに`puppeteer-test`が起動されるようにすれば、サーバーレススクレイピングの完成です

## callbackWaitsForEmptyEventLoop

starter-kitのindex.jsはメインロジックが

```javascript
exports.handler = async (event, context, callback) => {
  // For keeping the browser launch
  context.callbackWaitsForEmptyEventLoop = false;
  const browser = await setup.getBrowser();
  try {
    const result = await exports.run(browser);
    callback(null, result);
  } catch (e) {
    callback(e);
  }
};
```
となっていました。
この

```javascript
context.callbackWaitsForEmptyEventLoop = false;
```
を生かしたままだと、コンテンツに差があった場合もすぐには通知されず、次のLambda関数が実行されるときに通知されるという事態になりました。
ログは以下のようになっています。

```
16:09:26 2018-11-16T07:09:26.975Z	869de8a0-e96e-11e8-9c1c-2f4042d7d318	saved.json [ 〜
16:09:27 2018-11-16T07:09:27.365Z	869de8a0-e96e-11e8-9c1c-2f4042d7d318	topics.json [ 〜
16:09:28 2018-11-16T07:09:28.165Z	869de8a0-e96e-11e8-9c1c-2f4042d7d318	changed! 
16:09:28 END RequestId: 869de8a0-e96e-11e8-9c1c-2f4042d7d318
16:09:28 REPORT RequestId: 869de8a0-e96e-11e8-9c1c-2f4042d7d318	Duration: 13199.85 ms	Billed Duration: 13200 ms Memory Size: 384 MB	Max Memory Used: 308 MB
16:14:15 START RequestId: ce1cacdf-e96e-11e8-bde0-0573fa5da5b8 Version: $LATEST
16:14:15 2018-11-16T07:14:15.467Z	ce1cacdf-e96e-11e8-bde0-0573fa5da5b8	chatwork response: {"message_id":"xxxxxxxxxx"}
```
`changed!`が記録され、すぐにchatwork通知して欲しいのに、一度関数が終わり、次の実行サイクル時にchatwork通知がされています。
5分おきに実行だと、コンテンツに差があっても通知されるまで5分待たされることになります。
callbackWaitsForEmptyEventLoopをtrueにしたら、この問題は解決しました。
理由はよくわかってません。