- 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 に付属しているものです。BSD系など(macOSも)では仕様が若干違うことで、そのままではスクリプトが動作しないかもしれません。
休日判定スクリプト 2017年版
#!/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
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 コマンドのようにレンダリングしてくれるコマンドです。
:
コマンドの意味については、以下の記事を参照下さい。
set -eu
シェルスクリプトも良いスタイルを維持したいので、Perl でいう use strict
や use warnings
のような構文の厳格チェック set -eu
を入れます。
-e
は errexit とも呼ばれ、シェルスクリプトのコマンドの中で終了コードが 1 以上のものがあればそこで停止します。逆を言えば、これを指定しないシェルスクリプトは終了コードが 1 以上、つまり(多くの場合)エラーとなったコマンドがあっても後続のコマンドが実行されます。この動作が好ましくない場合は多いでしょう。
-u
は nounset とも呼ばれ、未定義の変数を参照できないようにする機能です。代入を行ったことがない変数が参照できないという意味では、Ruby のデフォルトの変数参照と同様と解釈してよいでしょう。
注意としては set -u
を書いてしまうと、第1引数が指定されない場合に $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 の変数参照の方法で除去しています。こうしておかないと、数値演算の時に8進数だと思われてしまうことがあり、それを気にする場合は除去を励行しておくと良いでしょう。
$ 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
「祝日の場合は0だっけ…」というのは悩みがち(特に Perl や PHP と「真偽値」が逆なので)なので、最初から名前をつけておくとよいですね。
休日判定スクリプト 2021年版
数年間2017年版を使っていたのですが、特に2020年は皆さん御存知の通り祝日が超カオスなことになり、手元では場当たり修正を入れまくっていました。
2021年も5個の輪のカオスが続くこと、元号が平成から令和になって天皇誕生日が変更になったなど元々2017年から祝日の変化があったことで、少し抜本的に手を入れることにしました。コメント欄等で祝日APIについての言及もあったことから、今回は Holidays JP API (日本の祝日API) を使用しています。
#!/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 を使えば perl -E 'say((stat(shift))[9])' ファイル名
で済むのですけれどね
GNU と BSD系 で基本コマンドには結構差異がありますが、このスクリプトにおいては両方で問題なく動作すると思います。特に date
と stat
が該当すると思います。
2021年の天皇誕生日を拾えなくて、その祝日でサッと書いたものなので動作確認はあまり取っていないことはご了承下さい(間違い等に気づけば記事修正します)。またライセンスは MIT ですので、どこでもご自由にお使い下さい。