奉行クラウドのタイムレコーダー打刻を自動化するためのツールを作った

# Motivation
弊社の出勤退勤では奉行クラウドを利用していますが、奉行クラウドは公式からデスクトップクライアントを提供していないのでタイムレコーダーの打刻のためにわざわざブラウザから奉行クラウドのページを開いて打刻のボタンを押す必要があります。
この作業が実に面倒くさいし外出/再入などは結構忘れる事が多いのでどうにかならんもんかなと思って自動化することにしました。

# TL;TD

マルチプラットフォーム環境に対応したCLIで奉行クラウドで打刻できるClientを作成しました。
[bugyo-client-go](https://github.com/tomtwinkle/bugyo-client-go)
こんな感じで使えます。

```bash
# 出勤
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と連携も出来る！**

```go
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を用意してあげます。
どうでも良い話ですがクッキー入れておく瓶のことをクッキージャーって呼ぶんですね。

```go
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を設定するようにしておきます。

```go
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://github.com/golang/go/blob/master/src/net/http/client.go#L144-L166)に倣ってちゃんと書くと👇のようになりますが今回は接続先がhttps前提なのとauthは考えなくて良いので省略しています。

```go:github.com/golang/go/blob/master/src/net/http/client.go#L144-L166
// 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をトレースする

- ログインまで

> 1. トップページ表示 -> CSRF tokenを取得
> 2. idの存在確認(CSRF tokenを利用)
> 3. 認証実施(id, password, CSRF tokenを利用)
> 4. 認証後リダイレクト(SessionIDを利用) -> user_codeを取得

- 打刻まで

> 1. 打刻ページに移動(SessionID, tenant_code, user_codeを利用)
> 2. 打刻(clocktypeの指定で出勤、退勤、外出、再入を選択)

の流れっぽいです。
打刻の際にPositionLatitude, PositionLongitudeを渡しているんですが会社の場所を入力するのが正なのかなと思いつつ、実際のRequestでは適当な場所渡してるので取り敢えず適当な値でも動くみたいですね。

# Bugyo ClientのCLI化

CLI化するにあたって利用したのは[urfave/cli](https://github.com/urfave/cli)です。

-  `app --version` でversion情報出して欲しい
- `app --help` でhelp出して欲しい

みたいな基本的なことから


- `app command [command options]` のようにcommand毎にflagを設定したい
- 特定コマンドで必須のフラグを指定していない場合はそのコマンドのhelpを出したい
- shortnameを指定してcommandやflagを省略出来るようにもしたい

みたいな事が簡単に書けるので便利です。
今回は

```bash
bugyoclient punchmark --type <clock type>
もしくは
bugyoclient pm -t <clock type>
```

で打刻。

```bash
bugyoclient punchmark --type <clock type> --verbose
```

で詳細ログもセットで出力。

のようなパラメータ指定でCLIが実行出来るようしました。

```go
	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を出してみます。

```bash
$ 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
```

```bash
$ 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`を作成します。

```yaml:.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`のグローバル変数の値の書き換え

    ```go:main.go

    var 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`で作成出来ます。


```yaml:.github/workflows/release.yml
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に添付されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/536869/83f5a1b0-48a2-95cc-31a6-65151d09729f.png)


# 完成品

というわけで出来上がったのがこちらです。
**[bugyo-client-go](https://github.com/tomtwinkle/bugyo-client-go)**
奉行クラウドを使ってなきゃそもそも使えないので非常にニッチですが少なくとも社内の人には使ってもらえるかな。
READMEにまだWindows版しかInstallation書いてないですが特にプラットフォーム固有の処理は書いてないのでバイナリをDownloadすればUbuntuやmacでも動くはず。
動かなかったらissue上げといてください。

# 参考サイト

- [Go で書いた CLI ツールのリリースは GoReleaser と GitHub Actions で個人的には決まり](https://tellme.tokyo/post/2020/02/04/release-go-cli-tool/)