Motivation
弊社の出勤退勤では奉行クラウドを利用していますが、奉行クラウドは公式からデスクトップクライアントを提供していないのでタイムレコーダーの打刻のためにわざわざブラウザから奉行クラウドのページを開いて打刻のボタンを押す必要があります。
この作業が実に面倒くさいし外出/再入などは結構忘れる事が多いのでどうにかならんもんかなと思って自動化することにしました。
TL;TD
マルチプラットフォーム環境に対応したCLIで奉行クラウドで打刻できるClientを作成しました。
bugyo-client-go
こんな感じで使えます。
# 出勤
bugyoclient punchmark --type in
# 退出
bugyoclient punchmark --type out
# 外出
bugyoclient punchmark --type go
# 再入
bugyoclient punchmark --type return
Windowsのタスクスケジューラでログオン、シャットダウン時に動くようにしておけば
PC立ち上げたら出勤、PCシャットダウンしたら退勤が実現できます。
cliとしてではなくgolangのlibraryとしても利用出来るようにClientを作成したのでCloudRun
やLambda
でも動かせます。これでslackと連携も出来る!
package main
import (
"github.com/tomtwinkle/bugyo-client-go"
"log"
)
func main() {
config := &bugyoclient.BugyoConfig{
TenantCode: "<Your Tenant Code>",
OBCiD: "<Your OBCID>",
Password: "<Your Password>",
}
client, err := bugyoclient.NewClient(config)
if err != nil {
log.Fatal(err)
}
if err := client.Login(); err != nil {
log.Fatal(err)
}
if err := client.Punchmark(bugyoclient.ClockTypeClockIn); err != nil {
log.Fatal(err)
}
}
まあそもそも打刻に奉行クラウド使ってないと使えないですけども!
以下には作成した際のログを残していくので奉行クラウド以外でも同じように公式でCLI提供されたないのでClientを作りたい場合に参考にしてください。
Bugyo Clientの作成
奉行クラウドにログインして打刻ボタン叩くまでSeleniumでやっても良いんですが
わざわざタイムレコーダーの打刻のためだけにSeleniumやヘッドレスブラウザ導入するのもtoo matchだしせっかくだからGoでClient作ることにします。
やることとしては単純、奉行クラウドのサイトにログインして打刻するまでのRequestをトレースしつつその間のsession情報を保持していればok。
http clientでSessionを保持する
Golangのbuilt-in packageであるhttpのclientはデフォルトではSessionを保持しません。
Session情報はcookieに記録されるのでhttp clientでsession保持させるためにcookiejarを用意してあげます。
どうでも良い話ですがクッキー入れておく瓶のことをクッキージャーって呼ぶんですね。
import (
"net/http"
"net/http/cookiejar"
)
type BugyoClient interface {}
type bugyoClient struct {
client *http.Client
}
func NewClient() (BugyoClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
client := &http.Client{
Jar: jar,
}
return &bugyoClient{client: client}, nil
}
あとは作成したclientを使い回せばClient側でSessionを保持してくれます。
referrerを設定する
ブラウザとしての動作を擬似的にトレースしてあげるためにはちゃんとreferrerの情報も渡してあげたほうが良いですね。
前回Requestの情報を元にreferrerを設定するようにしておきます。
func (b *bugyoClient) post(uri string, body url.Values) (*goquery.Document, error) {
req, err := http.NewRequest(http.MethodPost, uri, strings.NewReader(body.Encode()))
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", userAgent)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("__RequestVerificationToken", b.token)
req.Header.Add("X-Requested-With", "XMLHttpRequest")
if ref := b.refererForURL(b.lastReq); ref != "" {
req.Header.Set("Referer", ref)
}
defer b.setLastReq(req.URL)
// 省略
}
func (b *bugyoClient) refererForURL(lastReq *url.URL) string {
if lastReq == nil {
return ""
}
return lastReq.String()
}
func (b *bugyoClient) setLastReq(lastReq *url.URL) {
b.lastReq = lastReq
}
ちなみにnet/http/clientに倣ってちゃんと書くと👇のようになりますが今回は接続先がhttps前提なのとauthは考えなくて良いので省略しています。
// refererForURL returns a referer without any authentication info or
// an empty string if lastReq scheme is https and newReq scheme is http.
func refererForURL(lastReq, newReq *url.URL) string {
// https://tools.ietf.org/html/rfc7231#section-5.5.2
// "Clients SHOULD NOT include a Referer header field in a
// (non-secure) HTTP request if the referring page was
// transferred with a secure protocol."
if lastReq.Scheme == "https" && newReq.Scheme == "http" {
return ""
}
referer := lastReq.String()
if lastReq.User != nil {
// This is not very efficient, but is the best we can
// do without:
// - introducing a new method on URL
// - creating a race condition
// - copying the URL struct manually, which would cause
// maintenance problems down the line
auth := lastReq.User.String() + "@"
referer = strings.Replace(referer, auth, "", 1)
}
return referer
}
打刻Requestをトレースする
- ログインまで
- トップページ表示 -> CSRF tokenを取得
- idの存在確認(CSRF tokenを利用)
- 認証実施(id, password, CSRF tokenを利用)
- 認証後リダイレクト(SessionIDを利用) -> user_codeを取得
- 打刻まで
- 打刻ページに移動(SessionID, tenant_code, user_codeを利用)
- 打刻(clocktypeの指定で出勤、退勤、外出、再入を選択)
の流れっぽいです。
打刻の際にPositionLatitude, PositionLongitudeを渡しているんですが会社の場所を入力するのが正なのかなと思いつつ、実際のRequestでは適当な場所渡してるので取り敢えず適当な値でも動くみたいですね。
Bugyo ClientのCLI化
CLI化するにあたって利用したのはurfave/cliです。
-
app --version
でversion情報出して欲しい -
app --help
でhelp出して欲しい
みたいな基本的なことから
-
app command [command options]
のようにcommand毎にflagを設定したい - 特定コマンドで必須のフラグを指定していない場合はそのコマンドのhelpを出したい
- shortnameを指定してcommandやflagを省略出来るようにもしたい
みたいな事が簡単に書けるので便利です。
今回は
bugyoclient punchmark --type <clock type>
もしくは
bugyoclient pm -t <clock type>
で打刻。
bugyoclient punchmark --type <clock type> --verbose
で詳細ログもセットで出力。
のようなパラメータ指定でCLIが実行出来るようしました。
app := cli.NewApp()
app.Name = "Bugyo Client CLI for Go"
app.Usage = "奉行クラウドCLI"
app.Author = "tomtwinkle"
app.Version = fmt.Sprintf("bugyo-client-go cli version %s.rev-%s", version, revision)
app.Commands = []cli.Command{
{
Name: "punchmark",
ShortName: "pm",
Usage: "タイムレコーダーの打刻を行う",
Flags: []cli.Flag{
cli.StringFlag{
Name: "type, t",
Usage: "出勤: --type in or -t in" +
"\n\t退出: --type out or -t out" +
"\n\t外出: --type go or -t go" +
"\n\t再入: --type return or -t return",
Required: true,
Value: "",
},
cli.BoolFlag{
Name: "verbose, v",
Usage: "--verbose or -v",
Required: false,
},
},
Action: func(c *cli.Context) error {
var bcli bugyocli.CLI
if c.Bool("verbose") {
bcli = bugyocli.NewCLI(true)
} else {
bcli = bugyocli.NewCLI(false)
}
switch c.String("type") {
case "in":
return bcli.PunchMark(bugyoclient.ClockTypeClockIn)
case "out":
return bcli.PunchMark(bugyoclient.ClockTypeClockOut)
case "go":
return bcli.PunchMark(bugyoclient.ClockTypeGoOut)
case "return":
return bcli.PunchMark(bugyoclient.ClockTypeReturned)
default:
return cli.ShowSubcommandHelp(c)
}
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
試しにhelpを出してみます。
$ bugyoclient.exe help
NAME:
Bugyo Client CLI for Go - 奉行クラウドCLI
USAGE:
bugyoclient.exe [global options] command [command options] [arguments...]
VERSION:
bugyo-client-go cli version 0.10.6.rev-a83035a
AUTHOR:
tomtwinkle
COMMANDS:
punchmark, pm タイムレコーダーの打刻を行う
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
--version, -v print the version
$ bugyoclient.exe punchmark
NAME:
bugyoclient.exe punchmark - タイムレコーダーの打刻を行う
USAGE:
bugyoclient.exe punchmark [command options] [arguments...]
OPTIONS:
--type value, -t value 出勤: --type in or -t in
退出: --type out or -t out
外出: --type go or -t go
再入: --type return or -t return
--verbose, -v --verbose or -v
Required flag "type" not set
Flag定義くらいしかしてないですがで結構いい感じにhelpが出力されてますね。
go buildしたモジュールをgithubのreleasesに自動的に添付する
手動でgo build
してgithubのreleasesに添付していくのは面倒なのでこれもCI/CDで自動化していきたい所です。
具体的にはtag
で新規バージョンを切ってmaster burnch(今回はmain brunchですが)にpushした際に自動的にreleaseするように設定してきます。
これを実現するためにGithub Action
とGoReleaser
の組み合わせで実行します。
GoReleaserの設定
まずはGoReleaserの設定をしていきます。
プロジェクトルートに.goreleaser.yml
を作成します。
project_name: bugyoclient
env:
- GO111MODULE=on
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: bugyoclient
goos:
- windows
- darwin
- linux
goarch:
- amd64
- 386
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.revision={{.ShortCommit}}
env:
- CGO_ENABLED=0
archives:
- name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
files:
- README.md
format_overrides:
- goos: windows
format: zip
release:
prerelease: auto
-
builds/main
にbuild対象のmain.go
があるエントリポイントのパスを記載 -
builds/goos
,builds/goarch
にbuild対象のプラットフォームを記載 -
builds/ldflags
にbuild時のリンカオプションを指定。今回はmain.go
のグローバル変数の値の書き換えmain.govar version = "unknown" // リンカオプションでbuild時に書き換え var revision = "unknown" // リンカオプションでbuild時に書き換え func main() { }
-
archives/name_template
で出力するアーカイブ名の定義 -
archives/files
でバイナリ以外にアーカイブに含めたいファイルの指定 -
archives/format_overrides
で goos=windowsの場合のみzipで出力するように変更
Github Actionの設定
.github/workflows/release.yml
を作成します。
github上からもhttps://github.com/<user>/<repository>/actions/new
で作成出来ます。
name: release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v1
with:
go-version: 1.15
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v1
with:
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
on/push/tags
でversion形式でtag pushされた時に動作するようにします -
uses: goreleaser/goreleaser-action@v1
でgoreleaserを先程作成した設定で動かします
適当な修正でtag pushしてみましたがこんな感じでbuild済みのバイナリが格納されたアーカイブが自動でReleasesのAssetsに添付されます。
完成品
というわけで出来上がったのがこちらです。
bugyo-client-go
奉行クラウドを使ってなきゃそもそも使えないので非常にニッチですが少なくとも社内の人には使ってもらえるかな。
READMEにまだWindows版しかInstallation書いてないですが特にプラットフォーム固有の処理は書いてないのでバイナリをDownloadすればUbuntuやmacでも動くはず。
動かなかったらissue上げといてください。