【Google Apps Script】その25　GASの自家製Cronで複数スクリプトの定期実行を一元管理する（2/2）

この記事は[Google Apps Scriptを実例交えて基礎からざっくり学ぶ Advent Calendar 2017](https://qiita.com/advent-calendar/2017/gas_beginner)　25日目の記事です。

本アドベントカレンダーは@rt_pの個人プロジェクトですが、筆者は[Ateam Brides Inc. Advent Calendar 2017](https://qiita.com/advent-calendar/2017/a-t-brides)にも参加しています。そちらでも出張版記事を書いているので、覗いていただけると嬉しいです。

# はじめに

昨日の記事の仕組みを利用することで、複数のスクリプトの定期実行を一元管理できるようになりました。

ただ、まだ問題があります。
それは**ChatworkAPIを呼び出す処理が各ファイルに分散**していることです。

# ChatworkAPI処理が分散していると何が問題なのか？

主に以下3点かと思います。

- Chatworkトークンというクレデンシャルな情報を、多数のファイルにばら撒いている状態がよろしくない
- 仮にChatworkトークンが漏洩等で再発行の必要が出てきた時、全スクリプトを修正する必要がある
- ChatworkAPIの仕様変更により、パラメータの渡し方やエンドポイントURLが変わった際も全スクリプトを修正する必要がある

等です。
過去にChatworkAPIはversion1→2になった際、エンドポイントURLがv1からv2に変更され、数ヶ月の移行期間を経てv1エンドポイントURLは使用不可になりました。
今後も同様のことが発生する可能性があります。

# どう一元管理するか

今回の完成図は以下の通りです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/199678/897e93ce-dc86-5702-7f34-f92168d12dfe.png)

- Cronの役割を果たすシートが実行タイミングを監視しスクリプトのAPIを叩く。
- Chatwork通知が必要な場合、APIは投稿してほしいタイトル、本文をJSON形式のレスポンスで返す。
- Cronシートは、受け取ったJSONレスポンスの中にChatworkメッセージ形式があったらChatworkAPIを叩く。メール送信も同様

こうすることで、**ChatworkAPIを叩くロジック及びChatworkAPIトークンはCronシートだけに存在**するので、トークン変更時やAPI仕様変更時、Cronシートのロジック1箇所だけを変更すればOKです。

ちなみに別の方法として、ChatworkAPI処理だけを別ファイルとして保存し、各スクリプトから呼び出す方法もあります。が、ファイルを修正した場合は各スクリプトのバージョンを更新する必要があるようなので、余り根本的な解決にはならない為今回は採用していません。

# Cronスクリプトの修正

昨日作成したスクリプトを修正します。
スクリプトエディタを開き、以下コードに置き換えて実行します。
スクリプトエディタの開き方や*承認が必要です*メッセージが出た際の対処法が分からない場合は
[アドベントカレンダー1日目のHello, world!記事](https://qiita.com/rf_p/items/0535ec495df8f9d2b894)をご参照ください。

```javascript:cron_and_cw.gs
var ALL = '*';
var LIMIT_MATCHES = {'minute': '[0-5]?[0-9]', 'hour': '[01]?[0-9]|2[0-3]', 'day': '0?[1-9]|[1-2][0-9]|3[01]', 'month': '0?[1-9]|1[0-2]', 'week': '[0-6]'};
var MAX_NUMBERS   = {'minute': 59, 'hour': 23, 'day': 31, 'month': 12, 'week': 6}; // 本物のcronは曜日の7を日曜と判定するが、手間なのでmax6とする
var COLUMNS       = ['minute', 'hour', 'day', 'month', 'week'];

function myFunction() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('稼働中');
  var cronList = getCronList(sheet);
  var currentTime = new Date();
  var times  = {
    'minute': Utilities.formatDate(currentTime, 'Asia/Tokyo', 'm'),
    'hour':   Utilities.formatDate(currentTime, 'Asia/Tokyo', 'H'),
    'day':    Utilities.formatDate(currentTime, 'Asia/Tokyo', 'd'),
    'month':  Utilities.formatDate(currentTime, 'Asia/Tokyo', 'M'),
    'week':   Utilities.formatDate(currentTime, 'Asia/Tokyo', 'u')
  };
  for (var i = 1; i < cronList.length; i++) { // スプレッドシートから取得した一行目(key:0)はラベルなので、key1から実行
    executeIfNeeded(cronList[i], times, sheet, i + 1);
  }
}

// シートからCronの一覧を取得し配列で返す
function getCronList(sheet) {
  var lastRow = sheet.getLastRow();
  return sheet.getRange(1, 1, lastRow, 8).getValues(); // 1行ずつ取得すると都度通信が走り重いので、一括で範囲内全てを取得する
}

// 実行すべきタイミングか判定し、必要であれば実行
function executeIfNeeded(cron, times, sheet, row) {
  for (var i in COLUMNS) { // minute, hour, day, month, weekを順番にチェックして全て条件にマッチするようならcron実行
    var timeType = COLUMNS[i];
    var timingList = getTimingList(cron[i], timeType);
    if (!isMatch(timingList, times[timeType])) {
      return false;
    }
  }
  sheet.getRange(row, 11).setValue(new Date()); // 最終リクエスト送信日時
  executeAction(cron);
}

// 中身が*もしくは指定した数字を含んでいるか
function isMatch(timingList, time) {
  return (timingList[0] === ALL || timingList.indexOf(time) !== -1);
}

// getリクエストを送り、chatworkやメール送信を実行する
function executeAction(cron) {
  var url = cron[5];
  var response = UrlFetchApp.fetch(url);
  var json = response.getContentText("UTF-8");
  var jsonData = JSON.parse(json);
  var roomId = cron[6];
  var mailTo = cron[7];
  
  if (jsonData.response == 'success') {
    if (roomId && jsonData.chatwork_posts !== undefined) {
      for (var i in jsonData.chatwork_posts) { // 受け取ったchatwork_posts配列の要素数分だけchatworkに投稿する
        postToChatwork(roomId, jsonData.chatwork_posts[i]['subject'], jsonData.chatwork_posts[i]['body']);
      }
    }
    if (mailTo && jsonData.mail_posts !== undefined) {
      for (var i in jsonData.mail_posts) { // 受け取ったmail_posts配列の要素数分だけメールを送る
        MailApp.sendEmail(mailTo, jsonData.mail_posts[i]['subject'], jsonData.mail_posts[i]['body']);
      }
    }
  } else if (jsonData.response == 'error') {
    Logger.log(jsonData.message);
  }
}

// 文字列から数字のリストを返す
function getTimingList(timingStr, type) {
  var timingList = [];
  if (timingStr === ALL) { // * の時はそのまま配列にして返す
    timingList.push(timingStr);
    return timingList;
  }
  
  var limitPattern  = "(" + LIMIT_MATCHES[type] + ")";
  var numReg   = new RegExp("^" + limitPattern + "$");                      // 単一指定パターン ex) 2
  var rangeReg = new RegExp("^" + limitPattern + "-" + limitPattern + "$"); // 範囲指定パターン ex) 1-5
  var devReg   = new RegExp("^\\*\/" + limitPattern + "$");                 // 間隔指定パターン ex) */10
  var commaSeparatedList = timingStr.split(','); // 共存指定パターン ex) 1,3-5

  commaSeparatedList.forEach(function(value) {
  if (match = value.match(numReg)) { // 単一指定パターンにマッチしたら配列に追加
    timingList.push(toStr(match[1]));
  } else if ((match = value.match(rangeReg)) && toInt(match[1]) < toInt(match[2])) { // 範囲指定パターンにマッチしたら配列に追加
    for (var i = toInt(match[1]); i <= toInt(match[2]); i++) {
      timingList.push(toStr(i));
    }
  } else if ((match = value.match(devReg)) && toInt(match[1]) <= MAX_NUMBERS[type]) { // 間隔指定パターンにマッチしたら配列に追加
    var start = (type == 'day' || type == 'month') ? 1 : 0; // 月と日だけ0が存在しないので1からカウントする
    for (var i = start; i <= MAX_NUMBERS[type] / match[1]; i++) {
      timingList.push(toStr(i * match[1]));
    }
  }
  });
  return timingList;
}

// ifやforの判定を正しく行う為に文字列を10進数int型に変換
function toInt(num) {
  return parseInt(num, 10);
}
// 数値を10進数int型にして文字列に変換。実行タイミング一致判定（indexOf）で必要
function toStr(num) {
  return toInt(num).toFixed();
}

// chatworkに投稿する
function postToChatwork(roomId, subject, body) {
  var token = 'YOUR_TOKEN';
  var body  = '[info][title]' + subject + '[/title]' + body + '[/info]';
  var payload = {
    'body': body
  }
  var headers = {
    'X-ChatWorkToken': token
  }
  var options = {
    'method' : 'POST',
    'payload' : payload,
    'headers' : headers
  }

  var url = 'https://api.chatwork.com/v2/rooms/' + roomId + '/messages';
  var response = UrlFetchApp.fetch(url, options);
}
```

昨日の記事と変わった点としては2箇所です。

`executeAction()`の修正をしています。
具体的には、APIにリクエストを送って終わりではなく、レスポンスによってアクションを行うロジックの追加をしています。

また、そのアクションの一つとしてChatworkAPIを叩く処理をCron側に追加しています。

```javascript
  var token = 'YOUR_TOKEN'; // ここにトークンを入力
```

Chatworkトークンの取得方法は[16日目の記事](https://qiita.com/rf_p/items/3e20e4114a1182deb7bc)を参照してください。

また、Chatworkメッセージ投稿機能を削り、レスポンスを返すサンプルとしては、同じく[16日目の記事](https://qiita.com/rf_p/items/3e20e4114a1182deb7bc)の内容を流用したいと思います。
変更点も含め、ソースを以下に示します。

# 

```javascript:get_feeds.gs
var currentTime = new Date();
var newFeeds = [];

function myFunction() {
  var sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets();
  for (var i in sheets) { // 全シートを順番にチェック
    addNewFeeds(sheets[i]);
  }
}

// フィードの新着チェックを行い、新着があれば配列newFeedsに追加する
function addNewFeeds(sheet) {
  var feeds = getFeeds(sheet);
  for (var i in feeds['feeds']) {
    if (isNew(feeds['feeds'][i][2], feeds['lastCheckTime'])) { // 前回起動時間以降の投稿か判定
      feeds['feeds'][i][3] = sheet.getName();
      newFeeds.push(feeds['feeds'][i]);
    }
  }
}

// 最新のフィードを取得する
function getFeeds(sheet) {
  var values = sheet.getRange(1, 1, 1, 3).getValues(); // getValueを2回実行すると都度通信が入るので、A1:C1を習得
  var url = values[0][0];
  var lastCheckTime = values[0][2]; // C1には前回のタスク実行時刻が入っている
  sheet.getRange('C1').setValue(currentTime);
  var lastRow = sheet.getLastRow();
  var feeds = sheet.getRange(2, 1, lastRow - 1, 3).getValues(); // 一括で範囲内全てを取得する
  return {'feeds': feeds, 'lastCheckTime': lastCheckTime};
}

// 前回のチェック以降の投稿か確認
function isNew(date, lastCheckTime) {
  var postTime = new Date(date);
  return (postTime.getTime() > lastCheckTime.getTime());
}

function doGet(e) {
  var response = {
    'response': 'success',
    'chatwork_posts': [],
  };
  
  try {
    myFunction();
    for (var i in newFeeds) {
      response['chatwork_posts'].push({'subject': newFeeds[i][3], 'body': newFeeds[i][0] + "\r\n" + newFeeds[i][1]});
    }
  } catch (ex) {
    response['response'] = 'error';
    response['message']  = ex;
  }

  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);
}
```

修正としては、ChatworkAPIへのリクエスト処理部分を全て削除。
代わりに`doGet(e)`を追加し、`myFunction()`を実行した上で、JSONを整形して返す処理を加えています。

その後ウェブアプリケーションとして導入し、アクセスすると以下のようなレスポンスが来ます。

![rss.png](https://qiita-image-store.s3.amazonaws.com/0/199678/3b93e244-b67e-30a8-8987-01faea211933.png)

これで、GETリクエストを送ると結果をJSONで返すAPIとなりました。

もしChatworkではなくメールで通知させたい場合は、cron_and_cw.gsのソースを読めば分かるようにレスポンスとして`response['chatwork_posts']`の代わりに**`response['mail_posts']`**を返せばOKです。

# おわりに

25日間に渡るGASアドベントカレンダー、本日この記事にて完結となります。

12/8頃に思い立ち1人で始め、土日2日間で10記事書く等のかなり無茶なスケジュールでしたが、無事にこうやって期日通り終えることができ一安心です。
個人的なことになりますが、こうやって外部へ集中的にアウトプットする機会は初めてでしたが、本当にいい機会＆スキル向上に繋がったと思います。

今回紹介したシステムに関して、少しでもお役に立てると嬉しいです。
また、いいねやコメントをしていただけると非常に嬉しく思います。

GAS以外のテーマかもしれませんが、またこういった機会を取ってみたいなと思います。
ここまで読んでいただき、本当にありがとうございました。

前の記事
[【Google Apps Script】その24　GASの自家製Cronで複数スクリプトの定期実行を一元管理する（1/2）
](https://qiita.com/rf_p/items/a1be4699479026fc50d8)