はじめに
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
が必要です
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
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を使ってアクセスする処理
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;
}
ログインする処理
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 を参考にしました
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で通知する処理
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で設定してあげる
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にしたら、この問題は解決しました。
理由はよくわかってません。