クソ雑魚エンジニアのメモ帳

学んだことを書くところ

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.確認のダイアログに対して入力
  • 出勤・退勤の2つのスクリプトを作成
  • どちらも1コマンドで
  • ID/PASSは外部ファイルに

実装

準備

yarn init mf
yarn add puppeter

package.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で出勤・退勤の2種類のスクリプトを叩きます

スクリプト

スクリプト本体作成

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();
})();

ログイン情報を設定

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

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

...

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

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

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

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おまじない

(async () => {

  ...

})();

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

ページ表示

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

puppeterを起動して、ページを開いてます。 puppeteer.launch()の第3引数には、様々な設定ができます。例えば、{headless: false}とすると、ブラウザが表示された上で操作されます。デバッグに便利ですねー。

page.gotoの第三引数のwaitUntilは、puppeterがどの時点でページ描画完了とするかの設定値です。 ほかにも色々な設定ができます

form入力・submit

  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

  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

終了

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

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

Docker化

nodejsのイメージ使えばyarnがデフォで入っているのでamazonlinux2を使ってyarnのインストールしてるのは無駄でした。 というか、公式で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.

無理でした。