bashで祝日を判定する関数を作ってみた

cronで月曜日から金曜日に実行するスクリプトを設定しているんですが、祝日には動いてほしくないので祝日を判定する関数をbashで作ってみました。

---

### 動作確認環境
- Vagrant 2.2.14 
- CentOS 8

※Macのbashではdateコマンドの引数の仕様が異なるようなので正常に動作しません。

---

### 祝日データの取得元

[内閣府の「「国民の祝日」について」というページ](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html)で「[syukujitsu.csv](https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv)」というそのものズバリなファイル名で配布されていますので、今回はこのファイルを利用しました。

このファイルは以下のようなCSV形式になっていて、（2021年7月28日時点では）1955年から2022年までの国民の祝日が列挙されています。

```:syukujitsu.csv
国民の祝日・休日月日,国民の祝日・休日名称
1955/1/1,元日
1955/1/15,成人の日
〜省略〜
2022/11/3,文化の日
2022/11/23,勤労感謝の日
```

---

### 祝日を判定する関数の仕様

実装するにあたり、ざっくりと仕様を考えてみました。

- 外部ファイルで関数を定義する。
- 関数を実行するスクリプト内で `. ファイル名` のように読み込んで利用することを想定。
- ファイル名は `is_holiday.sh` とする。
- 関数名は `is_holiday` とする。
- 関数実行時に判定を行いたい祝日の日付を引数で渡す。
- 引数で渡す日付の形式はスラッシュ区切り( `YYYY/MM/DD` )もしくはハイフン区切り( `YYYY-MM-DD` )とする。
- 関数の呼び出しは `is_holiday 2021/1/1` のような形式を想定。
- 以下の場合は戻り値で文字列の `FALSE` を返し、終了ステータスは `1` とする。
    - 引数なしで実行した場合。( `is_holiday` )
    - 引数に空文字列を渡した場合。( `is_holiday ""` )
    - 引数が日付の形式ではなかった場合。( `is_holiday 12345678` or `is_holiday 2021/99/99` or `is_holiday 2021/725` )
    - 引数が存在しない日付だった場合。( `is_holiday 2021/2/29` )
- 以下の場合は戻り値で文字列の `TRUE` を返し、終了ステータスは `0` とする。
    - 引数の日付が祝日だった場合。( `is_holiday 2021/7/22` )
- 以下の場合は戻り値で文字列の `FALSE` を返し、終了ステータスは `0` とする。
    - 引数の日付が祝日ではなかった場合。( `is_holiday 2021/6/30` )

---

### 祝日を判定するスクリプト

```:is_holiday.sh
#!/usr/bin/env bash

set -eu

function is_holiday() {
    # 引数なしの場合
    if [ $# -eq 0 ] ; then
       echo 'FALSE'
       exit 1
    fi

    # 引数が空の場合
    if [ "$1" = "" ] ; then
       echo 'FALSE'
       exit 1
    fi

    # 引数が日付の形式ではない場合
    if [[ ! "$1" =~ ^(19[0-9]{2}|20[0-9]{2})(/|-)(0?[1-9]|1[0-2])(/|-)(0?[1-9]|[12][0-9]|3[01])$ ]] ; then
       echo 'FALSE'
       exit 1
    fi

    # 引数を元にYYYYMMDD形式にフォーマット
    local TARGET_DATE=`date '+%Y%m%d' -d $1 2>/dev/null`
    # 引数が不正な日付（例: 2021/6/31）の場合
    if [ "$TARGET_DATE" = "" ] ; then
       echo 'FALSE'
       exit 1
    fi

    # 内閣府から祝日のCSVを取得（1行目はヘッダー行のためスキップ）
    local HOLIDAYS=`curl -sL https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv |iconv -f cp932 -t utf8 |tail -n +2`
    local HOLIDAY=""
    for HOLIDAY in $HOLIDAYS;
    do
       # 第1カラム（日付）を取得
       local HOLIDAY_DATE=`echo $HOLIDAY |cut -d , -f 1`
       # 祝日の日付を元にYYYYMMDD形式にフォーマット
       local FORMATTED_HOLIDAY_DATE=`date '+%Y%m%d' -d $HOLIDAY_DATE 2>/dev/null`
       # 祝日が不正な日付（例: 2021/6/31）の場合
       if [ "$FORMATTED_HOLIDAY_DATE" = "" ] ; then
          echo 'FALSE'
          exit 1
       fi
       # 引数が祝日に該当した場合
       if [ $FORMATTED_HOLIDAY_DATE = $TARGET_DATE ] ; then
           echo 'TRUE'
           exit 0
       fi
    done
    # 引数が祝日に該当しなかった場合
    echo 'FALSE'
    exit 0
}
```

---

### 解説

- 引数の個数が `0` の場合は処理を終了する。

```
if [ $# -eq 0 ] ; then
```

