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

# はじめに
AWSLambdaで運行遅延情報をslackに通知するbotを作りました。
[運行遅延API](https://rti-giken.jp/fhc/api/train_tetsudo/)を利用して、遅延が発生して入れば運行会社のWEBから遅延内容をスクレイピングしてSlackにお知らせします。
何番煎じか分かりませんが、lambdaとnodejsで書かれた記事が見当たらなかったので紹介します。

# 作ったもの
![スクリーンショット 2020-01-09 16.40.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104642/761b116f-5f6f-2058-9d13-3e181d417193.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

# 事前準備
- AWSアカウント
- SlackのWebhook URLの取得
- AWSCLIの導入 【[真っさらな状態のMACにAWSCLIをインストールするまで](https://qiita.com/t-yasukawa/items/fdd7b0df0b01647143ee)】
- 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"`

```bash
$ 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に送信

```javascript: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)
}
```

```yaml: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: 

```bash
$ 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` を使うことにしました。

```bash
$ 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` を変える必要があります。

```diff
browser = await chromium.puppeteer.launch({
    args: chromium.args,
    defaultViewport: chromium.defaultViewport,
-    executablePath: await chromium.executablePath,
+    executablePath: null,
    headless: chromium.headless
    })
```
もしくは上記でDLしたchromiumのパスでも行けると思います。

```javascript
executablePath: `${process.cwd()}/node_modules/puppeteer/.local-chromium/mac-706915/chrome-mac/Chromium.app/Contents/MacOS/Chromium`
```

# 5.動作確認（解決）

```bash
$ sls invoke local --function sendTrainDelayToSlack
遅延情報が見つかりました。
*・JR東日本 <東北本線>* (<https://traininfo.jreast.co.jp/train_info/tohoku.aspx|jump>)
 東北本線は、釜石線内でのシカと衝突の影響で、盛岡～花巻駅間の上下線で一部列車が運休となっています。
```
無事スクレイピングできました。・・・シカさん :scream_cat: 

# 6.解説
API関連は `axios` を使いました。とてもシンプルで使いやすい！

```javascript
const res = await axios.get(delay_url)

const res = await axios.post(slack_url, payload)
```

遅延情報はJR東日本と仙台市地下鉄の２サイトからスクレイピングしました。
地下鉄はノード指定ですんなり取得できましたが、JRの方はノード取得に癖があったので力技でした。

スクレイピングのやり方ですが簡単に取得できます。(chromeの場合)
デベロッパーツールを開く（検証モード） → 指定ノードの箇所で右クリック → Copy → Copy selector

![スクリーンショット 2020-01-10 11.57.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104642/fa75a07d-955d-accd-2dda-45aceb505004.png)

コンソール上で以下を入力することでも確認できます。
`document.querySelector('{copyしたノード}').textContent`

jsなのでそのままコードで使えますが、今回は `puppeteer` の用意したものを使います。

```javascript
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に含めます。

```bash
$ rm -rf node_modules
$ npm i --production
```

`chrome-aws-lambda` もそこそこの容量なのでそのまま入れずzipに固めてLambda Layerに格納させます。
他のLambdaでスクレイピングしたい時はこのLayerが汎用的に使えて便利です。
[README](https://github.com/alixaxel/chrome-aws-lambda#aws-lambda-layer)にしたがってzipに圧縮します。
パーミッションも変えないとデプロイできなかったので適宜変えてください。

```bash
$ 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で固める前にさらに不要なファイルを除外します。

```yaml: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)`

```bash
$ 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に通知してみた](https://dev.classmethod.jp/cloud/aws/notify-slack-train-delay/)
- [VSCodeでServerless FrameworkのAWS Lambda(TypeScript)をデバッグする]
(https://dev.classmethod.jp/cloud/vscode-typescript-debug/)