GASとchatwork APIで容量の大きいファイル検出ツールを作った

#chatworkにファイルアップロードしすぎ問題
会社でchatworkを使っているのだけど、[ストレージ容量の制限](https://help.chatwork.com/hc/ja/articles/210441287-%E5%90%84%E3%83%97%E3%83%A9%E3%83%B3%E3%81%AE%E3%82%B9%E3%83%88%E3%83%AC%E3%83%BC%E3%82%B8%E5%AE%B9%E9%87%8F%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)があるため、ファイルのアップロードし過ぎる人がいると、すぐストレージ容量の上限に達してしまい困っていた。
「容量がいっぱいなので、ファイルを消してください！」とアナウンスしても消してくれる人が少ないので、
・一定容量のファイルを自動抽出
・消して欲しいファイルを個人に自動で通知する(今回はスプレッドシートへの書き出しまで)
ことで解決することにした。

アウトプットイメージ
![スクリーンショット 2018-08-07 8.37.46.png](https://qiita-image-store.s3.amazonaws.com/0/78136/a8417d82-3a8d-9a64-a178-edcbf7a77fb6.png)
※上記画像の数字は乱数で生成したものです

#実装の概要
chatbot用のアカウントがchatworkの各部屋に入っていることを前提として、
・全部屋の情報を取得(GET /rooms)
・各部屋のファイル情報を取得(GET /rooms/{room_id}/files)
・閾値以上のファイルを抽出
・ファイル情報をスプレッドシートに記載
の処理を行う。
この処理を定期バッチで30分おきに実行している。
※30分おきに実行している理由は後述
参考:[chatwork apiのエンドポイント](http://developer.chatwork.com/ja/endpoint_rooms.html)

#ハマったこと
##chatwork APIのリクエスト数制限にすぐ引っかかる
chatwork APIは[5分に100リクエスト](http://developer.chatwork.com/ja/endpoints.html)の制限があるため、**chatworkのAPI制限にすぐ引っかかってしまう**。
ファイル情報の取得には上記の通り、
・全部屋の情報を取得(1リクエスト)
・各部屋のファイル情報を取得(部屋の数分のリクエスト)
必要なため、100部屋に入っていれば1回の実行で制限を超えてしまう。

##ファイル情報がリクエストのたびに違うものが返ってくる
ファイル情報取得APIの説明に
>チャットのファイル一覧を取得 (※100件まで取得可能。今後、より多くのデータを取得する為のページネーションの仕組みを提供予定)

と書いてあるが、リクエストのたびにレスポンスが違うし、100件以上ある場合は1リクエストでは足りない。
繰り返しリクエストをした結果を合算したところ、全部屋の情報が取得できることは分かった。

#解決策
##1回の実行で取得する部屋数を4分の1にした
>・各部屋のファイル情報を取得(部屋の数分のリクエスト)

のリクエスト数がボトルネックになるので、
・0-3の乱数を取得
・取得した部屋のroomIdを4で割った余りが乱数と一致したものを取得対象
とすることで、1回の実行で取得する量を減らした。

##頻度高く定期実行する仕組みにした
・1回で4分の1の部屋にしか実行できない
・リクエストのたびにレスポンスが違う
欠点はあるが、何回も実行していれば半日以内には全ファイル情報を取得できることが分かった。
リクエスト数の制限に引っかからないように30分に1回程度にしている。

#実装
##必要なライブラリ
Chawork APIを投げるクライアントとして[ChatWorkClient](https://script.google.com/macros/library/versions/d/1nf253qsOnZ-RcdcFu1Y2v4pGwTuuDxN5EbuvKEZprBWg764tjwA5fLav)を使っているので、
GASの編集画面でリソース>ライブラリから上記ChatWorkClientを追加する必要がある。
##使うための設定
書き出したいspreadsheetのスクリプトに、下記スクリプトを貼り付ける。(tokenは使いたいものに置き換える)
main関数を30分おきに実行するようトリガーを設定する。
spreadsheetの1行目に
| chatworkId | 名前 | ファイルID | ファイル名 | アップロード日 | 容量(MB) | リンク |
を書いておくと見やすい。
冒頭のtotalShardを変えれば、1回の実行で実行する部屋の数を変えられる。
fileSizeThresholdを変えればスプレッドシートへ記載するファイルの容量下限値を変えられる。

```javascript

//　1回で全部屋の何分の1を実行するか(例:4の場合,4分の1の部屋に対して実行する。chatwork apiの制限回避が目的)
var totalShard = 4;
// 通知対象の閾値(byte)
var fileSizeThreshold = 1000 * 1000 * 10;
// 取得対象のchatworkアカウントのapi。このアカウントが所属している部屋の情報が抽出される。
var chatworkToken = "ここに対象のchatworkアカウントのtoken";
// chatworkClient
var client = ChatWorkClient.factory({'token': chatworkToken});

/**
 * このメソッドを定期実行することでスプレッドシートに記載される
 */
function main() {
  var targetShard = Math.floor(Math.random() * totalShard) + 1;
  getExeedFileAndWriteOnSpreadsheet(targetShard);
}

/**
 * 閾値を超えたファイルを取得し、スプレッドシートに記載します
 */
function getExeedFileAndWriteOnSpreadsheet(targetShard) {
  var roomIdList = getRoomIds(client);
  var targetRoomIdList = filterRoomIds(roomIdList, totalShard, targetShard);
  // 部屋に紐づくファイル情報を取得
  var fileInfos = getFileListFromRoomIds(targetRoomIdList);
  // 通知するファイル情報を選別
  var notifyfileList = getNotifyfileList(fileInfos, fileSizeThreshold);
  Logger.log(notifyfileList);
  // スプレッドシートに記載
  writeOnSpreadSheet(notifyfileList);
}

/**
 * 取得結果をスプレッドシートに記載します
 */
function writeOnSpreadSheet(notifyfileList) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheets()[0];
  // 既にスプレッドシートに存在しているファイルIDの取得
  var sheet = SpreadsheetApp.getActiveSheet();
  var lastRow = sheet.getLastRow() - 1;
  if(lastRow < 2) {
   lastRow = 2
  }
  var existFileIdList = sheet.getRange(2, 3, lastRow).getValues();
  // 記載処理
  var dataList = getNotificateForSpreadsheet(notifyfileList, existFileIdList);
  
  if(dataList === undefined || dataList.length == 0) {
   //　記載するデータがない場合
   return;
  }
  sheet.getRange(sheet.getLastRow()+1, 1, dataList.length, dataList[0].length).setValues(dataList);
}

/**
 * roomIdのうちシャードが合致したroomIdのみ抽出して返します。
 */
function filterRoomIds(roomIdList, totalShard, targetShard) {
  var filteredList = [];
  for(i = 0 ; i < roomIdList.length; i++) {
    var shard = roomIdList[i] % totalShard + 1;
    
    if(shard == targetShard) {
      filteredList.push(roomIdList[i]);
    }
  }
  return filteredList;
}


/**
 * ユーザーが所属する全部屋のroomIdのリストを返します
 */
function getRoomIds(client) {
  var rooms = client.getRooms();
  var roomIds = [];
  
  rooms.sort(function(a, b){
    var keyA = a.file_num;
        keyB = b.file_num;
    // Compare the 2 dates
    if(keyA < keyB) return 1;
    if(keyA > keyB) return -1;
    return 0;
  });
  
  for(i = 0 ; i < rooms.length; i++) {
    var room = rooms[i];
    if(room.file_num > 10) {
      // fileが一定数ある部屋のみを対象とする
      roomIds.push(room.room_id);
    }
    
  }
  shuffleArray(roomIds);
  return roomIds;
}

/**
 * 配列をシャッフルします
 */
function shuffleArray(array) {
    for (var i = array.length - 1; i > 0; i--) {
        var j = Math.floor(Math.random() * (i + 1));
        var temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
}

/**
 * 閾値以上の容量のファイルを返します
 */
function getFileListFromRoomIds(roomIdList) {
  var resultList = [];
  for(i = 0 ; i < roomIdList.length; i++) {  
    var notifyfileList = getFileList(roomIdList[i]);
    resultList = resultList.concat(notifyfileList);
  }
  return resultList;
}

/**
 * 閾値以上の容量のファイルを返します
 */
function getNotifyfileList(fileInfos, fileSizeThreshold) {
 var notifyfileList = [];
 for(i = 0 ; i < fileInfos.length; i++) {
    var fileInfo = fileInfos[i];
    if (fileInfo.filesize < fileSizeThreshold) {
      // 閾値以下のファイルは出力しない
      continue;
    }
    notifyfileList.push(fileInfo);
  }
  return notifyfileList;
}

/**
 * room_idに紐づくファイルリストを返します
 *
 * ファイル情報にroom_idを付与します
 */
function getFileList(roomId) {
  var headers = {
    "X-ChatWorkToken" : chatworkToken,
  };
  var options =
      {
        "method" : "get",
        "headers": headers,
      };
  if(roomId == null || roomId == "undefined") {
    return [];
  }
  var response = UrlFetchApp.fetch("https://api.chatwork.com/v2/rooms/" + roomId + "/files", options).getContentText();
  if(response == null || response == "undefined" || response == []) {
    return [];
  }
  var fileList = JSON.parse(response);
  for (i = 0 ; i < fileList.length; i++) {
    var file = fileList[i];
    file.roomId = roomId;
  }
  return fileList;
}

/**
 * room_idとmessage_idを元にメッセージへのリンクを返します。
 */
function getMessageURL(room_id, message_id) {
  return "https://kcw.kddi.ne.jp/#!rid" + room_id + "-" + message_id;
}

/**
 * Spreadsheet記載用の通知するメッセージを返します
 */
function getNotificateForSpreadsheet(fileList, existFileIdList){
  var result = [];
  for(i = 0 ; i < fileList.length; i++) {
    var fileInfo = fileList[i];
    
    if(contains(existFileIdList, fileInfo.file_id)) {
     //　記載済みなので何もしない 
     continue;
    }
    var fileInfoForSpreadsheet = [];
    fileInfoForSpreadsheet.push(fileInfo.account.account_id);
    fileInfoForSpreadsheet.push(fileInfo.account.name);
    fileInfoForSpreadsheet.push(fileInfo.file_id);
    fileInfoForSpreadsheet.push(fileInfo.filename);
    fileInfoForSpreadsheet.push(new Date(fileInfo.upload_time * 1000));
    fileInfoForSpreadsheet.push(Math.floor(fileInfo.filesize / 1000 / 1000));
    fileInfoForSpreadsheet.push(getMessageURL(fileInfo.roomId, fileInfo.message_id));
    result.push(fileInfoForSpreadsheet);
  }
  return result;
}

/**
 * listにvalueが含まれるかを返します
 */
function contains(list, value) {
  for(var i in list) {
    if(list[i] == value) {
     return true;
    }
  }
  return false;
}
```

##実装上工夫したこと
###すでにspreadsheetに記載されたファイル情報は二重に書き込まない
```javascript
var existFileIdList = sheet.getRange(2, 3, lastRow).getValues();
```

```javascript
/**
 * Spreadsheet記載用の通知するメッセージを返します
 */
function getNotificateForSpreadsheet(fileList, existFileIdList){
  var result = [];
  for(i = 0 ; i < fileList.length; i++) {
    var fileInfo = fileList[i];
    
    if(contains(existFileIdList, fileInfo.file_id)) {
     //　記載済みなので何もしない 
     continue;
    }
    var fileInfoForSpreadsheet = [];
    fileInfoForSpreadsheet.push(fileInfo.account.account_id);
    fileInfoForSpreadsheet.push(fileInfo.account.name);
    fileInfoForSpreadsheet.push(fileInfo.file_id);
    fileInfoForSpreadsheet.push(fileInfo.filename);
    fileInfoForSpreadsheet.push(new Date(fileInfo.upload_time * 1000));
    fileInfoForSpreadsheet.push(Math.floor(fileInfo.filesize / 1000 / 1000));
    fileInfoForSpreadsheet.push(getMessageURL(fileInfo.roomId, fileInfo.message_id));
    result.push(fileInfoForSpreadsheet);
  }
  return result;
}

/**
 * listにvalueが含まれるかを返します
 */
function contains(list, value) {
  for(var i in list) {
    if(list[i] == value) {
     return true;
    }
  }
  return false;
}
```
spreadsheet上のfileIdを取得して、すでに記載済みのファイルだったら記載しないようにした。
配列に含まれているかどうかをindexOfでやろうとしたけど判定が上手くいかず、メソッド作って冗長な書き方をしている。
いい書き方があれば教えてください。