GAS×OpenAI APIの6分タイムアウトを回避する実装術

AIツール・自動化
スポンサーリンク
スポンサーリンク

GASの「6分制限」でChatGPT処理が途切れる原因

Google Apps Script(GAS)を使ってスプレッドシート上のデータをOpenAI API(ChatGPT)で一括処理しようとした際、「Exceeded maximum execution time(実行時間の最大値を超過しました)」というエラーに遭遇したことはないでしょうか。

GASの無料アカウントには「1回のスクリプト実行時間は最大6分まで」という厳格な仕様があります。OpenAI APIは1回のリクエストに対して応答まで数秒から数十秒かかることが多く、数十行〜数百行のリストをfor文などでループ処理すると、あっという間にこの6分の壁に衝突します。

途中で処理が強制終了されると、「どこまでAPIにリクエストを送ったのか」「どの行から再開すればいいのか」を手動で確認し、スプレッドシートの行を削除・調整してから再度スクリプトを実行する羽目になります。大量のデータ処理を自動化するはずが、かえって運用コストを増大させる原因になってしまうのです。

PropertiesServiceとトリガーで自動再開させる仕組み

この6分制限を回避し、数百行のデータでも完全自動で処理しきるための最適解が、「PropertiesService(スクリプトプロパティ)」と「時間主導型トリガー」の組み合わせです。

仕組みは以下の3ステップで完結します。

1. 実行時間の監視と中断

スクリプトの開始時刻を記録しておき、ループのたびに経過時間をチェックします。5分(約300,000ミリ秒)など、制限の6分に到達する直前の時間を閾値に設定し、超過した時点で意図的にループを抜けます。

2. 進捗の保存とトリガーのセット

ループを抜けた際、「次に何行目から処理を再開するか」をPropertiesServiceに保存します。同時に、1分後に同じ関数を再実行するトリガーをGAS上で動的に生成し、現在の処理を安全に終了させます。

3. 未処理行からの再開と完了処理

1分後にトリガーが発動すると、スクリプトはPropertiesServiceから保存された行番号を読み取り、未処理の行から処理を再開します。すべての行の処理が終わったら、セットしたトリガーとプロパティの値を削除して後片付けを行います。

このリレー方式を実装することで、GASの制限を気にすることなく、擬似的な無限ループ処理が可能になります。

【コピペOK】未処理行から再開する完全版GASコード

スプレッドシートのA列にあるテキストをOpenAI APIに送信し、B列に回答を出力し続ける具体的なコードです。APIキーとシート名を変更するだけでそのまま使用できます。

JavaScript

const OPENAI_API_KEY = 'YOUR_API_KEY'; // ご自身のAPIキーを設定
const SHEET_NAME = 'シート1'; // 対象のシート名
const TIME_LIMIT = 5 * 60 * 1000; // 5分(ミリ秒)でタイムアウト判定

function processOpenAIWithResume() {
  const startTime = new Date().getTime();
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const lastRow = sheet.getLastRow();
  
  // プロパティから再開する行番号を取得(初回は2行目から想定)
  const properties = PropertiesService.getScriptProperties();
  let startRow = parseInt(properties.getProperty('NEXT_ROW')) || 2;
  
  for (let i = startRow; i <= lastRow; i++) {
    // 経過時間のチェック
    const currentTime = new Date().getTime();
    if (currentTime - startTime > TIME_LIMIT) {
      // 5分を超えたら次の行番号を保存してトリガーをセット
      properties.setProperty('NEXT_ROW', i.toString());
      setNextTrigger();
      return; // 処理を中断
    }
    
    // A列のテキストを取得
    const prompt = sheet.getRange(i, 1).getValue();
    if (!prompt) continue;
    
    // OpenAI API呼び出し
    const response = callOpenAI(prompt);
    
    // B列に結果を書き込み
    sheet.getRange(i, 2).setValue(response);
  }
  
  // すべて完了した場合は後片付け
  properties.deleteProperty('NEXT_ROW');
  deleteTriggers();
}

// OpenAI APIを呼び出す関数
function callOpenAI(prompt) {
  const url = 'https://api.openai.com/v1/chat/completions';
  const payload = {
    model: 'gpt-3.5-turbo',
    messages: [{ role: 'user', content: prompt }]
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': 'Bearer ' + OPENAI_API_KEY },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  
  try {
    const res = UrlFetchApp.fetch(url, options);
    const json = JSON.parse(res.getContentText());
    return json.choices[0].message.content;
  } catch (e) {
    return 'Error: ' + e.message;
  }
}

// 1分後に再実行するトリガーをセットする関数
function setNextTrigger() {
  deleteTriggers(); // 重複を防ぐため既存のトリガーを削除
  ScriptApp.newTrigger('processOpenAIWithResume')
    .timeBased()
    .after(60 * 1000) // 1分後
    .create();
}

// 自身の関数に紐づくトリガーを削除する関数
function deleteTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === 'processOpenAIWithResume') {
      ScriptApp.deleteTrigger(trigger);
    }
  });
}

実装のポイント

  • TIME_LIMITを5分(300,000ミリ秒)に設定することで、6分の強制終了が起きる前に安全に処理を退避させています。
  • APIのエラーなどで処理が落ちないよう、callOpenAI関数内にtry...catchを記述し、エラーメッセージをセルに出力して次の行へ進むようにしています。
  • deleteTriggers()を完了時だけでなくトリガーセット前にも呼び出すことで、トリガーが無限増殖するのを防いでいます。