Google Apps ScriptでBoard APIを使ってデータの入出力してみた【GAS】

2019年くらいにVBAで案件・請求管理サービスであるBoardのAPIを叩いて案件データを取得したことがありました。VBAで捌くにはなかなか骨が折れた記憶があります。

時は流れてまたちょっと使う機会があったので、今回はおなじみのGoogle Apps Scriptでやってみようと請求データの取得を行ってみました。ドキュメントもAPIも対して中身は変わっていないようだったので、チャレンジです。

今回利用するツール等

基本的な流れはVBAの時と全く同じです。ただJSONの扱いの大変なVBAと比較して、JavaScriptで走査する点はとても楽ちんです。しかし、今回は前回の案件データではなく請求データ。期間指定する部分があり、ページネーション処理も当然あります。

いくつか注意ポイントもあります。

VBAからBoard APIを使ってデータの入出力してみた

事前準備

APIトークンとAPIキーの取得

Board APIはよく見かけるOAuth2.0認証を利用した方法ではなく、あらかじめ用意しておいたAPIトークンとAPIキーの2つを送り付けることでAPIの操作を行う事が出来ます。以下にその取得手順を示します。

  1. Boardにログインする
  2. 右上の歯車アイコンをクリックして「API設定」を開く
  3. APIキーが表示されているので控えておく
  4. 新規トークン生成をクリックして、新しいAPIトークンを作成します。
  5. 用途説明を入力、このトークンへ与える権限をチェックして、登録ボタンを押します。権限は必要最低限にて。自分の場合、「案件リストの取得」「案件の取得」「請求リストの取得」の3つとしてます。
  6. APIトークンは設定時の1度しか表示されませんので注意が必要です。この2つを控えておきます。

図:Board API Token取得

スクリプトプロパティに格納

取得したAPIキーおよびAPIトークンは以下のスクリプトプロパティに格納しておき、GAS側で呼び出して取り出します。

  • boardapikey : ここにAPIキーを格納する
  • boardapitoken : ここにAPIトークンを格納する

図:スクリプトプロパティに格納しておく

リクエスト制限

API自体追加料金なしで利用は可能ですが、連続リクエストには制限があります。この制限を理解せずにコードを組んでしまうと、場合によっては制限を超えた分について処理されずにエラーとなってしまいます。上手に制限を回避するようにコードを書く必要があります。主な注意すべきリクエスト制限は以下の通りです。

  • 1日3000リクエストまで。なるべく一発で処理できるようにAPIの利用回数には気を付ける必要があります。
  • 3リクエスト/秒まで。但し、100リクエストまでは制限なく呼び出し可能。ただし、この100は全部で100であり、1回あたりではありません。よって、頻繁に3リクエスト/秒を超えるリクエストを投げてしまうと、この100を消費してしまいます。なるべく、ウェイトで調整して超えないようにしましょう。
  • 上記の100リクエストは一定期間利用されないと100まで補充される仕組みです。
  • リクエスト基準の1日は、UTC基準でありJSTでないので注意。
  • 制限をオーバーすると、秒間リクエストを超えると429 - Too Many Requestsが返ってきます。1日の制限リクエストを超えるとLimit Exceededが返ってきます。エラー処理も実装しておくと良いでしょう。
  • データの取得などで、1回のリクエストで取得できる件数は最大100件まで。それを超えた場合、ページネーションされ、ページ指定を繰り返してデータを取得する必要があります。なるべく取得する範囲を絞ってリクエストを投げましょう。

ソースコード

Board APIはGETでリクエストするので、URLに対してパラメータをつなげてリクエストする必要があります。この時に必要な最低限のパラメータが

  • invoice_date_gteq : 開始日付。yyyy-mm-ddの形式で指定する
  • invoice_date_lteq : 終了日付。同じ形式で指定する
  • per_page : 1ページあたりのレコード数。最大100件までなので、100を指定しておく。
  • page : 開始ページ番号。1固定指定する。

リクエスト時のオプションでx-api-keyとしてAPIキーを割り当て、Authorization BearerにAPIトークンを指定します。

一番の重要ポイントは最初のリクエスト時の返り値のヘッダにx-total-countという総レコード数を取得しておくこと。これをper_pageで割った数値をもってループの上限回数とし、回します。1リクエスト1秒のウェイトを入れて429エラーを回避する。

最後に返り値のレコードの中から必要な項目だけを配列にして、スプシに洗替えで書き出しをしています。この時、管理番号の無いものは今回は処理をスルーするようにしています。

