MFクラウドの勤怠管理をコマンドラインから操作(puppeter)

こんばんは。スマブラ発売間近で胸がワクワクmorifujiです。

最近、MFクラウドの勤怠管理を利用することになりまして、出勤時退勤時には専用webサイトにログインして、ボタンを押さないといけなくなりました。。

めんどくさいので、ヘッドレスブラウザでサクッとつくったので知見を共有します。puppeter久しぶりすぐる

## git
https://gitlab.com/morifuji/mf-auto-attendance

## 筆者環境

|環境|バージョン|
|:--|:--|
|PC|MacBook Pro (13-inch, 2016, Four Thunderbolt 3 Ports)|
|仮想環境/ローカル|ローカル環境|
|nodejs|v10.7.0|
|yarn|0.27.5|
|puppeter|1.11.0|


## ゴール
- 以下の手順をヘッドレスブラウザで実行すること
  - 1. ログインページからログイン
  - 2. 出勤ボタンまたは退勤ボタンをクリック
  - 3. 確認のダイアログに対して入力
- 出勤・退勤の２つのスクリプトを作成
- どちらも1コマンドで
- ID/PASSは外部ファイルに

## 実装

#### 準備

```
yarn init mf
yarn add puppeter
```

#### package.jsonを編集

```json
{
  "name": "mf",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "puppeteer": "^1.11.0"
  },
  "scripts": {
    "mf-in": "node ./main.js in",
    "mf-out": "node ./main.js out"
  }
}

```

`scripts`で出勤・退勤の２種類のスクリプトを叩きます

#### スクリプト
スクリプト本体作成

```js:main.js
const puppeteer = require('puppeteer');
const config = require('./config.js')

console.log("action: " + process.argv[2])

let isInAttendance  = null
switch(process.argv[2]) {
  case "in": 
    isInAttendance = true
    break;
  case "out":
    isInAttendance = false
    break;
}

if (null === isInAttendance) {
  console.error("引数に `in` または `out` を設定してください")
  return;
}

if (!config.id || !config.pass) {
  console.error("config.jsでID/パスワードを設定してください")
  return;
}

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://payroll.moneyforward.com/session/new', {waitUntil: 'networkidle2'});

  await page.type('input[id=sign_in_session_service_email]', config.id);
  await page.type('input[id=sign_in_session_service_password]', config.pass);

  const inputElement = await page.$('input[name=commit]');
  await inputElement.click();

  await page.waitFor(2000);

  page.on("dialog", (dialog) => {
    dialog.accept();
  });

  let attendanceButton = null
  if (isInAttendance) {
    attendanceButton = await page.$('.btn-attendance');
  } else {
    attendanceButton = await page.$('.btn-leaving');
  }

  await attendanceButton.click();

  // 3秒待つ
  await page.waitFor(3000);
  await browser.close();
})();
```

#### ログイン情報を設定

```js:config.js
module.exports = {
  id: "",
  pass: ""
}
```

idとpassに、自身のログイン情報を設定

## 実行

```
# 出勤
yarn mf-in

# 退勤
yarn mf-out
```

## もうちょい楽に

毎日実行することを考えるともう少し短くしたいので、エイリアスを設定

```
echo -e "alias mf-in='cd ~/mf/ && yarn mf-in'\nalias mf-out='cd ~/mf/ && yarn mf-out'" >> ~/.bashrc
```

:warning: `cd ~/mf/`の部分は、自分のプロジェクトディレクトリに書き直してください

## エイリアス実行
```
# 出勤
mf-in

# 退勤
mf-out
```

## 本体スクリプトざっくり説明

#### もろもろimport
```js
const puppeteer = require('puppeteer');
const config = require('./config.js')

...

if (!config.id || !config.pass) {
  console.error("config.jsでID/パスワードを設定してください")
  return;
}
```

ライブラリ(puppeter)をrequireして
設定ファイルから設定値を取得。取得できなかった/false評価ならエラー


#### 引数から、出勤/退勤を判定

```js

console.log("action: " + process.argv[2])

let isInAttendance  = null
switch(process.argv[2]) {
  case "in": 
    isInAttendance = true
    break;
  case "out":
    isInAttendance = false
    break;
}

if (null === isInAttendance) {
  console.error("引数に `in` または `out` を設定してください")
  return;
}
```

process.argv[2]には、 `in`または`out`が入っているので、それをもとに出勤か退勤か判定。

#### puppeterおまじない
```js
(async () => {

  ...

})();
```

async/awaitを使いたいので、asyncつけて即時関数にしてる

#### ページ表示

