Help us understand the problem. What is going on with this article?

ServerlessFramework+Slackで運行遅延情報をお知らせする

はじめに

AWSLambdaで運行遅延情報をslackに通知するbotを作りました。
運行遅延APIを利用して、遅延が発生して入れば運行会社のWEBから遅延内容をスクレイピングしてSlackにお知らせします。
何番煎じか分かりませんが、lambdaとnodejsで書かれた記事が見当たらなかったので紹介します。

作ったもの

スクリーンショット 2020-01-09 16.40.55.png

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

事前準備

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に送信
handler.js
'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)
}
serverless.yml
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が起動できないとエラーになりました。 :weary:

$ 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-corechrome-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>)
 東北本線は、釜石線内でのシカと衝突の影響で、盛岡~花巻駅間の上下線で一部列車が運休となっています。

無事スクレイピングできました。・・・シカさん :scream_cat:

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

スクリーンショット 2020-01-10 11.57.13.png

コンソール上で以下を入力することでも確認できます。
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で固める前にさらに不要なファイルを除外します。

serverless.yml
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.

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした