勤怠打刻操作を意識しないAPI打刻をする

おはこんばんちは、ソフトウエアエンジニアの橋本 (@af12066) です。コロナウイルスによる自粛期間もあり4ヶ月ほど散髪していないので、そろそろ行きつけの美容室に行きたい気持ちです。

これまでにfreee Developers Blogでは、人事労務freee APIを使用したさまざまな勤怠打刻方法がいくつか提案されてきました。
2018年にはIFTTTおよびGoogleアシスタントを利用した音声操作による打刻やUnity+Oculusを活用したVR打刻、2019年にはAWS IoTエンタープライズボタンを利用した打刻が紹介されました。

developers.freee.co.jp developers.freee.co.jp developers.freee.co.jp

本記事では打刻シリーズ第4弾として、私が開発した「打刻を意識しない打刻方法」を紹介したいと思います。
なお、この記事は「勤怠を自動化する技術」LT Nightで紹介した内容の文字起こし*1、および紹介しきれなかった内容の補足説明となります。

モチベーション

打刻は勤怠実績を残す重要な動作であり、時刻の正確性が要求されます。打刻の正確さと手軽さを両立させるために、たとえば人事労務freeeのトップ画面に打刻ボタンが設置されていたり、iOSアプリでは通知センターからワンタップで打刻することもできます。 一方、私のケースでは打刻ボタンを押す習慣がつく前に忘れてしまう、打刻リマインダを設定してもあとまわしにした結果打刻しない、といったことがたびたび発生していました...。

そこで、いつもどおり仕事をするだけで勝手に打刻される方法はないか?と考えた結果、本記事の手法が生まれました。

前提および事前準備

動作環境

この記事ではmacOSの機能を利用した方法を説明するため、それ以外の環境は対象外となります(すみません)。同様のサービス管理方法があれば実現可能であると思われます。

また、事業所にてfreeeアプリストアからアプリケーションを作成し、Client ID, Client Secret, 認可コードを取得しておいてください。手順の詳細は、freee APIチュートリアルをご参照ください。

developer.freee.co.jp

勤怠の定義

今回は自動打刻の都合上、勤怠は会社支給のラップトップ上で作業した時間のみを対象とします。つまり、ラップトップを起動しログインしている間は勤務しているとみなすことにします。

自動打刻の概観

打刻に関するAPIドキュメント/create)によると、打刻には次の4種類が存在します。

  • 出勤
  • 休憩開始
  • 休憩終了
  • 退勤

一日あたり8時間労働とすると、上記4つすべての打刻を行なう必要があります*2

出勤は勤怠の定義そのままで、ログインに成功した段階で打刻を行ないます。休憩は昼ごろに1時間確保しますが*3、具体的な時間の規定はないため、適当な時間に確保することにします。退勤もまた、ログアウト完了までに打刻をする必要があります。

ざっくり状態遷移は次のようになります。ただしAPIの具体的な仕様の説明ではないため、正確でない場合があります。

勤怠の状態遷移。出勤から開始し、休憩開始および休憩終了を経て退勤となる。休憩開始および休憩終了はくりかえすことができ、または休憩を行なわずに出勤・退勤のみ打刻することもできる
勤怠の状態遷移

休憩開始の直後に退勤を打刻したり、退勤のあとに休憩終了しようとするとAPI側でエラーとなることから、上記の状態遷移となることが推定されます。

次のようなエラーがAPIのメッセージとして返ってくる:「休憩開始時刻の後に退勤時刻を指定することはできません、退勤時刻の後に休憩終了時刻を指定することはできません」
不適切なAPI打刻の例

自動打刻の実装

トークンの取得および機密情報の格納

Client ID, Client Secret, 認可コードが取得済みであるとして、これらを用いてリフレッシュトークンを取得する必要があります。また、アクセストークンの取得・更新のために、Client ID、Client Secret、リフレッシュトークンも合わせて保存する必要があります。

developer.freee.co.jp

トークンは任意のファイルに書き込んでから暗号化して保存したり環境変数に保存してもよいですが、Macにはキーチェーンアクセスという機密情報を保存するためのアプリケーションがあるため、これを活用してみます。

support.apple.com

シェルからキーチェーンアクセスを操作するために、securityコマンドがあります。使い方の詳細はman 1 securityを参照してください。

リフレッシュトークンの取得からキーチェーンアクセスへの登録までをまとめると、以下のようなスクリプトになります。リフレッシュトークンの更新は別途行なうため、以下のスクリプトを実行するのは初回のみとなります。

# 初回のリフレッシュトークンを取得するために必要な設定をおこなう

export FREEE_CLIENT_ID="<freee APIのClient ID>"
export FREEE_CLIENT_SECRET="<freee APIのClient Secret>"
export AUTHORIZATION_CODE="<freee APIの認可コード>"
#!/bin/bash

readonly REFRESH_TOKEN=$(curl -X POST \
  -H "Content-Type:application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=${FREEE_CLIENT_ID}" \
  -d "client_secret=${FREEE_CLIENT_SECRET}" \
  -d "code=${AUTHORIZATION_CODE}" \
  -d "redirect_uri=urn:ietf:wg:oauth:2.0:oob" 'https://accounts.secure.freee.co.jp/public_api/token' \
  | jq -r '.refresh_token')

readonly KEYCHAIN_SERVICE_NAME='freee-kintai'

security add-generic-password -a client-id -w "${FREEE_CLIENT_ID}" -s "${KEYCHAIN_SERVICE_NAME}"
security add-generic-password -a client-secret -w "${FREEE_CLIENT_SECRET}" -s "${KEYCHAIN_SERVICE_NAME}"
security add-generic-password -a refresh-token -w "${REFRESH_TOKEN}" -s "${KEYCHAIN_SERVICE_NAME}"

キーチェーンアクセスを開き、パスワードと同様の扱いでClient ID, Client Secret, リフレッシュトークンが保存されていることを確認します。

キーチェーンアクセスでclient-idの詳細を確認しているスクリーンショット
キーチェーンアクセスでclient-idの詳細を確認(パスワードの表示にはMacのログインパスワードの入力が必要)

打刻の汎用スクリプト

人事労務freee APIを操作するための、打刻用のスクリプト (Bash) を書いていきます。出勤・休憩・退勤はすべて同じエンドポイントであるため、単一のスクリプトにまとめて引数で打刻種別を変えるようにしてみます。一回の打刻におけるAPIリクエストの流れは次のようになります。

  1. さきほど登録したClient ID、Client Secret、リフレッシュトークンを使用して認証をおこなう。このとき、アクセストークンおよびリフレッシュトークンが払い出され、リフレッシュトークンはキーチェーンに上書き保存し、アクセストークンは以降のAPIリクエストに使用する
  2. アクセストークンを使用して、事業所IDおよび従業員IDを取得する
  3. アクセストークン、および2.で得られた事業所ID、従業員IDに加えて打刻種別を指定し、打刻APIにPOSTする
Client ID、Client Secret、リフレッシュトークンを使用してアクセストークンを取得する

さきほどキーチェーンに登録したClient ID、Client Secret、リフレッシュトークンを取得し、アクセストークンを取得するエンドポイントにPOSTします。
security find-generic-passwordコマンドでキーチェーンの内容を取得できます。-wオプションを与えることで、標準出力にその登録内容だけを流すことができます。

#!/bin/bash

readonly CLIENT_ID=$(security find-generic-password -a client-id -w -s freee-kintai)
readonly CLIENT_SECRET=$(security find-generic-password -a client-secret -w -s freee-kintai)
readonly TOKEN=$(security find-generic-password -a refresh-token -w -s freee-kintai)

readonly AUTH_RESP=$(curl -X POST \
  -H "Content-Type:application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "refresh_token=${TOKEN}" \
  'https://accounts.secure.freee.co.jp/public_api/token' 2>/dev/null)
readonly ACCESS_TOKEN=$(echo ${AUTH_RESP} | jq -r '.access_token')
readonly REFRESH_TOKEN=$(echo ${AUTH_RESP} | jq -r '.refresh_token')

# add-generic-password -Uでキーチェーンに上書き保存する
security add-generic-password -a refresh-token -w "${REFRESH_TOKEN}" -s freee-kintai -U
事業所IDおよび従業員IDの取得

2020年現在、/api/v1/users/meで取得できます。

人事労務APIリファレンス | freee Developers Community

スクリプトは次のようになります。事業所名は自身の事業所名に置換してください。

#!/bin/bash

readonly COMPANY_RESPONSE=$(curl -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  https://api.freee.co.jp/hr/api/v1/users/me 2>/dev/null)
readonly COMPANY_ID=$(echo ${COMPANY_RESPONSE} | jq -r '.companies[] | select(.name | test("事業所名")) | .id')
readonly EMPROYEE_ID=$(echo ${COMPANY_RESPONSE} | jq -r '.companies[] | select(.name | test("事業所名")) | .employee_id')
打刻APIにPOSTする

2020年現在、/api/v1/employees/${EMPROYEE_ID}/time_clocksにPOSTすることで打刻できます。

人事労務APIリファレンス | freee Developers Community

#!/bin/bash

readonly time_clocks_type=$1

readonly ISO8601DATE=$(date "+%Y-%m-%d")

curl -X POST -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -H "Content-Type:application/json" \
  -d "{\"company_id\":${COMPANY_ID},\"type\":\"${time_clocks_type}\",\"base_date\":\"${ISO8601DATE}\"}" \
  https://api.freee.co.jp/hr/api/v1/employees/${EMPROYEE_ID}/time_clocks 2>/dev/null)

time_clocks_type=$1で引数を得ることで、アクセストークンの取得から打刻までを単一のスクリプトをまとめてdakoku.shとして実行権限つきで保存したとき、/path/to/dakoku.sh clock_inのように実行することで出勤打刻を行なうことができます。

打刻スクリプトの実行

作成したスクリプトdakoku.shを、勤怠種別ごとに実行させていきます。

出勤

システム環境設定の「ユーザとグループ」内、「ログイン項目」にて、ログイン直後に開きたいアプリケーションを指定します。ただし、実行権限を与えたシェルスクリプトを直接指定することはできないようです。
そこで、シェルスクリプトをラップしたAutomatorアプリケーションを作成し、それをログイン項目に指定することにします。

support.apple.com

# Automatorアプリケーションのシェルに以下を記述して保存する

export PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin

/path/to/dakoku.sh clock_in
launchctl unload -w ~/Library/LaunchAgents/freee-kintai-taikin.plist # 後述

初期状態では環境変数PATHには/usr/bin:/bin:/usr/sbin:/sbinしか含まれないため、スクリプトで使用するコマンドに応じてPATHを追加してください。
また、初回実行時にはキーチェーンアクセスへのアクセス許可を求められます。

securityコマンドがfreee-kintaiキーチェーンへのアクセスを求める表示があらわれる
securityコマンドからのアクセスを求められるので、つねに許可する

Automatorの設定画面。シェルに/bin/bashを指定し、前述のスクリプトを記述する
Automatorの設定画面

システム環境設定のスクリーンショット
システム環境設定のログイン項目に、Automatorアプリケーションを指定する

休憩開始および終了

厳密な休憩時刻は定められていないため、かりに12時から13時まで休憩することにします。

macOSでは、cronに相当する機能としてlaunchdが提供されています*4。launchdの概観を知るには、"A launchd Tutorial" がわかりやすいので、時間があれば眺めてみてください。

12時に休憩開始を打刻するplistは次のようになります。~/Library/LaunchAgents/freee-kintai-put-break-time.plist といった名称で保存しておきます(~/Library/LaunchAgents/以下であればファイル名はなんでもよい)。
StartCalendarIntervalを使用して平日の12時に打刻する設定となっており、Weekdayは日曜日が0、月曜日が1、...という指定となります。休憩終了も同様に作成します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>freee-kintai-put-break-time</string>
    <key>ProgramArguments</key>
    <array>
      <!-- 絶対パスでスクリプトを指定する -->
      <string>/Users/your-login-name/path/to/dakoku.sh</string>
      <string>break_begin</string>
    </array>
    <!-- スクリプトの内容に合わせてPATHを更新する -->
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    <key>StartCalendarInterval</key>
    <array>
      <dict>
        <key>Weekday</key>
        <integer>1</integer>
        <key>Hour</key>
        <integer>12</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key>
        <integer>2</integer>
        <key>Hour</key>
        <integer>12</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key>
        <integer>3</integer>
        <key>Hour</key>
        <integer>12</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key>
        <integer>4</integer>
        <key>Hour</key>
        <integer>12</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key>
        <integer>5</integer>
        <key>Hour</key>
        <integer>12</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
    </array>
    <key>ExitTimeout</key>
    <integer>30</integer>
    <key>RunAtLoad</key>
    <false/>
  </dict>
</plist>

launchctl loadコマンドにより、plistをlaunchdに登録します。

$ launchctl load -w ~/Library/LaunchAgents/freee-kintai-put-break-time.plist # 休憩開始
$ launchctl load -w ~/Library/LaunchAgents/freee-kintai-put-break-time-end.plist # 休憩終了
$ launchctl list | grep freee # launchdに登録したplistを確認。grepにヒットすればOK
-   0  freee-kintai-put-break-time
-   0  freee-kintai-put-break-time-end
退勤

出勤は「ログイン項目」にて指定できたものの、退勤に相当する「ログアウト項目」は存在しません。

ここで、freeeの退勤打刻を上書きできる仕様*5を活用します。具体的には、

  1. 19:00に退勤を打刻(WebまたはAPI経由で)
  2. 19:01に退勤をAPIで打刻
  3. 19:02に退勤をAPIで打刻

といった操作を順番に行ったとき、結果はすべて成功となり、最終的な退勤時刻は19:02となります(手順1.および2.は上書きされる)。つまり、休憩終了後からログアウトするまで退勤打刻をくりかえすことで、ログアウト時刻を退勤時刻とみなすことができます。

退勤をくりかえすplist (~/Library/LaunchAgents/freee-kintai-taikin.plist) は次のようになります。以下の例ではStartIntervalを使用して2分ごとに退勤操作をくりかえしています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>freee-kintai-taikin</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/your-login-name/path/to/dakoku.sh</string>
      <string>clock_out</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    <!-- 120秒ごとに退勤打刻をおこなう -->
    <key>StartInterval</key>
    <integer>120</integer>
    <!-- ねんのためstdout/stderrを残しておく -->
    <key>StandardOutPath</key>
    <string>/Users/your-login-name/path/to/taikin.out</string>
    <key>StandardErrorPath</key>
    <string>/Users/your-login-name/path/to/taikin.err</string>
    <key>ExitTimeout</key>
    <integer>30</integer>
    <!-- launchctl loadを実行した際にも退勤打刻をする -->
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>

ここで、出勤時に~/Library/LaunchAgents/freee-kintai-taikin.plistをunload(launchdから登録解除)していましたが、これは休憩開始前に退勤打刻されるのを防ぐためです。
休憩終了後に再度loadする必要があるため、そのためのplistを休憩打刻と同様に以下のように作成します。休憩終了は13:00としたので、ひとまず14:30から退勤打刻を開始すれば問題ないでしょう。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>freee-kintai-trigger-taikin</string>
    <key>ProgramArguments</key>
    <array>
      <string>launchctl</string>
      <string>load</string>
      <string>-w</string>
      <string>/Users/your-login-name/Library/LaunchAgents/freee-kintai-taikin.plist</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    <key>StartCalendarInterval</key>
    <array>
      <dict>
        <key>Weekday</key>
        <integer>1</integer>
        <key>Hour</key>
        <integer>14</integer>
        <key>Minute</key>
        <integer>30</integer>
      </dict>
      <!-- ...省略 -->
    </array>
    <key>ExitTimeout</key>
    <integer>30</integer>
    <key>RunAtLoad</key>
    <false/>
  </dict>
</plist>

再度launchctl listを確認します。退勤をunload|loadしているため、時間帯によって結果が異なる場合があります。

$ launchctl list | grep freee
-   0  freee-kintai-trigger-taikin
-   0  freee-kintai-put-break-time
-   0  freee-kintai-put-break-time-end

あとは数日間運用しつつ人事労務freeeのタイムレコーダーを確認し、正常に打刻されていることが確認できればOKです。

人事労務フリーのタイムレコーダーのスクリーンショット。出勤・休憩開始・休憩終了・退勤がすべて記録されている
人事労務freeeのタイムレコーダー

打刻の信頼性を向上させる

これまで紹介した実装に加えて、以下の実装を追加で行っています。

  • APIサーバへの簡単な疎通確認を行ない、サーバに到達できないケース、認証に失敗したケース、状態遷移が正しくないケースのハンドリングやリトライ処理を行なう
  • stdout/stderrを即座に確認しづらいため、たとえばosascript -e 'display notification "人事労務freeeで打刻しました" with title "出勤"'を実行することで打刻の成功・失敗に関するフィードバックを通知させる
    • Mac標準の通知機能をAppleScript経由で利用

打刻漏れをしてしまうことがまずいため、それを早期に検知できること、対処方法がわかること(スクリプトやネットワークがまずいのか、打刻種別がよくないのか、あきらめて手動打刻しなければならないのか、など)が重要であると考えます。

まとめ

この記事では、能動的に打刻操作をしない自動打刻方法を紹介しました。別の環境や打刻以外の場面で活用できるかもしれないので、機会があれば参考にしてみてください。

Enjoy!

*1:書こうと思いつつ機会を逃していたら1年経過していたので、復習も兼ねる

*2:労働時間・休憩・休日関係|厚生労働省によると、労働基準法第34条にて、8時間を超える勤務の場合は少なくとも1時間の休憩が定められている

*3:実際には1時間以上確保したり複数回休憩することも可能だが、例外とみなす

*4:スケジューラ以外にもサービス管理などの機能があるが、ここでは紹介しない

*5:仕様であることを確認済み