- 引数が空文字列の場合は処理を終了する。

```
if [ "$1" = "" ] ; then
```

- 正規表現で `YYYY/MM/DD` または `YYYY-MM-DD` 形式かを判定し、条件に合致する場合のみ処理を続行する。
- 月と日が1月1日のように1桁の場合、前ゼロがあってもなくてもOKとする。（ `2021/1/1` or `2021/01/1` or `2021/1/01` or `2021/01/01` ）
- 年は `19XX` か `20XX` とする
- 月は `1` から `12` の範囲とする。
- 日は `1` から `31` の範囲とする。

```
if [[ ! "$1" =~ ^(19[0-9]{2}|20[0-9]{2})(/|-)(0?[1-9]|1[0-2])(/|-)(0?[1-9]|[12][0-9]|3[01])$ ]] ; then
```

- CSVファイルをcurlコマンドで取得して、文字コードがShift_JISなのでUTF-8に変換する。
- CSVファイルの1行目はヘッダー行となっているので、2行目以降を変数 `HOLIDAYS` に格納する。

```
local HOLIDAYS=`curl -sL https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv |iconv -f cp932 -t utf8 |tail -n +2`
```

- 祝日のリストをループして1件ずつ処理する。

```
for HOLIDAY in $HOLIDAYS;
do
   ・
   ・
   ・
done
```

- CSVファイルの各行は `2022/11/23,勤労感謝の日` のような形式になっているので、第1カラムを抽出して変数 `HOLIDAY_DATE` に格納する。

```
local HOLIDAY_DATE=`echo $HOLIDAY |cut -d , -f 1`
```

- dateコマンドで 引数で渡された日付を `YYYYMMDD` 形式に変換する。
- 変換できなかった場合は `date: invalid date ‘2021/2/29’` のようなエラーメッセージが表示されるので、標準エラー出力を表示しないようにして、戻り値が空の場合は変換エラーが発生したとみなし処理を終了する。

```
# 引数を元にYYYYMMDD形式にフォーマット
local TARGET_DATE=`date '+%Y%m%d' -d $1 2>/dev/null`
# 引数が不正な日付（例: 2021/6/31）の場合
if [ "$TARGET_DATE" = "" ] ; then
```

- 引数で渡された日付とCSVファイルの祝日を比較して、一致した場合は戻り値として文字列 `TRUE` を返す。

```
# 引数が祝日に該当した場合
if [ $FORMATTED_HOLIDAY_DATE = $TARGET_DATE ] ; then
    echo 'TRUE'
    exit 0
fi
```

- 引数で渡された日付が祝日に該当しなかった場合は、戻り値として文字列 `FALSE` を返す。

```
# 引数が祝日に該当しなかった場合
echo 'false
```

---

### テストコード

仕様として挙げていたパターンのテストも書いてみました。

```:test_is_holiday.sh
#!/usr/bin/env bash

. is_holiday.sh

# エラー発生時にスクリプトを終了しない
set +e

tests=0
success=0
failures=0

# 引数なし
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が空
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday ""`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が日付の形式ではない
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 1234567`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が日付の形式ではない
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 12345678`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する（ハイフン区切り）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021-1-1`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する（スラッシュ区切り）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/1/1`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する（月が前ゼロ）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/01/1`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する（日が前ゼロ）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/1/01`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する（月が前ゼロ、日が前ゼロ）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/01/01`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当しない（うるう年、祝日ではない）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2020/2/29`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が不正な日付（存在しない日付）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/2/29`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当しない（存在する日付、祝日ではない）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/6/30`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が不正な日付（存在しない日付）
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/6/31`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 祝日に該当する
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/7/22`
if [[ $result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が不正な日付
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/99/99`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が日付の形式ではない
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 2021/725`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

# 引数が日付の形式ではない
tests=`expr $tests + 1`
echo "Tests:${tests}"
result=`is_holiday 20217/25`
if [[ !$result ]] ; then
    echo 'SUCCESS'
    success=`expr $success + 1`
else
    echo 'FAILURE'
    failures=`expr $failures + 1`
fi

echo "Tests: ${tests}, Success: ${success}, Failures: ${failures}."
```

テストコードの実行結果はこんな感じ。

```
$ bash test_is_holiday.sh 
Tests:1
SUCCESS
Tests:2
SUCCESS
〜省略〜
Tests:16
SUCCESS
Tests:17
SUCCESS
Tests: 17, Success: 17, Failures: 0.
```

---

### 参考URL
- [bashで祝日を含めた休日の判定](https://qiita.com/curry_rohio/items/31263850f847ad475931)
- [日本の祝日を判定するBashスクリプトを書いた件](https://qiita.com/mindwood/items/d22689d3da61034751bd)
- [休日に実行する・しないcronの簡単な支援スクリプト](https://qiita.com/xtetsuji/items/78e770e76ea968a7cfd2)