休日に実行する・しないcronの簡単な支援スクリプト

- 2021/02/23: 2017年版の初版スクリプトの他、改良した2021年版スクリプトも記載しました
- 2021/05/06: 2021年版スクリプトがLinux環境で動作しなかった（GNU coreutils stat に存在しない BSD stat のオプションを使用していた）のを修正しました

----

crontab で休日に実行したくない場合

```
0 12 * * 1-5 xtetsuji program.sh
```

といったように、5番目の曜日指定部分で `1-5` つまり月曜日から金曜日と指定することはよく行われます。

ただ、この方法では祝日に対応できない、というわけで職業プログラマー人生で定期的に書いては捨てている気がするプログラムを今回も書いてみました。

* 注：このスクリプトで想定している `date` コマンドは、多くの Linux ディストリビューションでインストールされる [GNU coreutils](https://ja.wikipedia.org/wiki/GNU_Core_Utilities) に付属しているものです。BSD系など（macOSも）では仕様が若干違うことで、そのままではスクリプトが動作しないかもしれません。

# 休日判定スクリプト 2017年版

```bash:is-holiday.sh
#!/bin/bash

case "_$1" in
    "_-h"|"_--help")
        perldoc $0
        exit
        ;;
esac

set -eu

year=$( LANG=C date +"%Y" )
month=$( LANG=C date +"%m" )
day=$( LANG=C date +"%d" )
month=${month#0}
day=${day#0}
month_day="$month/$day"

weekday=$( LANG=C date +"%w" )
nth_weekday_in_month=$(( ($day - 1) / 7 + 1 ))

month_nth_weekday="${month}_${nth_weekday_in_month}_${weekday}"

EXIT_CODE_HOLIDAY=0
EXIT_CODE_WORKDAY=1

# 土日は休日
case $weekday in
    0|6|7)
        exit $EXIT_CODE_HOLIDAY
        ;;
esac

# 祝日を列挙（固定日）
case $month_day in
    1/[123]|2/11|4/29|5/[345]|8/11|11/3|11/23|12/23)
        exit $EXIT_CODE_HOLIDAY
        ;;
esac

# 祝日を列挙（第n月曜日系）
case $month_nth_weekday in
    1_2_1)
        # 1月第2月曜日は成人の日
        exit $EXIT_CODE_HOLIDAY
        ;;
    7_3_1)
        # 7月第3月曜日は海の日
        exit $EXIT_CODE_HOLIDAY
        ;;
    9_3_1)
        # 9月第3月曜日は敬老の日
        exit $EXIT_CODE_HOLIDAY
        ;;
    10_2_1)
        # 10月第2月曜日は体育の日
        exit $EXIT_CODE_HOLIDAY
        ;;
esac

# その他ある？
# 自社の休暇などもここで列挙
if [ $year = 2017 ] ; then
    if [ $month_day = "3/20" ] || [ $month_day = "9/18" ] ; then
        # 2017年の秋分の日と春分の日
        exit $EXIT_CODE_HOLIDAY
    fi
fi
# TODO: 春分の日や秋分の日を割り出し（holidays.icalをどこからか取得して解析？）

# そうでなければ働く日
exit $EXIT_CODE_WORKDAY

: <<END_POD

=pod

=encoding utf-8

=head1 NAME

is-holiday.sh - 休日かどうか

=head1 SYNOPSIS

  is-holiday.sh ; echo $?
  is-holiday.sh || some-weekday-batch.sh

=head1 DESCRIPTION

このプログラムを実行すると、休日だと判定される場合は終了コード 0 で正常終了、休日だと判定されない場合は（つまり働く日の場合は）終了コード 1 で非正常終了します。

なので、 || や && といった条件演算子と組み合わせることによって、cron の中で祝日判定をすることなく、営業日のみ実行するプログラムをしかけたりすることができます。

=head1 MODIFICATION GUIDLINE

祝日定義を追加する際には以下のようにするとよいでしょう。

  # 祝日を列挙（固定日）
  case $month_day in
      1/[123]|2/11|4/29|5/[345]|8/11|11/3|11/23|12/23)

固定の祝日は この条件節に入るので、例えば3月3日を祝日にする場合は 3/3 を縦棒で区切ってこの中に入れると良いです。

  # 祝日を列挙（第n月曜日系）
  case $month_nth_weekday in
      1_2_1)

最近多い「第2月曜日」系については、その下にある case 文に入れます。

${m} 月、第 ${n} 月曜日の場合は ${m}_${n}_1 という書き方になります。月曜日は 1。日曜日は 0 か 7 （date コマンドの実装依存？）ですが、火曜日から土曜日は 2 から 6 となります。大抵は月曜日のはずなので _1 で良いはず。

=head1 TODO

春分の日と秋分の日の動的判定。どこかから holidays.ical を取ってくればいいんだけど、シェルスクリプトだと少々面倒。

=head1 SEE ALSO

L<Wikipedia - 国民の祝日|https://ja.wikipedia.org/wiki/%E5%9B%BD%E6%B0%91%E3%81%AE%E7%A5%9D%E6%97%A5>

=head1 AUTHOR

Initial written by OGATA Tetsuji E<lt>tetsuji.ogata@gmail.comE<gt>.

=cut

END_POD

```

少しずつシェルスクリプト力が成長しているので、数年前より多少は洗練されているような気がします。

使い方はソースコードの中に書かれている通りです。コマンドからだと `is-holiday.sh --help` などとすることで見ることができる親切設計。

端的に書けば

```
is-holiday.sh && 祝日や休日に実行したいスクリプト
is-holiday.sh || 営業日に実行したいスクリプト
```

なのですが、せっかくなので上記シェルスクリプトで使われているテクニックを解説します。

# 個別のシェルスクリプトテクニック

## ヘルプと perldoc

```bash
case "_$1" in
    "_-h"|"_--help")
        exec perldoc $0
        ;;
esac
```

ヘルプを出力するために `-h` もしくは `--help` という引数が第1引数にあるか検査する部分。

シェルスクリプトは大部分の構文がコマンドとして実行されているので、文字列を受け取る時にハイフンで始まる文字は不都合があることが多く、冒頭のハイフンを隠すような書き方が好まれます。上記では `_` を使っていますが、古いシェルスクリプトでは `x` を使っている例が多く見受けられます。

bash の case 文は、単純な文字列合致や OR 条件を if 文より簡単に書くことができるので便利です。

上記では `-h` もしくは `--help` 引数を指定すると `exec perldoc $0` を実行することになります。bash での `exec` コマンドは、このシェルスクリプトのプロセスを破棄して（正確にはメモリ的に置き換えて）引数に指定されたコマンドを実行するというもの。なので、以後は `perldoc $0` コマンドとなります。

`$0` は自分自身のファイルパスに展開されるので、 `perldoc` コマンドに自分自身を与えることになります。

```
: <<END_POD

=pod

=encoding utf-8
```

`perldoc` コマンドは `=pod` から `=cut` までの間を POD フォーマットで書かれていると認識して man コマンドのようにレンダリングしてくれるコマンドです。

`:` コマンドの意味については、以下の記事を参照下さい。

* [何もしない組み込みコマンド ":" （コロン）の使い道 - Qiita](http://qiita.com/xtetsuji/items/381dc17241bda548045d)

## `set -eu`

シェルスクリプトも良いスタイルを維持したいので、Perl でいう `use strict` や `use warnings` のような構文の厳格チェック `set -eu` を入れます。

`-e` は errexit とも呼ばれ、シェルスクリプトのコマンドの中で終了コードが 1 以上のものがあればそこで停止します。逆を言えば、これを指定しないシェルスクリプトは終了コードが 1 以上、つまり（多くの場合）エラーとなったコマンドがあっても後続のコマンドが実行されます。この動作が好ましくない場合は多いでしょう。

`-u` は nounset とも呼ばれ、未定義の変数を参照できないようにする機能です。代入を行ったことがない変数が参照できないという意味では、Ruby のデフォルトの変数参照と同様と解釈してよいでしょう。

注意としては `set -u` を書いてしまうと、第１引数が指定されない場合に `$1` と書いてしまうと、その時点でエラーとなってしまうこと。なので `$1` チェックの後に書いています。

もし `set -u` を書いた後に `$1` を参照したい場合は `${1:-}` と書くか `set +u` で nounset を一度無効化する必要があるでしょう。

## date コマンドとフォーマット

```
year=$( LANG=C date +"%Y" )
```

date コマンドは `+` の後にフォーマット文字列を伴うことでパーツを受け取ることができます。上記では2017年に実行すると 2017 という4文字を受け取ります。

`LANG=C` 部分は、 これがないと `date +"%m"` で Jul が返ってきたりとたまに驚くことが起こるので指定しています。

```
month=${month#0}
day=${day#0}
month_day="$month/$day"
```

月も日も printf でいうと %02d 的フォーマットで得られるので、頭のゼロを bash の変数参照の方法で除去しています。こうしておかないと、数値演算の時に８進数だと思われてしまうことがあり、それを気にする場合は除去を励行しておくと良いでしょう。

```
$ echo $(( 1 + 07 ))
8
$ echo $(( 1 + 08 ))
bash: 1 + 08: value too great for base (error token is "08")
```

## 曜日

```
weekday=$( LANG=C date +"%w" )
nth_weekday_in_month=$(( ($day - 1) / 7 + 1 ))

month_nth_weekday="${month}_${nth_weekday_in_month}_${weekday}"
```

曜日は date コマンドで `%w` というフォーマットから得られます。

`LANG=C` を指定していれば、得られる値は crontab のそれとだいたい同じで、1から6 で 月曜日から土曜日です。日曜日は 0 または 7 で得られ、それは実装依存となっているようです（理解曖昧）。

最近の祝日を考えるとき、「第n月曜日」ということを考える必要がありますが、「第いくつ」かは、少し考えると上記のような計算式で計算できます。bash の数値計算は `$(( ... ))` で行えること、また `/` は割り算の商のみで小数点演算はしないことに注意。

## 定数で終了コードに名前をつける

```
EXIT_CODE_HOLIDAY=0
EXIT_CODE_WORKDAY=1
```

「祝日の場合は０だっけ…」というのは悩みがち（特に Perl や PHP と「真偽値」が逆なので）なので、最初から名前をつけておくとよいですね。

# 休日判定スクリプト 2021年版

数年間2017年版を使っていたのですが、特に2020年は皆さん御存知の通り祝日が超カオスなことになり、手元では場当たり修正を入れまくっていました。

2021年も5個の輪のカオスが続くこと、元号が平成から令和になって天皇誕生日が変更になったなど元々2017年から祝日の変化があったことで、少し抜本的に手を入れることにしました。コメント欄等で祝日APIについての言及もあったことから、今回は [Holidays JP API (日本の祝日API)](https://holidays-jp.github.io/) を使用しています。

```bash:is-holiday.sh
#!/bin/bash

# 祝日API(CSV)のURL
HOLIDAY_CSV_API_URL=https://holidays-jp.github.io/api/v1/date.csv

# 祝日CSVを保管するディレクトリ
HOLIDAY_CSV_DIR=/tmp

# 祝日CSVのキャッシュ期限を秒数で指定
HOLIDAY_CSV_CACHE_LIFETIME=$(( 86400 * 30 ))

set -eu

function get-mtime-bsd {
    local file=$1
    local eval $(stat -s "$file")
    echo $st_mtime
}

function get-mtime-gnu {
    local file=$1 stat_command
    if type gstat >/dev/null 2>&1 ; then
        stat_command=gstat
    else
        stat_command=stat
    fi
    $stat_command --printf="%Y\n" "$file"
}

function get-mtime {
    local file=$1 command
    case $(uname) in
        Darwin)
            command=get-mtime-bsd
            ;;
        Linux)
            command=get-mtime-gnu
            ;;
        *)
            command=get-mtime-gnu
            ;;
    esac
    $command "$file"
}

function is-holiday-from-api {
    local RC_HOLIDAY=0 RC_NO_HOLIDAY=1
    local date=${1:-$(date +%Y-%m-%d)}
    local csvpath=$HOLIDAY_CSV_DIR/date.csv
    local download_mode=true
    if [ -f "$csvpath" ] ; then
        local st_mtime=$(get-mtime "$csvpath")
        local now=$(date +%s)
        if (( now - st_mtime > HOLIDAY_CSV_CACHE_LIFETIME )) ; then
            download_mode=true
        else
            download_mode=false
        fi
    else
        download_mode=true
    fi
    if $download_mode ; then
        curl --silent "$HOLIDAY_CSV_API_URL" > "$csvpath"
    fi
    if grep -q "^$(date +%Y-%m-%d)," "$csvpath" ; then
        return $RC_HOLIDAY
    else
        return $RC_NO_HOLIDAY
    fi
}

case "_${1:-}" in
    "_-h"|"_--help")
        if type perldoc >/dev/null ; then
            perldoc $0
        else
            sed -ne '/=pod/,/=cut/p' $0
            exit
        fi
        ;;
esac

year=$( LANG=C date +"%Y" )
month=$( LANG=C date +"%m" )
day=$( LANG=C date +"%d" )
month=${month#0}
day=${day#0}
month_day="$month/$day"

weekday=$( LANG=C date +"%w" )
nth_weekday_in_month=$(( ($day - 1) / 7 + 1 ))

month_nth_weekday="${month}_${nth_weekday_in_month}_${weekday}"

EXIT_CODE_HOLIDAY=0
EXIT_CODE_WORKDAY=1

# 土日は休日
case $weekday in
    0|6|7)
        exit $EXIT_CODE_HOLIDAY
        ;;
esac

# 祝日APIより
if is-holiday-from-api ; then
    exit $EXIT_CODE_HOLIDAY
fi

# その他ある？
# 自社の休暇などもここで列挙
case "$year-$month" in
    12-29|12-3[01]|01-0[123])
        exit $EXIT_CODE_HOLIDAY
        ;;
esac

# そうでなければ働く日
exit $EXIT_CODE_WORKDAY

: <<'END_POD'

=pod

=encoding utf-8

=head1 NAME

is-holiday.sh - 休日かどうか

=head1 SYNOPSIS

  is-holiday.sh ; echo $?
  is-holiday.sh || some-weekday-batch.sh

=head1 DESCRIPTION

このプログラムを実行すると、休日だと判定される場合は終了コード 0 で正常終了、休日だと判定されない場合は（つまり働く日の場合は）終了コード 1 で非正常終了します。

なので、 || や && といった条件演算子と組み合わせることによって、cron の中で祝日判定をすることなく、営業日のみ実行するプログラムをしかけたりすることができます。

=head1 HOLIDAY API

祝日APIとして L<Holidays JP API|https://holidays-jp.github.io/> を使って祝日判定をしています。

サーバ自体は GitHub にあるようで、GitHub 同様の安定性を想定していいと思いますが、この API へのアクセス可能性がこのスクリプトの祝日判定の要になります。

またこの API へのアクセスに curl を利用しています。

=head1 MODIFICATION GUIDLINE

冒頭に祝日API関連の設定があります。
祝日は頻繁に変わるものでもなく、毎回実行時に祝日APIからデータを取ってくるのはコスト的に得策ではないので、ある程度の時間はキャッシュするようにしています。キャッシュ生存期間の設定は少なくとも1ヶ月(2592000)より小さくするメリットは無いでしょう。

秒数指定が見づらい場合は、bash の数値計算構文 $((...)) が使えます。86400秒が1日であると分かる場合、$(( 86400 * 90 )) とすることで約3ヶ月の指定が可能となります。

他に創立記念日などといった組織独自の休日がある場合は、スクリプトの中に追記することで対応できます。年月日や第n曜日を取得する各種変数があらかじめ定義されているので、case 文や if 文で検査してスクリプトの他の箇所にあるように exit $EXIT_CODE_HOLIDAY すると良いでしょう。

=head1 SEE ALSO

L<Wikipedia - 国民の祝日|https://ja.wikipedia.org/wiki/%E5%9B%BD%E6%B0%91%E3%81%AE%E7%A5%9D%E6%97%A5>

=head1 LICENSE

MIT License.

MIT License の日本語訳は L<https://licenses.opensource.jp/MIT/MIT.html> を参照。

=head1 AUTHOR

Initial written by OGATA Tetsuji E<lt>tetsuji.ogata@gmail.comE<gt>.

=cut

END_POD
```

他のプログラミング言語の習慣に引っ張られて、当時より自分のシェルスクリプトのスタイルが関数を細かく分けるような書き方にシフトしているのですが、基本的に2017年版をベースにしつつ、一部を関数にしています。

ダウンロードしたファイルの mtime を調べる際に stat コマンドを使用するのですが、GNU 系と BSD 系で使用方法にかなりの差異があるので、別関数として独立して書いておいて、フロントで呼び出す関数が uname を見て分岐させています。ただ、uname だけでも判別が付かない部分もあるので、実際に `stat --version >/dev/null 2>&1` （長いオプション名は GNU のものであり、BSD のコマンドには存在しない） したステータスコードで分岐する方が割り切っていていいかもしれません。というより、ほぼどこでも入っていると仮定して良い [perl とその組み込みコマンド stat](http://perldoc.jp/func/stat) を使えば `perl -E 'say((stat(shift))[9])' ファイル名` で済むのですけれどね

GNU と BSD系 で基本コマンドには結構差異がありますが、このスクリプトにおいては両方で問題なく動作すると思います。特に `date` と `stat` が該当すると思います。

2021年の天皇誕生日を拾えなくて、その祝日でサッと書いたものなので動作確認はあまり取っていないことはご了承下さい（間違い等に気づけば記事修正します）。またライセンスは MIT ですので、どこでもご自由にお使い下さい。