サイボウズOfficeのワークフローをPuppeteerでSlackに通知してみた

# はじめに
この記事は [株式会社ピーアールオー（あったらいいな！を作ります） Advent Calendar 2019](https://qiita.com/advent-calendar/2019/pro-japan1) の9日目の記事です。
今回は最近困っていることを解決してみようと思います。

# サイボウズOfficeのワークフローにすぐ気づけない...

[サイボウズOffice](https://products.cybozu.co.jp/office/)でワークフローなどの申請が来た場合、すぐに気づける方法はいくつかあります。

- ブラウザ開いてのホーム画面で確認
- パソコン用リマインダーツールを使う([Cybozu Desktop](https://cybozu.co.jp/info/desktop2/))
- メールを確認する

が、私は基本vscodeとslackばかり見ているので、すぐに気づかずSlackで突っ込まれることが多々あります...
なので、直ぐに気づくことができるよう、ワークフローの申請があった際にSlackに通知するツールを作ってみようと思います。

# どうやって作る？

サイボウズOfficeのAPIを叩いてワークフローの状況を定期的に参照してSlackに通知しよう！
と、思ったのですが、私が調べた限りではサイボウズOfficeはAPI使えないみたいです。
([kintone](https://kintone.cybozu.co.jp/)や[garoon](https://garoon.cybozu.co.jp/)はAPI使えるようです。)

なので、ヘッドレスでブラウザを操作できる[Puppeteer](https://pptr.dev/)を使って、ワークフローの状況を参照、slackに通知していきたいと思います。

# 処理の流れ
1. dockerでPuppeteer用の環境構築 & 監視用スクリプト稼働
2. サイボウズOfficeへアクセスして、ワークフローの状況を取得
3. docker(Puppeteer)から、SlackのIncoming Webhookにリクエストを送信
4. Incoming Webhook がSlackへ通知

# dockerでPuppeteer環境を作る

dockerで開発環境を作っていきます。
まずは、dockerfile。[Puppeteer Troubleshooting](https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker)のdockerのコピペです。

```docker
FROM node:10-slim

# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer
# installs, work.
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

# If running Docker >= 1.13.0 use docker run's --init arg to reap zombie processes, otherwise
# uncomment the following lines to have `dumb-init` as PID 1
# ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init
# RUN chmod +x /usr/local/bin/dumb-init
# ENTRYPOINT ["dumb-init", "--"]

# Uncomment to skip the chromium download when installing puppeteer. If you do,
# you'll need to launch puppeteer with:
#     browser.launch({executablePath: 'google-chrome-unstable'})
# ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true

# Install puppeteer so it's available in the container.
RUN npm i puppeteer \
    # Add user so we don't need --no-sandbox.
    # same layer as npm install to keep re-chowned files from using up several hundred MBs more space
    && groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
    && mkdir -p /home/pptruser/Downloads \
    && chown -R pptruser:pptruser /home/pptruser \
    && chown -R pptruser:pptruser /node_modules

# Run everything after as non-privileged user.
USER pptruser

# CMD ["google-chrome-unstable"]”

```

次はdocker-compose。
先ほどのdockerfileでコンテナを参照。
ワークフロー監視用のjsを共有後、homeディレクトリにコピーして実行するようにしています。
(共有ディレクトリだと上手く動かなかったので。)

参照先のサイボウズの設定なども環境変数に持たせて変更できるようにしました。
参照先は[サイボウズOfficeデモサイト](https://onlinedemo.cybozu.info/scripts/office10/ag.cgi?)。
監視間隔5秒で設定しています。

デモサイトなので、パスワードなし。
ログインIDはchromeデベロッパーツールでログインユーザのセレクトボックス(option値)を参照して記載しています。
Basic認証の設定も入れました。処理上は作成しますが、今回はデモサイトで認証がないので適当な値を入れます。

```yaml
version: '3.3'
services:
  puppeteer:
    build:
      context: ./
      dockerfile: Dockerfile
    volumes:
      - ./checkWorkflow.js:/opt/checkWorkflow.js
    environment:
      - CYBOZU_URL=https://onlinedemo.cybozu.info/scripts/office10/ag.cgi?
      - IS_BASIC=0
      - BASIC_ID=xxxx
      - BASIC_PW=xxxx
      - LOGIN_ID=17
      - LOGIN_PW=
      - SLACK_CHANNEL=@xxxxxxx
      - SLACK_ENTRY_POINT=/services/xxxxx/xxxxx/xxxxxxxxxxxxxxxxx
      - LOOP_TIME=5000
    command: sh -c "cp /opt/checkWorkflow.js /home/pptruser/. && node /home/pptruser/checkWorkflow.js"
    tty: true

```

**環境変数一覧**

|環境変数|内容|
|---|---|
|CYBOZU_URL|サイボウズのURL|
|IS_BASIC|Basic認証有無(0:なし、1:あり)|
|BASIC_ID|Basic認証 ID|
|BASIC_PW|Basic認証 Password|
|LOGIN_ID|サイボウズログインID|
|LOGIN_PW|サイボウズログインPW|
|SLACK_CHANNEL|検知時送信先 Slackチャンネル|
|SLACK_ENTRY_POINT|検知時送信先 Slack送信先URL(パス)|
|LOOP_TIME|監視間隔|

開発自体はcommandをコメントアウトして、VSリモートで作成したコンテナにアクセスして開発しました。

# Puppeteerで申請状況を取得する

## ログイン

docker-composeで設定した環境変数を利用してBasic認証とログインを行います。
「page.select」と「page.type」でログインフォームにログインユーザとパスワードを設定。
「document.querySelector('input[name="Submit"]').click()」でログインボタンを押します。

```javascript
  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
  const page = await browser.newPage();

  // Basic認証
  if (IS_BASIC === '1')
    await page.authenticate({ username: BASIC_ID, password: BASIC_PW });

  // サイボウズアクセス
  await page.goto(CYBOZU_URL, {
    waitUntil: 'domcontentloaded'
  });

  // ログイン設定
  await page.select('select[name="_ID"]', LOGIN_ID);
  await page.type('input[name="Password"]', LOGIN_PW);

  //　ログイン
  await page.evaluate(() => {
    document.querySelector('input[name="Submit"]').click();
  });

  // ログイン後画面が読み込まれるまで待機
  await page.waitForNavigation({
    timeout: 30000,
    waitUntil: 'domcontentloaded'
  });
```
## ワークフロー取得

ワークフロー画面遷移後、受信一覧を参照します。
最新の申請の番号を取得し、チェック済み配列に格納します。
次回以降は申請済配列を参照し、新規番号がある場合は後続の処理（Slack通知）に繋げるようにしています。

```javascript
 // ワークフロー画面へ
  await page.goto(CYBOZU_URL + 'page=WorkFlowRecept');

  // 受信一覧取得
  const list = await page.$$('table.dataList > tbody  > tr');

  // 申請があるか判定判定
  if (list.length <= 1) {
    await browser.close();
    console.log("nothing ")
    return;
  }

  // 最新の申請取得
  const td = await list[1].$('td')
  // 申請番号取得
  const newAppNumber = (await (await td.getProperty('textContent')).jsonValue()).replace(/\r?\n/g, '');

  // ブラウザ終了
  await browser.close();

  // チェック済みの番号か判定
  if (appNumberList.indexOf(newAppNumber) >= 0) {
    console.log("checked number : " + newAppNumber);
    return;
  } else {
    appNumberList.push(newAppNumber);
    console.log("new number : " + newAppNumber);
  }
```

# Slackに通知する
Slackの[Incoming Webhook](https://slack.com/intl/ja-jp/help/articles/115005265063)を利用して通知します。
Incoming Webhooksについては、以下など、説明されている記事が沢山あると思いますので、割愛します。
参考: [SlackのIncoming Webhooksを使い倒す](https://qiita.com/ik-fib/items/b4a502d173a22b3947a0)

環境変数に設定したSlackのチャンネル、URLを設定してPOSTします。

```javascript
 let postData = {
    channel: SLACK_CHANNEL,
    username: 'work-flow-checker',
    text: '新規申請があります！',
    icon_emoji: ':ghost:'
  };
  let postDataStr = JSON.stringify(postData);

  let options = {
    host: 'hooks.slack.com',
    // port: 80,
    path: SLACK_ENTRY_POINT,
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(postDataStr)
    }
  };

  let req = http.request(options, res => {
    console.log('STATUS: ' + res.statusCode);
    console.log('HEADERS: ' + JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on('data', chunk => {
      console.log('BODY: ' + chunk);
    });
  });
  req.on('error', e => {
    console.log('problem with request: ' + e.message);
  });
  req.write(postDataStr);
  req.end();

```
# 完成

ソースが完成しました。
環境変数を変数に設定後、監視用のfunctionをsetIntervalで定期的に稼働させるようにしています。
チェック済申請番号はメモリ上に保持するので、起動毎にチェック済みの申請番号が消えますが、起動しっぱなしにするので気にしません。

```terminal:ファイル全量
.
├── Dockerfile
├── checkWorkflow.js
└── docker-compose.yml
```

```javascript:checkWorkflow.js
const puppeteer = require('puppeteer');
const http = require('https');

// 設定
const CYBOZU_URL = process.env.CYBOZU_URL
const IS_BASIC = process.env.IS_BASIC
const BASIC_ID = process.env.BASIC_ID
const BASIC_PW = process.env.BASIC_PW
const LOGIN_ID = process.env.LOGIN_ID
const LOGIN_PW = process.env.LOGIN_PW
const SLACK_CHANNEL = process.env.SLACK_CHANNEL
const SLACK_ENTRY_POINT = process.env.SLACK_ENTRY_POINT
const LOOP_TIME = process.env.LOOP_TIME // ミリ秒

// チェック済申請番号格納
var appNumberList = [];

async function checkWorkflow() {
  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
  const page = await browser.newPage();

  // Basic認証
  if (IS_BASIC === '1')
    await page.authenticate({ username: BASIC_ID, password: BASIC_PW });

  // サイボウズアクセス
  await page.goto(CYBOZU_URL, {
    waitUntil: 'domcontentloaded'
  });

  // ログイン設定
  await page.select('select[name="_ID"]', LOGIN_ID);
  await page.type('input[name="Password"]', LOGIN_PW);

  //　ログイン
  await page.evaluate(() => {
    document.querySelector('input[name="Submit"]').click();
  });

  // ログイン後画面が読み込まれるまで待機
  await page.waitForNavigation({
    timeout: 30000,
    waitUntil: 'domcontentloaded'
  });

  // ワークフロー画面へ
  await page.goto(CYBOZU_URL + 'page=WorkFlowRecept');

  // 受信一覧取得
  const list = await page.$$('table.dataList > tbody  > tr');

  // 申請があるか判定判定
  if (list.length <= 1) {
    await browser.close();
    console.log("nothing ")
    return;
  }

  // 最新の申請取得
  const td = await list[1].$('td')
  // 申請番号取得
  const newAppNumber = (await (await td.getProperty('textContent')).jsonValue()).replace(/\r?\n/g, '');

  // ブラウザ終了
  await browser.close();

  // チェック済みの番号か判定
  if (appNumberList.indexOf(newAppNumber) >= 0) {
    console.log("checked number : " + newAppNumber);
    return;
  } else {
    appNumberList.push(newAppNumber);
    console.log("new number : " + newAppNumber);
  }

  // SlackへのPOST用データ作成
  let postData = {
    channel: SLACK_CHANNEL,
    username: 'work-flow-checker',
    text: '新規申請があります！',
    icon_emoji: ':ghost:'
  };
  let postDataStr = JSON.stringify(postData);

  // POST設定
  let options = {
    host: 'hooks.slack.com',
    // port: 80,
    path: SLACK_ENTRY_POINT,
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(postDataStr)
    }
  };

  // POST
  let req = http.request(options, res => {
    console.log('STATUS: ' + res.statusCode);
    console.log('HEADERS: ' + JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on('data', chunk => {
      console.log('BODY: ' + chunk);
    });
  });
  req.on('error', e => {
    console.log('problem with request: ' + e.message);
  });
  req.write(postDataStr);
  req.end();
};

setInterval(function () {
  checkWorkflow();
}, LOOP_TIME);
```

# 動かしてみる

それでは動かしていきます！

**デモサイトの申請状況**

<img width="1354" alt="ワークフロー（受信一覧）_-_サイボウズ_Office.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/84b36df2-5afe-a690-fc11-d9263860df9c.png">


**docker起動**

```terminal
 docker-compose up
Starting cybozu-puppeteer_puppeteer_1 ... done
Attaching to cybozu-puppeteer_puppeteer_1
puppeteer_1  | new number : 11 
puppeteer_1  | STATUS: 200
puppeteer_1  | HEADERS: {"content-type":"text/html","transfer-encoding":"chunked","connection":"close","date":"Sun, 08 Dec 2019 21:07:31 GMT","server":"Apache","vary":"Accept-Encoding","strict-transport-security":"max-age=31536000; includeSubDomains; preload","referrer-policy":"no-referrer","x-frame-options":"SAMEORIGIN","access-control-allow-origin":"*","x-via":"haproxy-www-ow8r","x-cache":"Miss from cloudfront","via":"1.1 89e14ce757792ac369341dc84fa01d52.cloudfront.net (CloudFront)","x-amz-cf-pop":"NRT57-C2","x-amz-cf-id":"p0yzt6kUDK3Nk1tuxr7ojy1tWrIgTG8j1F8m2Euu0pIPnxj9kV-gug=="}
puppeteer_1  | BODY: ok
puppeteer_1  | checked number : 11 
```
new numberとして、11番の申請が検知されました。
Slackの方にも通知がきました。

<img width="754" alt="Slack___Slackbot___PRO-D-Sol_と_「サイボウズOfficeのワークフローをPuppeteerでSlackに通知してみた」を編集_-_Qiita.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/56c25eed-f94d-7b69-8548-57629bc4ba3f.png">

デモサイトで追加申請をあげてみます。
新規申請が追加されることで、再度Slackに通知が来るはずです。

**申請追加**
<img width="1344" alt="ワークフロー（受信一覧）_-_サイボウズ_Office.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/eec1353b-bfdd-4fc5-39a3-1157765df3e2.png">

Slackを確認。追加で申請がきました!

<img width="749" alt="Slack___Slackbot___PRO-D-Sol_と_「サイボウズOfficeのワークフローをPuppeteerでSlackに通知してみた」を編集_-_Qiita.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359665/c3071eaf-c1a9-ff5f-f6cd-d0438137000f.png">

# 感想
普段サイボウズの通知見逃しが多いので作ってみました。
エラーハンドリングなどあまり考慮せずバッと作ったので、エラー沢山あるかもしれません...
実際に使ってみて、少しずつ直して行く予定です。これで、申請漏れなどなくなることを祈ってます!

# 今回作成したソース
よろしければ使ってみてください。
https://github.com/hiro-kane/cybozu-check-tool

# 参考
- [PuppeteerでヘッドレスChromeを操ってみる](https://qiita.com/sand/items/b01d7d8f3d9c1642298b)
- [Puppeteer Troubleshooting](https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker)
- [puppeteerでBasic認証を突破する](https://helloworld-blog.tech/javascript/puppeteer%E3%81%A7basic%E8%AA%8D%E8%A8%BC%E3%82%92%E7%AA%81%E7%A0%B4%E3%81%99%E3%82%8B)
- [puppeteerでの要素の取得方法
](https://qiita.com/go_sagawa/items/85f97deab7ccfdce53ea)
- [Node.jsで環境変数を取得する](https://sioyaki.com/entry/2016/05/20/235357)