//Board APIを叩いて請求データを取得する
function getBoardApiSeikyu(){
  //APIkeyとTokenを取得
  const prop = PropertiesService.getScriptProperties();
  const apikey = prop.getProperty("boardapikey");
  const apitoken = prop.getProperty("boardapitoken");

  //key, tokenの設定チェック
  if (!apitoken || !apikey) {
    console.log('エラー: APIトークンまたはAPIキーが設定されていません。');
    console.log('スクリプトプロパティに「boardapitoken」と「boardapikey」を設定してください。');
    return false;
  }

  //取得期間の設定
  const fromDate = '2024-01-01';
  const today = new Date();
  const toDate = Utilities.formatDate(today, 'JST', 'yyyy-MM-dd');

  //リクエストエンドポイントを構築
  const baseUrl = boardpoint + "invoices";

  //取得用の変数
  let allInvoices = [];   //請求データ格納用
  let page = 1;           //現在のページ
  let totalPages = 1;     //初期化の為の仮の値
  const perPage = 100;    //1リクエストあたり取得する件数

  //ページネーションを考慮して全データを取得
  try {
    do {
      //GETクエリパラメータを構築
      const queryParams = {
        invoice_date_gteq: fromDate,
        invoice_date_lteq: toDate,
        per_page: perPage,
        page: page
      };

      //オブジェクトの各キーと値をエンコードしながらクエリ文字列を生成
      const queryString = Object.keys(queryParams)
        .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
        .join('&');

      //リクエストURLを構築
      const url = `${baseUrl}?${queryString}`;

      //リクエストオプション
      const options = {
        'method': 'get',
        'headers': {
          'Authorization': `Bearer ${apitoken}`,
          'x-api-key': apikey,
          'Content-Type': 'application/json'
        },
        'muteHttpExceptions': true
      };

      //HTTPリクエストを実行
      console.log(`リクエストを送信中... Page: ${page}`);
      const response = UrlFetchApp.fetch(url, options);
      const responseCode = response.getResponseCode();

      //ステータス200なら続けて値を取得
      if (responseCode === 200) {
        //レスポンスデータを取り出し
        const data = JSON.parse(response.getContentText());

        //ヘッダから総ページ数を取得する
        if (page === 1) {
          //レスポンスヘッダを取り出す
          const headers = response.getAllHeaders();
          
          //レコード数を取得する
          const totalCount = parseInt(headers['x-total-count'], 10) || 0;
          
          //totalPagesを算出する
          if (totalCount > 0) {
            // 全件数を1ページあたりの件数で割り、小数点以下を切り上げて総ページ数を計算
            totalPages = Math.ceil(totalCount / perPage);
          } else {
            totalPages = 0; // データが0件ならループは1回で終了
          }

          console.log(`総件数: ${totalCount}, 総ページ数: ${totalPages}`);
        }

        //配列にデータを結合
        if (Array.isArray(data) && data.length > 0) {
          //配列データを結合する
          allInvoices = allInvoices.concat(data);
          console.log(`レコード数合計: ${allInvoices.length}`)
        }
        
        //ページカウントアップ
        page++;
      } else {
        console.log(`エラーが発生しました。 Status: ${responseCode}, Body: ${response.getContentText()}`);
        return false;
      }

      //1ページリクエスト1秒のウェイトを入れる
      if (page <= totalPages) {
         Utilities.sleep(1000);
      }
    } while (page <= totalPages);     //ページネーションが残ってる場合は処理を継続

    //ページ取得完了
    console.log(`データの取得が完了しました。合計 ${allInvoices.length} 件の請求データを取得しました。`);
    
    //書き出しデータのチェック
    if (allInvoices.length > 0) {
      //シート書き出し指示
      let result = writeDataToSheet(allInvoices);

      //終了処理
      return result;
    } else {
      console.log('書き出すデータがありませんでした。');
      return false;
    }
  } catch (e) {
    console.log(`予期せぬエラーが発生しました: ${e.message}`);
    return false;
  }
}

//スプシに書き出し
function writeDataToSheet(invoices) {
  try {
    //取得するシートとシートクリアの実行
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("board");
    sheet.getRange("A2:S").clearContent();

    //書き出し用配列を用意
    const dataForSheet = []; // 書き出し用の空の配列を準備
    for (let i = 0; i < invoices.length; i++) {
      //レコードを一個取り出す
      const inv = invoices[i]; // 現在処理中の請求書データを取得

      //管理番号が未入力のものがスルーする
      if(inv.management_no == "" || inv.management_no == "タスク登録なし" || inv.management_no == undefined){
        continue;
      }

      //1行分のデータを配列として作成
      const rowData = [
        Utilities.getUuid(),
        inv.management_no,
        inv.project_id,
        inv.id,
        inv.name,
        inv.client.name,
        inv.total,       
        inv.tax,            
        Number(inv.total) + Number(inv.tax),   //合計金額算出
        inv.invoice_date,
        inv.paid_date,
      ];
      
      //書き出し用配列にpush
      dataForSheet.push(rowData); // 書き出し用の配列に行データを追加
    }

    //シートへ書き出し
    let lastColumn = dataForSheet[0].length; //カラムの数を取得する
    let lastRow = dataForSheet.length;       //行の数を取得する
    sheet.getRange(2,1,lastRow,lastColumn).setValues(dataForSheet);
    console.log(`${dataForSheet.length} 件のデータをシートに書き出しました。`);
    return true;

  } catch (e) {
    console.log(`シートへの書き込み中にエラーが発生しました: ${e.message}`);
    return false;
  }
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)