4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-11-12

はじめに

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に書いてあることが理解できないけど、その通りやってみました

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

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

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

$ 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がうまく動作しないので修正する

$ 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関数をローカルで実行するには、こんな感じで起動用の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が必要です

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

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を使ってアクセスする処理

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;
  }

ログインする処理

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 を参考にしました

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で通知する処理

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で設定してあげる

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はメインロジックが

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);
  }
};

となっていました。
この

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にしたら、この問題は解決しました。
理由はよくわかってません。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?