毎月XX日（土日祝日の場合は前倒し）にcronジョブを実行する

# はじめに
職場の人に頼まれて業務自動化プログラムをPythonで作成した際，コストをかけずに定期実行するため，「**常時起動しているPCにデプロイし，cronジョブとして登録する**」方法を選択しました。

しかし，定期実行のタイミングが「毎月20日（**ただし20日が土日祝日の場合は20日以前の最終営業日**）の午前10時30分」という複雑なものだったため，cronジョブの登録の仕方でつまづきました。

いろいろ調べた結果，おそらく一番スマートだと思われる方法を見つけたので共有しようと思います。

管見の限りでは，「土日祝日だけ実行」や「平日だけ実行」といったソリューションはあるものの，今回のパターンにベストマッチするソリューションはなかったように思いますが，万が一既出だったらすみません。

# 実行環境
以下の環境では動作が確認できています。
- WSL2 (Windows10) & Python3.6
- Mac OS 12 & Python3.10

# 前提
今回は例として，`yagao`ユーザーでホームディレクトリ直下の`sample.sh`を上に書いたタイミングで実行するcronジョブを登録しようと思います。`sample.sh`は以下のように同階層に配置されたファイル`hoge`のタイムスタンプを更新するだけのシンプルなスクリプトです。
```sample.sh
touch ./hoge
```
ディレクトリ構成は以下のようになっています。
```
yagao/
├── hoge
└── sample.sh
```

# 事前準備
今回はcronジョブを動的に登録するためのPythonスクリプトを作成するため，Python3を実行できる環境が必要になります。

Python3とpip3がインストールできたら以下のコマンドで必要なライブラリをインストールします。

```bash
pip3 install -y python-crontab jpholiday python-dateutil
```

また，Macの場合はcronに対してフルディスクアクセス権限を付与する必要があります。やり方は以下をご参照ください。

[crontabコマンドでシェルスクリプトが実行できないときの対処法。mac osのシステム環境設定を変更する方法。](https://prograshi.com/general/command/crontab-trouble-shooting/)

# 解法
今回は，当月のスクリプト実行日を判定してcronジョブを登録するPythonスクリプト（`set_cron_job.py`）を作成し，それを毎月1日に実行されるようcronに登録するという方式を採ります（スクリプトの格納場所はどこでも構いませんが，今回は`sample.sh`と同じディレクトリ配下に置くことにします）。

```bash
yagao~$ crontab -l
* * 1 * * cd /home/yagao; /usr/bin/python3 ./set_cron_job.py
```

この方法だとcrobジョブの実行が月に2回だけで済むので，今日が実行日かどうか毎日判定する方式よりイケてるんじゃないかと思います。

Pythonスクリプトのサンプルは以下のようになります。
```set_cron_job.py
from datetime import datetime, date

from crontab import CronTab
import jpholiday
from dateutil.relativedelta import relativedelta

def isBizDay(d):
    # d: "yyyymmdd"
    the_date = date(int(d[0:4]), int(d[4:6]), int(d[6:8]))
    if the_date.weekday() >= 5 or jpholiday.is_holiday(the_date):
        return False
    else:
        return True

# crontabを操作するOSユーザーを指定
cron = CronTab(user='yagao')

# 前回登録したジョブを削除
cron.remove_all(command="sample.sh")

# 当月20日のDatetimeオブジェクト
twentieth = datetime.now().replace(day=20)
# ジョブ登録用に時刻を調整
job_day = twentieth.replace(minute=30, hour=10)

# 20日が非営業日だった場合，1日ずつ遡った最初の営業日をジョブ実行日とする
if not isBizDay(twentieth.strftime("%Y%m%d")):
    while True:
        job_day = job_day - relativedelta(days=1)
        if isBizDay(job_day.strftime("%Y%m%d")):
            break

# ジョブの登録
job = cron.new(command="cd /home/yagao; sample.sh")
# 実行タイミングの設定
job.setall(job_day)
# ジョブを保存
cron.write()
```
そんなに難しいことはしていないので，コメントを読めばだいたい理解できると思います。

なお関数`isBizDay()`は，以下の記事で紹介されていたものをお借りして少しだけ修正したものになります。

[datetimeとjpholidayを組み合わせて、平日か土日祝日かを判定するスクリプト - Qiita](https://qiita.com/hid_tanabe/items/3c5e6e85c6c65f7b38be)

このスクリプトを実行すると以下のようになります。
```bash
yagao~$ python3 set_cron_job.py
yagao~$ crontab -l
* * 1 * * cd /home/yagao; /usr/bin/python3 ./set_cron_job.py

30 10 20 9 * cd /home/yagao; sample.sh
```
2022年9月20日は平日なので，これで問題ないですね。1行空行が挿入されてしまうのは仕様のようです[^1]。

スクリプトの最初の方で前回登録した`sample.sh`のジョブを削除しているので，再度`set_cron_job.py`を実行しても結果は同じになります。

次に，`twentieth = datetime.now().replace(month=11, day=20)`として再度実行してみます。
```bash
yagao~$ python3 set_cron_job.py
yagao~$ crontab -l
* * 1 * * cd /home/yagao; /usr/bin/python3 ./set_cron_job.py

30 10 18 11 * cd /home/yagao; sample.sh
```
2022年11月20日は日曜日なので，それ以前の最終営業日である11月18日（金）を実行日として登録されました。

更に，`twentieth = datetime.now().replace(year=2021, day=20)`として再度実行してみます。
```bash
yagao~$ python3 set_cron_job.py
yagao~$ crontab -l
* * 1 * * cd /home/yagao; /usr/bin/python3 ./set_cron_job.py

30 10 17 9 * cd /home/yagao; sample.sh
```
2021年9月20日（月）は敬老の日だったため，それ以前の最終営業日である9月17日（金）を実行日として登録されました。

めでたしめでたし。

# 補足
創立記念日などを考慮する必要がある場合は，`jpholiday`の機能を使って独自の休日を追加することができます。

[Lalcs/jpholiday: 日本の祝日を取得するライブラリ#独自の休日を追加](https://github.com/Lalcs/jpholiday#%E7%8B%AC%E8%87%AA%E3%81%AE%E4%BC%91%E6%97%A5%E3%82%92%E8%BF%BD%E5%8A%A0)

# おわりに
crontabをPythonで操作するライブラリがあって助かりました。定期実行の幅が広がりそうです。

`isBizDay()`は`jpholiday`に実装されてほしいですね・・・。

今回は前倒しのパターンでしたが，`while`ループの中の`-`を`+`に変えれば後ろ倒しのパターンも簡単に作れます。

よかったら取り入れてみてください。

# 参考
[Martin Owens / python-crontab · GitLab](https://gitlab.com/doctormo/python-crontab/)
[Pythonで月初・月末（初日・最終日）、最終X曜日の日付を取得 | note.nkmk.me](https://note.nkmk.me/python-datetime-first-last-date-last-week/)

[^1]: [Python crontab adding newline character automatically and disabling other cron job - Stack Overflow](https://stackoverflow.com/questions/59980850/python-crontab-adding-newline-character-automatically-and-disabling-other-cron-j)