はじめに
AWSLambdaで運行遅延情報をslackに通知するbotを作りました。
運行遅延APIを利用して、遅延が発生して入れば運行会社のWEBから遅延内容をスクレイピングしてSlackにお知らせします。
何番煎じか分かりませんが、lambdaとnodejsで書かれた記事が見当たらなかったので紹介します。
作ったもの
GitHub
https://github.com/t-yasukawa/incoming-webhook
環境
- macOS Catalina 10.15.2
- VSCode 1.41.1
- AWS(Lambda, CloudFormation)
- ServerlessFramework 1.60
- Node.js 12.14.1
- puppeteer 2.0.0
- chrome-aws-lambda 2.0.0
事前準備
- AWSアカウント
- SlackのWebhook URLの取得
- AWSCLIの導入 【真っさらな状態のMACにAWSCLIをインストールするまで】
- Node.jsの導入(nodebrewが便利)
1. ServerlessFrameworkの導入
まずはバージョン確認
$ npm ls --depth=0 -g
/Users/t-yasukawa/.nodebrew/node/v12.14.1/lib
└── npm@6.13.4
ServerlessFrameworkを入れます。
npm i serverless -g
でも良いのですが、他のプロジェクトでも使っているので今回はプロジェクト直下におきます。
npm init
で生成される package.json
の初期値は適当に埋めてください。
$ mkdir incoming-webhook
$ cd incoming-webhook
$ npm init
package name: (incoming-webhook)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC)
$ npm i serverless
$ npm ls --depth=0
incoming-webhook@1.0.0 /Users/t-yasukawa/git/incoming-webhook
└── serverless@1.60.5
無事インストールできました。
しかしこのままでは ./node_modules/.bin/sls
と毎回打たないといけないので面倒です。
方法は色々ありますが、安直にパスを追加します。
$ echo 'export PATH=node_modules/.bin:$PATH' >> ~/.bash_profile
$ source ~/.bash_profile
$ sls -v
Framework Core: 1.60.5
Plugin: 3.2.7
SDK: 2.2.1
Components Core: 1.1.2
Components CLI: 1.4.0
これでOKです。
早速プロジェクトファイルをテンプレートから作成します。
$ sls create --template aws-nodejs --path ./
Serverless: Generating boilerplate...
Serverless Error ---------------------------------------
The directory "/Users/t-yasukawa/git/incoming-webhook/" already exists, and serverless will not overwrite it. Rename or move the directory and try again if you want serverless to create it"
はい、怒られました。
プロジェクトディレクトリはこのタイミングで作らないといけないようです。
serverlessをグローバルにしなかったせいですね。仕方ないので一旦パスを変えます。
$ sls create --template aws-nodejs --path ./src/models/lambda
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/Users/t-yasukawa/git/test/src/models/lambda"
_______ __
| _ .-----.----.--.--.-----.----| .-----.-----.-----.
| |___| -__| _| | | -__| _| | -__|__ --|__ --|
|____ |_____|__| \___/|_____|__| |__|_____|_____|_____|
| | | The Serverless Application Framework
| | serverless.com, v1.60.5
-------'
Serverless: Successfully generated boilerplate for template: "aws-nodejs"
生成されたファイルを変更します。
$ mv src/models/lambda/serverless.yml ./
$ mv src/models/lambda/.gitignore ./
$ tree -a -L 1
.
├── .gitignore
├── node_modules
├── package-lock.json
├── package.json
├── serverless.yml
└── src
2.puppeteer導入
スクレイピング処理にpuppeteer
を利用しますが、このままローカルで利用することができたのですが、
いざLambda上にデプロイしようとした時にLambdaの上限250MBを超えてしまう問題が発生しました。
An error occurred: SessionLambdaFunction - Unzipped size must be smaller than 262144000 bytes.
そこで、軽量版の puppeteer-core
とAWS上でchromiumが動く chrome-aws-lambda
を入れることでこれを回避します。
(ついでにAPI取得用のaxiosも入れます。)
※versionを揃えないと実行時にエラーとなるので注意
Error: Chromium revision is not downloaded. Run "npm install" or "yarn install"
$ npm i chrome-aws-lambda puppeteer-core axios
$ npm ls --depth=0
incoming-webhook@1.0.0 incoming-webhook
├── axios@0.19.1
├── chrome-aws-lambda@2.0.0
├── puppeteer-core@2.0.0
└── serverless@1.60.5
3.実装
- 遅延情報APIで遅延情報を取得
- お知らせしたい路線を検出
- 検出できたらWebに飛んでスクレイピング
- Slackに送信
'use strict'
const axios = require("axios")
const chromium = require("chrome-aws-lambda");
// 取得したい路線情報
const CHECK_LIST = [
{
'name': '常磐線',
'company': 'JR東日本',
'website': 'https://traininfo.jreast.co.jp/train_info/tohoku.aspx',
'selector': async (page) => await selectorForJrEast(page, '常磐線')
},
{
'name': '東北本線',
'company': 'JR東日本',
'website': 'https://traininfo.jreast.co.jp/train_info/tohoku.aspx',
'selector': async (page) => await selectorForJrEast(page, '東北本線')
},
{
'name': '仙台市営地下鉄',
'company': '仙台市交通局',
'website': 'https://www.kotsu.city.sendai.jp/unkou/',
'selector': async (page) => await selectorForSendaiSubway(page)
},
]
module.exports.sendToSlack = async () => {
// 鉄道運行遅延の情報を取得
const notifyDelays = await getNotifyDelays()
if (notifyDelays.length == 0) {
console.log('遅延情報はありませんでした。')
return;
}
console.log('遅延情報が見つかりました。')
// 遅延内容を取得
const messages = await getDelayMessage(notifyDelays)
console.log(messages.join('\n'))
// Sclackに送信
await postSlack(messages.join('\n'))
}
/**
* 遅延情報を取得
*/
async function getNotifyDelays() {
const delay_url = process.env['TRAIN_DELAY_JSON_URL']
const notifyDelays = []
try {
// 運行遅延情報を取得
const res = await axios.get(delay_url)
// res = [{
// "name":"東北本線",
// "company":"JR東日本",
// "lastupdate_gmt":1578638905,
// "source":"鉄道com RSS"
// }]
// 通知する路線のみ抽出
res.data.forEach(delayItem => {
CHECK_LIST.forEach(checkItem => {
if (delayItem.name == checkItem.name && delayItem.company == checkItem.company) {
notifyDelays.push(checkItem)
}
})
})
} catch (error) {
console.error(error)
}
return notifyDelays
}
/**
* 遅延メッセージを取得
*
* @param {Array} delays
*/
async function getDelayMessage(delays) {
const messages = [];
let browser = null
try {
browser = await chromium.puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath,
headless: chromium.headless
})
const page = await browser.newPage()
for(const i of delays) {
// websiteから遅延情報をスクレイピング
await page.goto(i.website)
const detail = await i.selector(page)
const message = `*・${i.company} \<${i.name}\>* (<${i.website}|jump>)\n ${detail}\n`
messages.push(message)
}
} catch(e) {
console.warn(e)
} finally {
if (browser !== null) {
await browser.close()
}
}
return messages;
}
/**
* JR東日本(東北エリア)の遅延内容をスクレイピング
*
* @param {Page} page Page
* @param {string} target 路線名
*/
async function selectorForJrEast(page, target) {
const selector = '#wrapper > div.main_con02 > div.table_access > table > tbody > tr'
const messages = []
try {
for (const item of await page.$$(selector)) {
const lineName = await getTextContext(item, '.line_name')
if (lineName == target) {
const message = await getTextContext(item, '.status_text')
messages.push(message)
}
}
} catch (error) {
console.error(error)
return `:warning: ノードの取得に失敗しました。DOMが変更されている可能性があります。\n \`${selector}\` `
}
return messages.join('\n')
}
/**
* 仙台市地下鉄(南北・東西)の遅延内容をスクレイピング
*
* @param {Page} page Page
*/
async function selectorForSendaiSubway(page) {
const selector = '#unkou_detail'
try {
const item = await page.$(selector)
const text = await getTextContext(item)
if (text == null) {
return `:warning: ノードの取得に失敗しました。DOMが変更されている可能性があります。\n \`${selector}'\` `
}
} catch (error) {
console.error(error)
return `:warning: ノードの取得に失敗しました。DOMが変更されている可能性があります。\n \`${selector}'\` `
}
return text
}
/**
* textContent取得
*
* @param {ElementHandle} elementHandle
* @param {string} target
*/
async function getTextContext(elementHandle, target) {
const tag = await elementHandle.$(target)
const prop = await tag.getProperty('textContent')
const text = await prop.jsonValue()
return text
}
/**
* Slackへ送信
*
* @param {string} message
*/
async function postSlack(message) {
const slack_url = process.env['SLACK_WEBHOOK_URL']
const payload = {
'username': '運行遅延お知らせbot',
'icon_emoji': ':train:',
'attachments': [
{
'fallback': message,
'color': '#36a64f',
'pretext': '<!channel> 電車の遅延があります。',
'text': message,
"mrkdwn_in": [
"text"
],
'channel': '#列車運行情報'
}
]
}
const res = await axios.post(slack_url, payload)
console.log(res)
}
service: incoming-webhook
provider:
name: aws
runtime: nodejs12.x
timeout: 300
profile: ${self:custom.profiles.${self:provider.stage}}
region: ${opt:region, self:custom.defaultRegion}
custom:
defaultRegion: ap-northeast-1
profiles:
dev: default
package:
exclude:
- node_modules/serverless/**
- node_modules/chrome-aws-lambda/**
- chrome-aws-lambda/**
functions:
sendTrainDelayToSlack:
handler: src/models/lambda/handler.sendToSlack
events:
- schedule:
# 7:15,8:15,18:15 月~金
rate: cron(15 9,22,23 ? * MON-FRI *)
layers:
- {Ref: ChromeLambdaLayer}
environment:
TRAIN_DELAY_JSON_URL: 'https://tetsudo.rti-giken.jp/free/delay.json'
SLACK_WEBHOOK_URL: 'https://hooks.slack.com/services/****************'
layers:
chrome:
package:
artifact: ./chrome-aws-lambda/chrome_aws_lambda.zip
4. 動作確認(問題発生)
動作確認のためにローカルでLambdaを実行するとchromiumが起動できないとエラーになりました。
$ sls invoke local --function sendTrainDelayToSlack
遅延情報が見つかりました。
Error: Failed to launch chrome!
/var/folders/v8/ydzbmvkj6_zbm8msr6x730nr0000gn/T/chromium: /var/folders/v8/ydzbmvkj6_zbm8msr6x730nr0000gn/T/chromium: cannot execute binary file
TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md
Mac環境でデバッグしようとするとPC内のChromeアプリのバイナリを利用しようとするのですが、
バイナリファイルのchromiumを実行することができませんでした。
Chromeとモジュールのバージョンがリビジョン単位で違うけど、そのせい?よくわからず。。
Chromeバージョン: 79.0.3945.117
(2020/01/10時点)
puppeteer Version | chrome-aws-lambda Version | Chromium Revision |
---|---|---|
2.0.* | npm i chrome-aws-lambda@~2.0.2 | 705776 (79.0.3945.0) |
参照: https://github.com/alixaxel/chrome-aws-lambda |
解決策
ローカルでは容量が大きいけど puppeteer
でchroniumのバイナリファイルをDLして利用し、
AWS上では puppeteer-core
と chrome-aws-lambda
を使うことにしました。
$ npm i --save-prod chrome-aws-lambda puppeteer-core
$ npm i --save-dev puppeteer
Downloading Chromium r706915 - 111.8 Mb [====================] 100% 0.0s
Chromium downloaded to /Users/t-yasukawa/git/incoming-webhook/node_modules/puppeteer/.local-chromium/mac-706915
ただ、これだけではローカルで動かないのでデバッグ中だけ executablePath
を変える必要があります。
browser = await chromium.puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
- executablePath: await chromium.executablePath,
+ executablePath: null,
headless: chromium.headless
})
もしくは上記でDLしたchromiumのパスでも行けると思います。
executablePath: `${process.cwd()}/node_modules/puppeteer/.local-chromium/mac-706915/chrome-mac/Chromium.app/Contents/MacOS/Chromium`
5.動作確認(解決)
$ sls invoke local --function sendTrainDelayToSlack
遅延情報が見つかりました。
*・JR東日本 <東北本線>* (<https://traininfo.jreast.co.jp/train_info/tohoku.aspx|jump>)
東北本線は、釜石線内でのシカと衝突の影響で、盛岡~花巻駅間の上下線で一部列車が運休となっています。
無事スクレイピングできました。・・・シカさん
6.解説
API関連は axios
を使いました。とてもシンプルで使いやすい!
const res = await axios.get(delay_url)
const res = await axios.post(slack_url, payload)
遅延情報はJR東日本と仙台市地下鉄の2サイトからスクレイピングしました。
地下鉄はノード指定ですんなり取得できましたが、JRの方はノード取得に癖があったので力技でした。
スクレイピングのやり方ですが簡単に取得できます。(chromeの場合)
デベロッパーツールを開く(検証モード) → 指定ノードの箇所で右クリック → Copy → Copy selector
コンソール上で以下を入力することでも確認できます。
document.querySelector('{copyしたノード}').textContent
jsなのでそのままコードで使えますが、今回は puppeteer
の用意したものを使います。
const browser = await chromium.puppeteer.launch()
const page = await browser.newPage()
await page.goto({webUrl})
const tag = await page.$('#wrapper > div.main_con02 > div.table_access > table > tbody > tr:nth-child(21) > td > p.status_text')
const prop = await tag.getProperty('textContent')
const text = await prop.jsonValue()
console.log(text)
// 水郡線は、台風の影響で、西金~常陸大子駅間の上下線で当面の間運転を見合わせます。同区間でバスによる代行輸送を実施します。
それにしても長い。。。
7.AWSへデプロイ
さぁ、最後はAWSへデプロイしてCloudWatchを使って定時実行させれば完成です。
Lambdaの容量をなるべく節約して使うため不要なモジュールたちを削除します。
ローカルで使っていた puppeteer
を除いた状態で再インストールします。
serverless
もLambdaには必要ないのですがデプロイコマンドで必要なのでproductionに含めます。
$ rm -rf node_modules
$ npm i --production
chrome-aws-lambda
もそこそこの容量なのでそのまま入れずzipに固めてLambda Layerに格納させます。
他のLambdaでスクレイピングしたい時はこのLayerが汎用的に使えて便利です。
READMEにしたがってzipに圧縮します。
パーミッションも変えないとデプロイできなかったので適宜変えてください。
$ git clone --depth=1 https://github.com/alixaxel/chrome-aws-lambda.git
$ cd chrome-aws-lambda
$ make chrome_aws_lambda.zip
$ chmod 777 chrome_aws_lambda.zip
Lambdaアプリをzipで固める前にさらに不要なファイルを除外します。
package:
exclude:
- node_modules/serverless/**
- node_modules/chrome-aws-lambda/**
- chrome-aws-lambda/**
layers:
chrome:
package:
artifact: ./chrome-aws-lambda/chrome_aws_lambda.zip
最後にデプロイして終了!
ちょっとしたアプリですがモジュールを入れるとそこそこのサイズになりますね。
incoming-webhook.zip file to S3 (25.49 MB)
chrome_aws_lambda.zip file to S3 (41.63 MB)
$ sls deploy --verbose --profile dev
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service incoming-webhook.zip file to S3 (25.49 MB)...
Serverless: Uploading service chrome_aws_lambda.zip file to S3 (41.63 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
CloudFormation - UPDATE_IN_PROGRESS - AWS::CloudFormation::Stack - incoming-webhook-dev
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromeLambdaLayer
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromeLambdaLayer
CloudFormation - UPDATE_COMPLETE - AWS::Lambda::LayerVersion - ChromeLambdaLayer
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::Function - SendTrainDelayToSlackLambdaFunction
CloudFormation - UPDATE_COMPLETE - AWS::Lambda::Function - SendTrainDelayToSlackLambdaFunction
CloudFormation - UPDATE_COMPLETE_CLEANUP_IN_PROGRESS - AWS::CloudFormation::Stack - incoming-webhook-dev
CloudFormation - DELETE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromeLambdaLayer
CloudFormation - DELETE_COMPLETE - AWS::Lambda::LayerVersion - ChromeLambdaLayer
CloudFormation - UPDATE_COMPLETE - AWS::CloudFormation::Stack - incoming-webhook-dev
Serverless: Stack update finished...
Service Information
service: incoming-webhook
stage: dev
region: ap-northeast-1
stack: incoming-webhook-dev
resources: 7
api keys:
None
endpoints:
None
functions:
sendTrainDelayToSlack: incoming-webhook-dev-sendTrainDelayToSlack
layers:
chrome: arn:aws:lambda:ap-northeast-1:*:layer:chrome:9
Stack Outputs
SendTrainDelayToSlackLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:*:function:incoming-webhook-dev-sendTrainDelayToSlack:17
ChromeLambdaLayerQualifiedArn: arn:aws:lambda:ap-northeast-1:*:layer:chrome:9
ServerlessDeploymentBucketName: incoming-webhook-dev-serverlessdeploymentbucket-*
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.
参考
- 電車の運行情報(遅延・運転見合・運休など)を毎朝Slackに通知してみた
- [VSCodeでServerless FrameworkのAWS Lambda(TypeScript)をデバッグする]
(https://dev.classmethod.jp/cloud/vscode-typescript-debug/)