【GAS】毎月XX日（土日祝日の場合は前倒し）にスクリプトを実行する

# はじめに
職場の人に頼まれてGoogle Drive操作の自動化プログラムをGASで作成した際，定期実行のタイミングが「**毎月20日（ただし20日が土日祝日の場合は20日以前の最終営業日）の午前9～10時**」という複雑なもので，単純にトリガーを設定するだけでは要件を満たせなかったため，少し工夫しました。

Python＆crontabでも同じようなことをやっているので，よかったら下の記事もご覧ください。

[毎月XX日（土日祝日の場合は前倒し）にcronジョブを実行する - Qiita](https://qiita.com/yagaodekawasu/items/1ecfa00ada7c00c2c44d)

# やり方
GASのトリガーを「**毎日午前9~10時**」で作成し，エントリーポイントに設定する`trigger`関数でその日が定期実行実施日かどうか判定します。判定の仕方は以下の通りです。

1. 当月20日が営業日であれば，20日を実施日とする
1. 営業日でなければ1日ずつ遡り，最初に出現した営業日を実施日とする
1. スクリプトを実行した日付と実施日の日付を比較する
1. 両者が一致すれば`main`関数を呼び出す
1. 一致しなければ何もせずに終了する

# コード
```Javascript
function trigger() {
  var base = new Date(); // インクリメント用Dateオブジェクト
  var fixed; // 算出した実施日のDateオブジェクトを格納する変数
  base.setDate(20);
  if (isHoliday(base)) {
    while (true) {
      base.setDate(base.getDate() - 1);
      if (!(isHoliday(base))) {
        break
      }
    }
  }
  fixed = base;

  var today = new Date();
  if (today.getDate() == fixed.getDate()) {
    main();
  }
}

function isHoliday(day){
  //土日か判定
  var weekInt = day.getDay();
  if(weekInt <= 0 || 6 <= weekInt){
    return true;
  }

  //祝日か判定
  var calendarId = "ja.japanese#holiday@group.v.calendar.google.com";
  var calendar = CalendarApp.getCalendarById(calendarId);
  var events = calendar.getEventsForDay(day);
  if(events.length > 0){
    return true;
  }

  return false;
}

function main() {
  // 省略
}
```
`while`ループは1日から20日まで全て休日だった場合エラーを吐いてしまいそうですが，流石にそんなことは起こらないと思うのでご容赦ください。

土日祝日を判定するコードは以下のサイトで紹介されているものを利用させていただいたので，ここでの解説は省略させていただきます。

[【GAS】土日・祝日・特定休日を判定する｜もりさんのプログラミング手帳](https://moripro.net/gas-check-holiday/)

# 別解
今回はGASなので，特にリソース消費を気にせず毎日トリガー実行でも問題ないと思いますが，[前回](https://qiita.com/yagaodekawasu/items/1ecfa00ada7c00c2c44d)と同様なるべくスクリプトの実行回数を減らそうとしたは以下のような手順になります。

1. （手動）毎月1日に`setTrigger`関数を実行するトリガーを作成する
1. （`setTrigger`）`main`関数を実行する既存のトリガーを削除
1. （`setTrigger`）`main`関数の実行日を算出する
1. （`setTrigger`）算出した日付で`main`関数を実行するトリガーを作成

## コード
```Javascript
function setTrigger() {
  delTrigger();

  var executionDate = new Date();
  // 挙動確認用
  // executionDate.setMonth(10);
  executionDate.setDate(20);
  if (isHoliday(executionDate)) {
    while (true) {
      executionDate.setDate(executionDate.getDate() - 1);
      if (!(isHoliday(executionDate))) {
        break
      }
    }
  }
  executionDate.setHours(9);
  executionDate.setMinutes(0);
  executionDate.setSeconds(0);

  ScriptApp.newTrigger('main').timeBased().at(executionDate).create();
}

// 既存のトリガーを削除
function delTrigger() {
  const triggers = ScriptApp.getProjectTriggers();
  for(const trigger of triggers){
    if(trigger.getHandlerFunction() == "main"){
      ScriptApp.deleteTrigger(trigger);
    }
  }
}

function isHoliday(day){
  // 省略
}

function main() {
  // 省略
}
```
`delTrigger`関数は以下のサイトで紹介されているものをお借りしました。

[Google Apps Scriptで呼び出したトリガーを削除する方法](https://tonari-it.com/gas-trigger-delete/#toc2)

試しに2022年10月の設定で`setTrigger`を実行すると，
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/af0a06b7-e670-ba44-acb9-d4d27225b725.png)
このようにトリガーが作成されました。そのまま設定を2022年11月にして再実行すると，
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/99fd177c-00c5-f700-6664-39f96c11d64b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/d32a2d82-fa9d-2bf9-b053-f1944967a2a5.png)
トリガーの総数は変わりませんが，`main`を呼び出すトリガーの実行日時が変わっており，前のトリガーが削除されて新たなトリガーが作成されたことがわかります。

# おわりに
運用面を考えたら，祝日の判定にGoogleカレンダーを使えるのは安心感があっていいですね。
別解の方はコード行数が増えるものの，日時を秒単位で指定するので実行タイミングがかっちり決まるのはいいかもしれません。
行数が気になる場合は`setTrigger`を別のファイルやプロジェクトに切り出すのもアリですね。
今回紹介した以外にもっとスマートな解法があったら是非教えてください。

# 参考
[【GAS】正確な時間に起動するトリガーの設定 | ネコニキの開発雑記](https://nekoniki.com/20200915_gas_trigger)