```js
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://payroll.moneyforward.com/session/new', {waitUntil: 'networkidle2'});

```
puppeterを起動して、ページを開いてます。
`puppeteer.launch()`の第３引数には、[様々な設定ができます](https://pptr.dev/#?product=Puppeteer&version=v1.11.0&show=api-puppeteerlaunchoptions)。例えば、`{headless: false}`とすると、ブラウザが表示された上で操作されます。デバッグに便利ですねー。

`page.goto`の第三引数のwaitUntilは、puppeterがどの時点でページ描画完了とするかの設定値です。
[ほかにも色々な設定ができます](https://pptr.dev/#?product=Puppeteer&version=v1.11.0&show=api-pagegotourl-options)

#### form入力・submit
```js

  await page.type('input[id=sign_in_session_service_email]', config.id);
  await page.type('input[id=sign_in_session_service_password]', config.pass);

  const inputElement = await page.$('input[name=commit]');
  await inputElement.click();

  await page.waitFor(2000);
```

クエリセレクタを書いて、そこに第二引数の文字を入力しています。
クリックは少しめんどくさい

waitForでページの描画を待機してます。

#### 出勤ボタンクリック・ダイアログaccept
```js
  page.on("dialog", (dialog) => {
    dialog.accept();
  });

  let attendanceButton = null
  if (isInAttendance) {
    attendanceButton = await page.$('.btn-attendance');
  } else {
    attendanceButton = await page.$('.btn-leaving');
  }

  await attendanceButton.click();
```

出勤ボタン/退勤ボタンのクリックは先ほどと同じ流れです。

この勤怠システムでは、出勤ボタン/退勤ボタンを押すと、確認のダイアログが表示されます。puppeterがそのダイアログを選択する必要があります。

今回は、ダイアログが表示されると`dialog`イベントが発火するので、`page.on('{イベント}', {発火する関数})`でイベントリスなを設定してます。他にも、いろんなイベントをみることができるみたいです。

```
    page.on('close')v1.3.0
    page.on('console')v0.9.0
    page.on('dialog')v0.9.0
    page.on('domcontentloaded')v1.1.0
    page.on('error')v0.9.0
    page.on('frameattached')v0.9.0
    page.on('framedetached')v0.9.0
    page.on('framenavigated')v0.9.0
    page.on('load')v0.9.0
    page.on('metrics')v0.12.0
    page.on('pageerror')v0.9.0
    page.on('request')v0.9.0
    page.on('requestfailed')v0.9.0
    page.on('requestfinished')v0.9.0
    page.on('response')v0.9.0
    page.on('workercreated')v1.5.0
    page.on('workerdestroyed')v1.5.0
```

>https://pptr.dev/#?product=Puppeteer&version=v1.11.0&show=api-class-page

#### 終了
```js
  // 3秒待つ
  await page.waitFor(3000);
  await browser.close();
```

出勤・退勤ボタンのクリックから三秒ほど待機。
そのあとヘッドレスブラウザを閉じます

## Docker化

~~nodejsのイメージ使えばyarnがデフォで入っているのでamazonlinux2を使ってyarnのインストールしてるのは無駄でした。~~
というか、[公式でDockerfile載せてるやん。。。 :astonished: ](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker)

以下、無駄ですがどうぞ

```dockerfile:Dockerfile
FROM amazonlinux:2

RUN yum update -y
RUN yum upgrade -y

RUN curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | tee /etc/yum.repos.d/yarn.repo
# nodeのバージョンに注意
RUN curl --silent --location https://rpm.nodesource.com/setup_8.x | bash -
RUN yum install -y nodejs
RUN yum install -y yarn

RUN mkdir /root/mf

WORKDIR /root/mf

ADD . /root/mf

# 環境依存なのでdockerないでyarnさせる
RUN rm -rf node_modules
RUN yarn

# 起動設定
CMD /bin/bash -c "yarn mf-in"
```

#### 実行

```
❯ docker build -t mf .
...

❯ docker run mf
yarn run v1.12.3
$ node ./main.js in
action: in
(node:27) UnhandledPromiseRejectionWarning: Error: Failed to launch chrome!
/root/mf/node_modules/puppeteer/.local-chromium/linux-609904/chrome-linux/chrome: error while loading shared libraries: libX11.so.6: cannot open shared object file: No such file or directory


TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md

    at onClose (/root/mf/node_modules/puppeteer/lib/Launcher.js:342:14)
    at Interface.helper.addEventListener (/root/mf/node_modules/puppeteer/lib/Launcher.js:331:50)
    at emitNone (events.js:111:20)
    at Interface.emit (events.js:208:7)
    at Interface.close (readline.js:368:8)
    at Socket.onend (readline.js:147:10)
    at emitNone (events.js:111:20)
    at Socket.emit (events.js:208:7)
    at endReadableNT (_stream_readable.js:1064:12)
    at _combinedTickCallback (internal/process/next_tick.js:139:11)
(node:27) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:27) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
Done in 0.57s.
```

無理でした。