【GASコード完全版】手書き家計簿・レシートをOCRとGeminiで読み取り、スプレッドシートへ自動登録する方法(第三回)

【GASコード完全版】手書き家計簿・レシートをOCRとGeminiで読み取り、スプレッドシートへ自動登録する方法
第3回/全4回
🤖 AI活用レベル Lv.4獲得可能XP 100 AI×GAS×Google Workspace
※この記事で使用する店舗名、日付、金額、フォルダID、スプレッドシートID、APIキーなどは、すべて説明用の架空例です。実際の検証データや個人情報は掲載していません。
📊 学習XP:0 / 100
🏅 バッジ:🎧耳から学習✅実装完了🔍深掘り🎯クイズ挑戦👑全問正解
家計簿レシートOCR自動化システム実装ガイド完全版 Google Apps Script×Gemini APIで構築する自動化ワークフロー
🎧 この記事を音声で聞く(作業しながらの学習にどうぞ)

再生速度:1.0x1.25x1.5x2.0x

前回は、家計簿OCR自動化に必要な次の環境を準備しました。

Google Driveの親フォルダ/Googleスプレッドシート/独立型Google Apps Script/Gemini APIキー/Google Drive API/テスト用のレシート・手書き家計簿

今回は、実際に動作確認したGASコードを、ファイルごとに全文掲載します。

完成すると、次の処理を行えるようになります。

手書き家計簿・レシートを撮影Google Driveの「01_処理待ち」へ保存Google Drive OCRで文字を抽出原本画像・PDFとOCR原文をGeminiへ送信日付・店舗・内容・カテゴリ・金額・支払方法を整理Googleスプレッドシートへ自動登録処理済みまたは要確認フォルダへ移動
入力Google Drive処理待ちへ保存 抽出Google Drive OCRで文字抽出 解析Gemini 3.1 Flash-Liteが構造化 登録家計データへ自動入力 整理処理済み要確認へ自動仕分けの5ステップ図

今回使用するGoogle Drive拡張サービスは、Apps ScriptからDrive APIを利用するための機能です。使用前に、Apps Scriptのサービスまたはappsscript.jsonで有効化する必要があります。

Gemini 3.1 Flash-Liteは、テキスト、画像、PDFなどの入力と構造化出力に対応しています。ただし、JSON形式が正しくても、日付や金額の内容まで正しいとは限りません。Google公式も、構造化出力後に値を検証するよう案内しています。

そのため、今回の仕組みでは自動登録後も、原本と金額を人が確認します。

今回作成するGASファイル

Apps Scriptの左側にある「+」から、次のスクリプトファイルを作成してください。

Config.gs/Setup.gs/OCR.gs/Gemini.gs/Main.gs/Spreadsheet.gs/Utils.gs

また、プロジェクト設定から表示した次のファイルも使用します。

appsscript.json

ファイル追加時は、入力欄にConfigと入力します。Apps Script側で.gsが付くため、Config.gsと入力すると、環境によってはConfig.gs.gsと表示されることがあります。

Config.gsとSetup.gsが設定中枢 OCR.gsが抽出エンジン Gemini.gsがAI頭脳 Main.gsがオーケストレーター Spreadsheet.gsが出力 Utils.gsがデータ補正という7ファイルの役割分担図

1.Config.gs

このファイルでは、フォルダID、スプレッドシートID、Geminiモデル、カテゴリなどを設定します。

次の2か所を、自分のIDへ変更してください。

ROOT_FOLDER_ID: 'ここにGoogle Driveの親フォルダID',
SPREADSHEET_ID: 'ここにスプレッドシートID',

APIキーは、このコードには入力しません。

Config.gs
/**
 * 家計OCR自動化
 * Config.gs
 *
 * フォルダ、スプレッドシート、Gemini、
 * OCR、カテゴリなどの基本設定です。
 */
const CONFIG = Object.freeze({
  /*
   * 読者自身のIDへ変更してください。
   */
  ROOT_FOLDER_ID: 'ここにGoogle Driveの親フォルダID',
  SPREADSHEET_ID: 'ここにGoogleスプレッドシートID',

  /*
   * Gemini設定
   */
  GEMINI_MODEL: 'gemini-3.1-flash-lite',
  GEMINI_API_KEY_PROPERTY: 'GEMINI_API_KEY',

  /*
   * OCR・時刻設定
   */
  OCR_LANGUAGE: 'ja',
  TIME_ZONE: 'Asia/Tokyo',

  /*
   * 1回の実行で処理する最大ファイル数
   */
  MAX_FILES_PER_RUN: 3,

  /*
   * Geminiへ直接送るファイルサイズの上限
   * 今回は安全側に12MBとしています。
   */
  MAX_FILE_BYTES: 12 * 1024 * 1024,

  /*
   * Geminiへ渡すOCR文字数の上限
   */
  OCR_TEXT_MAX_CHARS: 30000,

  /*
   * 自動作成するフォルダ
   */
  FOLDERS: {
    WAITING: '01_処理待ち',
    PROCESSED: '02_処理済み',
    REVIEW: '03_要確認',
    OCR_DOCUMENTS: '04_OCRドキュメント'
  },

  /*
   * 自動作成するシート
   */
  SHEETS: {
    SETTINGS: '00_設定',
    OCR: '01_OCR原文',
    DATA: '02_家計データ',
    SUMMARY: '03_月次集計',
    LOG: '04_検証ログ'
  },

  /*
   * 処理対象のファイル形式
   */
  ALLOWED_MIME_TYPES: [
    'image/jpeg',
    'image/png',
    'image/webp',
    'application/pdf'
  ],

  /*
   * 家計簿のカテゴリ
   */
  CATEGORIES: [
    '食費',
    '日用品',
    '住居費',
    '水道光熱費',
    '通信費',
    '交通費',
    '医療費',
    '教育費',
    '衣服費',
    '娯楽費',
    '交際費',
    '保険料',
    '税金',
    '特別支出',
    'その他'
  ],

  /*
   * 確認状態
   */
  CONFIRMATION_STATES: [
    '未確認',
    '確認済み',
    '要確認',
    '対象外'
  ]
});


/**
 * 各シートの見出しです。
 */
const SHEET_HEADERS = Object.freeze({
  OCR: [
    '受付ID',
    '撮影日',
    '書類種類',
    '原本URL',
    '撮影条件',
    'OCR原文',
    'AI整形結果',
    '処理状態',
    '備考'
  ],

  DATA: [
    '取引ID',
    '利用日',
    '店舗・相手先',
    '内容',
    'カテゴリ',
    '金額',
    '支払方法',
    '原本URL',
    'OCRドキュメントURL',
    '取込方法',
    '確認状態',
    '対象月',
    '確認事項',
    '登録日時'
  ],

  LOG: [
    '処理ID',
    '処理日時',
    'ファイル名',
    '書類種類',
    'ファイル形式',
    'ファイルサイズ',
    'OCR結果',
    'AI抽出結果',
    '抽出件数',
    '確認状態',
    '処理時間',
    'エラー内容',
    '原本URL'
  ]
});

2.Setup.gs

このファイルでは、必要なフォルダとシートを自動作成します。

今回の記事では、スプレッドシートに紐づけない独立型Apps Scriptを使用します。そのため、SpreadsheetApp.getUi()は使用せず、実行結果はログと00_設定シートへ記録します。

Setup.gs
/**
 * 家計OCR自動化
 * Setup.gs
 *
 * 独立型Apps Script対応版です。
 * SpreadsheetApp.getUi()は使用しません。
 */


/**
 * 初期設定を実行します。
 *
 * ・サブフォルダ作成
 * ・フォルダID保存
 * ・シート作成
 * ・見出し設定
 * ・入力規則設定
 * ・月次集計式設定
 */
function setupSystem() {
  console.log('====================================');
  console.log('家計OCR自動化の初期設定を開始します。');
  console.log('====================================');

  try {
    const rootFolder = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);

    console.log('親フォルダ取得完了:' + rootFolder.getName());

    const folders = ensureSubFolders_(rootFolder);

    console.log('サブフォルダの準備が完了しました。');

    const properties = PropertiesService.getScriptProperties();

    properties.setProperties(
      {
        FOLDER_WAITING_ID: folders.waiting.getId(),
        FOLDER_PROCESSED_ID: folders.processed.getId(),
        FOLDER_REVIEW_ID: folders.review.getId(),
        FOLDER_OCR_ID: folders.ocr.getId()
      },
      false
    );

    console.log('フォルダIDをスクリプトプロパティへ保存しました。');

    const spreadsheet = getSpreadsheet_();

    console.log('スプレッドシート取得完了:' + spreadsheet.getName());

    ensureSystemSheets_(spreadsheet);

    console.log('必要なシートの作成・取得が完了しました。');

    writeSettingsSheet_(spreadsheet, folders);
    configureDataValidation_(spreadsheet);
    configureSummarySheet_(spreadsheet);
    applySheetFormatting_(spreadsheet);

    SpreadsheetApp.flush();

    writeSetupStatus_(spreadsheet, '初期設定完了', '');

    console.log('====================================');
    console.log('初期設定が完了しました。');
    console.log('');
    console.log('次の作業:testGeminiConnectionを実行してください。');
    console.log('====================================');

    return {
      status: '成功',
      waitingFolderId: folders.waiting.getId(),
      processedFolderId: folders.processed.getId(),
      reviewFolderId: folders.review.getId(),
      ocrFolderId: folders.ocr.getId()
    };
  } catch (error) {
    const errorMessage = stringifyError_(error);

    console.error('初期設定でエラーが発生しました。\n' + errorMessage);

    try {
      const spreadsheet = getSpreadsheet_();
      writeSetupStatus_(spreadsheet, '初期設定エラー', errorMessage);
    } catch (statusError) {
      console.error('初期設定エラーの記録にも失敗しました。\n' + stringifyError_(statusError));
    }

    throw error;
  }
}


/**
 * 必要なサブフォルダを作成または取得します。
 */
function ensureSubFolders_(rootFolder) {
  if (!rootFolder) {
    throw new Error('親フォルダを取得できませんでした。');
  }

  return {
    waiting: getOrCreateChildFolder_(rootFolder, CONFIG.FOLDERS.WAITING),
    processed: getOrCreateChildFolder_(rootFolder, CONFIG.FOLDERS.PROCESSED),
    review: getOrCreateChildFolder_(rootFolder, CONFIG.FOLDERS.REVIEW),
    ocr: getOrCreateChildFolder_(rootFolder, CONFIG.FOLDERS.OCR_DOCUMENTS)
  };
}


/**
 * 同名フォルダがあれば使用し、
 * なければ新規作成します。
 */
function getOrCreateChildFolder_(parentFolder, folderName) {
  const folders = parentFolder.getFoldersByName(folderName);

  if (folders.hasNext()) {
    const existingFolder = folders.next();
    console.log('既存フォルダを使用:' + folderName);
    return existingFolder;
  }

  const newFolder = parentFolder.createFolder(folderName);
  console.log('フォルダを新規作成:' + folderName);
  return newFolder;
}


/**
 * 必要なシートを作成または取得します。
 */
function ensureSystemSheets_(spreadsheet) {
  ensureSheet_(spreadsheet, CONFIG.SHEETS.SETTINGS);

  const ocrSheet = ensureSheet_(spreadsheet, CONFIG.SHEETS.OCR);
  const dataSheet = ensureSheet_(spreadsheet, CONFIG.SHEETS.DATA);

  ensureSheet_(spreadsheet, CONFIG.SHEETS.SUMMARY);

  const logSheet = ensureSheet_(spreadsheet, CONFIG.SHEETS.LOG);

  writeHeader_(ocrSheet, SHEET_HEADERS.OCR);
  writeHeader_(dataSheet, SHEET_HEADERS.DATA);
  writeHeader_(logSheet, SHEET_HEADERS.LOG);
}


/**
 * シートを取得または作成します。
 */
function ensureSheet_(spreadsheet, sheetName) {
  let sheet = spreadsheet.getSheetByName(sheetName);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(sheetName);
    console.log('シートを新規作成:' + sheetName);
  } else {
    console.log('既存シートを使用:' + sheetName);
  }

  return sheet;
}


/**
 * 見出しを設定します。
 */
function writeHeader_(sheet, headers) {
  ensureSheetSize_(sheet, 2, headers.length);

  const range = sheet.getRange(1, 1, 1, headers.length);

  range
    .setValues([headers])
    .setFontWeight('bold')
    .setBackground('#102f4f')
    .setFontColor('#ffffff')
    .setHorizontalAlignment('center');

  sheet.setFrozenRows(1);
}


/**
 * 00_設定シートを作成します。
 */
function writeSettingsSheet_(spreadsheet, folders) {
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.SETTINGS);

  sheet.clear();

  const rows = [
    ['設定項目', '値', '説明'],
    ['親フォルダID', CONFIG.ROOT_FOLDER_ID, '家計OCR自動化の親フォルダ'],
    ['スプレッドシートID', CONFIG.SPREADSHEET_ID, '登録先スプレッドシート'],
    ['処理待ちフォルダID', folders.waiting.getId(), CONFIG.FOLDERS.WAITING],
    ['処理済みフォルダID', folders.processed.getId(), CONFIG.FOLDERS.PROCESSED],
    ['要確認フォルダID', folders.review.getId(), CONFIG.FOLDERS.REVIEW],
    ['OCRドキュメントフォルダID', folders.ocr.getId(), CONFIG.FOLDERS.OCR_DOCUMENTS],
    ['Geminiモデル', CONFIG.GEMINI_MODEL, '画像・PDF・構造化出力対応モデル'],
    ['OCR言語', CONFIG.OCR_LANGUAGE, '日本語'],
    ['1回の最大処理数', CONFIG.MAX_FILES_PER_RUN, '処理時間超過を防ぐための上限'],
    ['Gemini APIキー', 'スクリプトプロパティに保存', 'GEMINI_API_KEY']
  ];

  ensureSheetSize_(sheet, Math.max(rows.length + 2, CONFIG.CATEGORIES.length + 2), 8);

  sheet.getRange(1, 1, rows.length, 3).setValues(rows);

  sheet
    .getRange(1, 1, 1, 3)
    .setFontWeight('bold')
    .setBackground('#102f4f')
    .setFontColor('#ffffff');

  sheet
    .getRange('E1')
    .setValue('カテゴリ候補')
    .setFontWeight('bold')
    .setBackground('#102f4f')
    .setFontColor('#ffffff');

  sheet
    .getRange(2, 5, CONFIG.CATEGORIES.length, 1)
    .setValues(CONFIG.CATEGORIES.map(function(category) { return [category]; }));

  sheet
    .getRange('G1:H1')
    .setValues([['システム状態', '内容']])
    .setFontWeight('bold')
    .setBackground('#102f4f')
    .setFontColor('#ffffff');

  sheet.setFrozenRows(1);

  sheet.setColumnWidth(1, 230);
  sheet.setColumnWidth(2, 380);
  sheet.setColumnWidth(3, 330);
  sheet.setColumnWidth(5, 180);
  sheet.setColumnWidth(7, 180);
  sheet.setColumnWidth(8, 450);
}


/**
 * 初期設定状態を記録します。
 */
function writeSetupStatus_(spreadsheet, status, detail) {
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.SETTINGS);

  if (!sheet) {
    return;
  }

  sheet
    .getRange('G2:H5')
    .setValues([
      ['初期設定', status],
      ['更新日時', new Date()],
      ['Geminiモデル', CONFIG.GEMINI_MODEL],
      ['詳細', truncateForCell_(detail || '', 5000)]
    ]);

  sheet.getRange('H3').setNumberFormat('yyyy/mm/dd hh:mm:ss');
}


/**
 * 家計データの入力規則を設定します。
 */
function configureDataValidation_(spreadsheet) {
  const dataSheet = spreadsheet.getSheetByName(CONFIG.SHEETS.DATA);

  const requiredRows = 5000;

  ensureSheetSize_(dataSheet, requiredRows, SHEET_HEADERS.DATA.length);

  const categoryRule = SpreadsheetApp
    .newDataValidation()
    .requireValueInList(CONFIG.CATEGORIES, true)
    .setAllowInvalid(false)
    .build();

  const confirmationRule = SpreadsheetApp
    .newDataValidation()
    .requireValueInList(CONFIG.CONFIRMATION_STATES, true)
    .setAllowInvalid(false)
    .build();

  dataSheet.getRange(2, 5, requiredRows - 1, 1).setDataValidation(categoryRule);
  dataSheet.getRange(2, 11, requiredRows - 1, 1).setDataValidation(confirmationRule);
  dataSheet.getRange(2, 2, requiredRows - 1, 1).setNumberFormat('yyyy/mm/dd');
  dataSheet.getRange(2, 6, requiredRows - 1, 1).setNumberFormat('#,##0');
  dataSheet.getRange(2, 14, requiredRows - 1, 1).setNumberFormat('yyyy/mm/dd hh:mm:ss');
}


/**
 * 月別・カテゴリ別集計を設定します。
 */
function configureSummarySheet_(spreadsheet) {
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.SUMMARY);

  sheet.clear();

  sheet
    .getRange('A1')
    .setFormula(
      '=QUERY(\'02_家計データ\'!A:N,' +
      '"select L,sum(F) ' +
      'where L is not null ' +
      'group by L ' +
      'order by L ' +
      'label L \'対象月\',sum(F) \'支出合計\'",1)'
    );

  sheet
    .getRange('D1')
    .setFormula(
      '=QUERY(\'02_家計データ\'!A:N,' +
      '"select L,E,sum(F) ' +
      'where L is not null ' +
      'group by L,E ' +
      'order by L,E ' +
      'label L \'対象月\',E \'カテゴリ\',sum(F) \'金額\'",1)'
    );

  sheet.setFrozenRows(1);

  sheet.setColumnWidth(1, 130);
  sheet.setColumnWidth(2, 130);
  sheet.setColumnWidth(4, 130);
  sheet.setColumnWidth(5, 160);
  sheet.setColumnWidth(6, 130);
}


/**
 * 各シートの表示を整えます。
 */
function applySheetFormatting_(spreadsheet) {
  const ocrSheet = spreadsheet.getSheetByName(CONFIG.SHEETS.OCR);
  const dataSheet = spreadsheet.getSheetByName(CONFIG.SHEETS.DATA);
  const logSheet = spreadsheet.getSheetByName(CONFIG.SHEETS.LOG);

  ocrSheet.setColumnWidth(1, 190);
  ocrSheet.setColumnWidth(2, 140);
  ocrSheet.setColumnWidth(3, 150);
  ocrSheet.setColumnWidth(4, 260);
  ocrSheet.setColumnWidth(5, 120);
  ocrSheet.setColumnWidth(6, 500);
  ocrSheet.setColumnWidth(7, 500);
  ocrSheet.setColumnWidth(8, 120);
  ocrSheet.setColumnWidth(9, 300);

  dataSheet.setColumnWidth(1, 200);
  dataSheet.setColumnWidth(2, 110);
  dataSheet.setColumnWidth(3, 180);
  dataSheet.setColumnWidth(4, 180);
  dataSheet.setColumnWidth(5, 130);
  dataSheet.setColumnWidth(6, 100);
  dataSheet.setColumnWidth(7, 160);
  dataSheet.setColumnWidth(8, 260);
  dataSheet.setColumnWidth(9, 260);
  dataSheet.setColumnWidth(10, 210);
  dataSheet.setColumnWidth(11, 110);
  dataSheet.setColumnWidth(12, 100);
  dataSheet.setColumnWidth(13, 350);
  dataSheet.setColumnWidth(14, 160);

  logSheet.setColumnWidth(1, 200);
  logSheet.setColumnWidth(2, 160);
  logSheet.setColumnWidth(3, 250);
  logSheet.setColumnWidth(4, 150);
  logSheet.setColumnWidth(5, 180);
  logSheet.setColumnWidth(6, 120);
  logSheet.setColumnWidth(7, 250);
  logSheet.setColumnWidth(8, 500);
  logSheet.setColumnWidth(9, 100);
  logSheet.setColumnWidth(10, 110);
  logSheet.setColumnWidth(11, 110);
  logSheet.setColumnWidth(12, 350);
  logSheet.setColumnWidth(13, 260);
}


/**
 * 5分ごとの自動処理トリガーを作成します。
 */
function createFiveMinuteTrigger() {
  deleteTriggersByHandler_('processWaitingFiles');

  const trigger = ScriptApp
    .newTrigger('processWaitingFiles')
    .timeBased()
    .everyMinutes(5)
    .create();

  console.log('5分ごとの自動処理トリガーを作成しました。');
  console.log('トリガーID:' + trigger.getUniqueId());

  try {
    const spreadsheet = getSpreadsheet_();
    const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.SETTINGS);

    if (sheet) {
      sheet
        .getRange('G7:H9')
        .setValues([
          ['自動処理', '5分ごと'],
          ['トリガー状態', '有効'],
          ['作成日時', new Date()]
        ]);

      sheet.getRange('H9').setNumberFormat('yyyy/mm/dd hh:mm:ss');
    }
  } catch (error) {
    console.error('トリガー状態の記録に失敗しました。\n' + stringifyError_(error));
  }
}


/**
 * 自動処理トリガーを削除します。
 */
function deleteAutomationTriggers() {
  const deletedCount = deleteTriggersByHandler_('processWaitingFiles');
  console.log('processWaitingFilesのトリガーを' + deletedCount + '件削除しました。');
}


/**
 * 指定関数のトリガーを削除します。
 */
function deleteTriggersByHandler_(handlerName) {
  const triggers = ScriptApp.getProjectTriggers();

  let deletedCount = 0;

  triggers.forEach(function(trigger) {
    if (trigger.getHandlerFunction() === handlerName) {
      ScriptApp.deleteTrigger(trigger);
      deletedCount++;
    }
  });

  return deletedCount;
}


/**
 * 現在のシステム状態を確認します。
 */
function showSystemStatus() {
  const context = getSystemContext_();

  const waitingCount = countFiles_(context.waitingFolder);
  const processedCount = countFiles_(context.processedFolder);
  const reviewCount = countFiles_(context.reviewFolder);
  const ocrCount = countFiles_(context.ocrFolder);

  const triggerCount = ScriptApp
    .getProjectTriggers()
    .filter(function(trigger) {
      return trigger.getHandlerFunction() === 'processWaitingFiles';
    })
    .length;

  console.log('====================================');
  console.log('現在のシステム状態');
  console.log('処理待ち:' + waitingCount + '件');
  console.log('処理済み:' + processedCount + '件');
  console.log('要確認:' + reviewCount + '件');
  console.log('OCRドキュメント:' + ocrCount + '件');
  console.log('自動トリガー:' + (triggerCount > 0 ? '有効' : '無効'));
  console.log('使用モデル:' + CONFIG.GEMINI_MODEL);
  console.log('====================================');

  return {
    waitingCount: waitingCount,
    processedCount: processedCount,
    reviewCount: reviewCount,
    ocrCount: ocrCount,
    triggerCount: triggerCount,
    model: CONFIG.GEMINI_MODEL
  };
}

Apps Scriptの時間主導型トリガーは、一定間隔で関数を自動実行できます。スタンドアロン型のApps Scriptでも利用でき、プログラムから作成・削除できます。

3.OCR.gs

このファイルでは、画像またはPDFをGoogleドキュメントへ変換し、OCR文字を取得します。

OCR.gs
/**
 * 家計OCR自動化
 * OCR.gs
 */


/**
 * 原本画像またはPDFをGoogleドキュメントへ変換し、
 * Google Drive OCRを実行します。
 */
function createOcrDocument_(sourceFile, ocrFolder) {
  const timestamp = Utilities.formatDate(new Date(), CONFIG.TIME_ZONE, 'yyyyMMdd_HHmmss');

  const ocrName = ['OCR', timestamp, sanitizeFileName_(sourceFile.getName())].join('_');

  const blob = sourceFile.getBlob();

  const metadata = {
    name: ocrName,
    mimeType: 'application/vnd.google-apps.document',
    parents: [ocrFolder.getId()]
  };

  const createdFile = Drive.Files.create(
    metadata,
    blob,
    {
      ocrLanguage: CONFIG.OCR_LANGUAGE,
      supportsAllDrives: true,
      fields: 'id,name,webViewLink'
    }
  );

  if (!createdFile || !createdFile.id) {
    throw new Error('OCRドキュメントの作成結果からIDを取得できませんでした。');
  }

  return {
    id: createdFile.id,
    name: createdFile.name || ocrName,
    url: createdFile.webViewLink || ('https://docs.google.com/document/d/' + createdFile.id + '/edit')
  };
}


/**
 * OCRドキュメント本文を取得します。
 *
 * OCR直後は反映に時間がかかる場合があるため、
 * 数回再試行します。
 */
function readOcrTextWithRetry_(documentId) {
  const maxAttempts = 6;
  let lastError = null;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      Utilities.sleep(attempt === 1 ? 1500 : 1000);

      const document = DocumentApp.openById(documentId);
      const text = document.getBody().getText().trim();

      if (text) {
        return text;
      }
    } catch (error) {
      lastError = error;
    }
  }

  if (lastError) {
    throw new Error(
      'OCRドキュメントを作成しましたが、本文を取得できませんでした。\n' + stringifyError_(lastError)
    );
  }

  throw new Error('OCRドキュメントを作成しましたが、OCR文字が空でした。');
}

4.Gemini.gs

このファイルでは、元画像・PDFとOCR文字をGeminiへ送り、家計データをJSONで取得します。

APIキーは、スクリプトプロパティに保存したGEMINI_API_KEYを読み取ります。

APIキーはパスワードと同様に扱い、コードや公開ページへ直接書かないことが重要です。Google公式も、キーをソースコードへ埋め込んだり、公開したりしないよう案内しています。

Geminiの思考回路を制御するプロンプト設計 視覚情報の優先でOCR原文は補助情報として原本画像上の位置関係を重視 金額の定義で小計消費税お釣りを除外し最終支払金額のみ抽出 JSON出力の強制で判断不能な項目はneedsReview trueを返す
Gemini.gs
/**
 * 家計OCR自動化
 * Gemini.gs
 */


/**
 * 原本画像・PDFとOCR原文をGeminiへ送り、
 * 家計データを構造化して取得します。
 */
function extractTransactionsWithGemini_(sourceFile, ocrText) {
  const apiKey = getGeminiApiKey_();

  const mimeType = sourceFile.getMimeType();
  const blob = sourceFile.getBlob();
  const bytes = blob.getBytes();

  if (bytes.length > CONFIG.MAX_FILE_BYTES) {
    throw new Error(
      'ファイルサイズが上限を超えています。' +
      '\n現在:' + bytes.length + 'バイト' +
      '\n上限:' + CONFIG.MAX_FILE_BYTES + 'バイト'
    );
  }

  const prompt = buildGeminiPrompt_(ocrText);

  const requestBody = {
    contents: [
      {
        role: 'user',
        parts: [
          { text: prompt },
          {
            inlineData: {
              mimeType: mimeType,
              data: Utilities.base64Encode(bytes)
            }
          }
        ]
      }
    ],

    generationConfig: {
      temperature: 0,
      maxOutputTokens: 8192,
      responseMimeType: 'application/json',
      responseSchema: buildGeminiResponseSchema_()
    }
  };

  const endpoint =
    'https://generativelanguage.googleapis.com/v1beta/models/' +
    encodeURIComponent(CONFIG.GEMINI_MODEL) +
    ':generateContent';

  const response = UrlFetchApp.fetch(
    endpoint,
    {
      method: 'post',
      contentType: 'application/json; charset=utf-8',
      headers: { 'x-goog-api-key': apiKey },
      payload: JSON.stringify(requestBody),
      muteHttpExceptions: true
    }
  );

  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();

  if (statusCode < 200 || statusCode >= 300) {
    throw new Error(
      'Gemini APIエラー:HTTP ' + statusCode + '\n' + truncateForCell_(responseText, 5000)
    );
  }

  const responseObject = JSON.parse(responseText);

  if (
    !responseObject.candidates ||
    !responseObject.candidates.length ||
    !responseObject.candidates[0].content ||
    !responseObject.candidates[0].content.parts
  ) {
    throw new Error(
      'Geminiから抽出結果を取得できませんでした。\n' + truncateForCell_(responseText, 5000)
    );
  }

  const generatedText = responseObject.candidates[0].content.parts
    .map(function(part) { return part.text || ''; })
    .join('')
    .trim();

  if (!generatedText) {
    throw new Error('Geminiの構造化出力が空でした。');
  }

  try {
    return JSON.parse(stripCodeFence_(generatedText));
  } catch (error) {
    throw new Error(
      'GeminiのJSON解析に失敗しました。\n出力:' + truncateForCell_(generatedText, 5000)
    );
  }
}


/**
 * Geminiへ渡す指示文を作成します。
 */
function buildGeminiPrompt_(ocrText) {
  const limitedOcrText = String(ocrText || '').slice(0, CONFIG.OCR_TEXT_MAX_CHARS);

  return [
    'あなたは日本の家計簿データを整理する専門AIです。',
    '',
    '添付した原本画像またはPDFと、Google Drive OCRの文字情報を確認してください。',
    '',
    '【最重要】',
    '・原本画像またはPDFを一次情報として扱ってください。',
    '・OCR原文は補助情報であり、誤認識や行順の崩れを含みます。',
    '・手書きの場合は、文字だけでなく横並び、行、余白、位置関係を確認してください。',
    '・画像内に複数の支出がある場合は、支出1件につき1データとして抽出してください。',
    '',
    '【抽出項目】',
    'date:利用日。YYYY-MM-DD形式',
    'merchant:店舗・相手先',
    'description:支出内容',
    'category:指定カテゴリから1つ',
    'amount:最終支払金額。数字のみ',
    'paymentMethod:支払方法',
    'needsReview:人の確認が必要ならtrue',
    'reviewReason:確認理由',
    'confidence:high、medium、lowのいずれか',
    'corrections:OCRから補正した内容',
    '',
    '【カテゴリ候補】',
    CONFIG.CATEGORIES.join('、'),
    '',
    '【レシートの金額判定】',
    '・最終的に支払った合計金額を使用してください。',
    '・小計、税抜額、消費税、預かり金、お釣りは使用しないでください。',
    '・レシート本体とカード売上票に同じ金額があっても二重計上しないでください。',
    '・商品が複数あっても、基本的にはレシート1枚を1取引として扱ってください。',
    '',
    '【手書き家計簿の判定】',
    '・日付、店舗、内容、カテゴリ、金額、支払方法の横並びを確認してください。',
    '・OCR文字の出現順ではなく、原本画像上の同じ行を1取引として扱ってください。',
    '・離れた行の情報を誤って結合しないでください。',
    '',
    '【高い確度で行ってよいOCR補正】',
    '・金額欄の「1.18c」は、画像と文脈から明らかな場合は「1180」へ補正してください。',
    '・金額欄の「8.460」は、画像と文脈から明らかな場合は「8460」へ補正してください。',
    '・末尾のc、C、o、Oなどが0の誤認識と明らかな場合は0へ補正してください。',
    '・「口座振える」「口座振かえ」などは、明らかな場合は「口座振替」へ補正してください。',
    '・「カード」は「クレジットカード」に統一してください。',
    '・「IC」は「ICカード」に統一してください。',
    '・日付の一部が記号や英字に崩れていても、原本画像から読める場合は正しい日付へ補正してください。',
    '',
    '【推測を禁止するもの】',
    '・原本画像にも根拠がない店舗名',
    '・画像上で判別できない金額',
    '・画像上で対応関係が分からない日付',
    '・記載されていない支出',
    '',
    '判断できない項目は空欄にし、needsReviewをtrueにしてください。',
    '',
    '【個人情報】',
    'カード番号、会員番号、承認番号、電話番号、登録番号は出力しないでください。',
    '',
    '【Google Drive OCR原文】',
    limitedOcrText
  ].join('\n');
}


/**
 * Gemini構造化出力用のスキーマです。
 */
function buildGeminiResponseSchema_() {
  return {
    type: 'OBJECT',

    properties: {
      documentType: {
        type: 'STRING',
        enum: ['receipt', 'handwritten_household_book', 'invoice', 'other']
      },

      transactions: {
        type: 'ARRAY',

        items: {
          type: 'OBJECT',

          properties: {
            date: { type: 'STRING', description: 'YYYY-MM-DD形式。不明なら空文字' },
            merchant: { type: 'STRING', description: '店舗・相手先。不明なら空文字' },
            description: { type: 'STRING', description: '食料品、日用品、医療費などの短い内容' },
            category: { type: 'STRING', enum: CONFIG.CATEGORIES },
            amount: { type: 'STRING', description: '数字のみ。不明なら空文字' },
            paymentMethod: { type: 'STRING', description: '現金、クレジットカード、ICカード、口座振替など' },
            needsReview: { type: 'BOOLEAN' },
            reviewReason: { type: 'STRING' },
            confidence: { type: 'STRING', enum: ['high', 'medium', 'low'] },
            corrections: { type: 'STRING', description: 'OCRから補正した内容。不明なら空文字' }
          },

          required: [
            'date', 'merchant', 'description', 'category', 'amount',
            'paymentMethod', 'needsReview', 'reviewReason', 'confidence', 'corrections'
          ]
        }
      }
    },

    required: ['documentType', 'transactions']
  };
}


/**
 * Gemini APIキーを取得します。
 */
function getGeminiApiKey_() {
  const apiKey = PropertiesService.getScriptProperties().getProperty(CONFIG.GEMINI_API_KEY_PROPERTY);

  if (!apiKey) {
    throw new Error('スクリプトプロパティにGEMINI_API_KEYが登録されていません。');
  }

  return apiKey;
}


/**
 * Geminiへ接続できるか確認します。
 *
 * 独立型Apps Script対応版です。
 */
function testGeminiConnection() {
  const apiKey = getGeminiApiKey_();

  const endpoint =
    'https://generativelanguage.googleapis.com/v1beta/models/' +
    encodeURIComponent(CONFIG.GEMINI_MODEL);

  const response = UrlFetchApp.fetch(
    endpoint,
    {
      method: 'get',
      headers: { 'x-goog-api-key': apiKey },
      muteHttpExceptions: true
    }
  );

  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();

  if (statusCode >= 200 && statusCode < 300) {
    console.log('Gemini接続成功\nモデル:' + CONFIG.GEMINI_MODEL);

    const spreadsheet = getSpreadsheet_();
    const settingsSheet = spreadsheet.getSheetByName(CONFIG.SHEETS.SETTINGS);

    if (settingsSheet) {
      settingsSheet.getRange('G1').setValue('Gemini接続状態');
      settingsSheet.getRange('G2').setValue('接続成功');
      settingsSheet.getRange('G3').setValue(CONFIG.GEMINI_MODEL);
      settingsSheet.getRange('G4').setValue(new Date());
      settingsSheet.getRange('G4').setNumberFormat('yyyy/mm/dd hh:mm:ss');
    }

    return;
  }

  throw new Error('Gemini接続失敗:HTTP ' + statusCode + '\n' + responseText);
}

5.Main.gs

このファイルが、自動化処理の中心です。OCR、Gemini、スプレッドシート登録、ファイル移動を順番に実行します。

Main.gs
/**
 * 家計OCR自動化
 * Main.gs
 *
 * 処理待ちフォルダの画像・PDFを、
 * OCR → Gemini → スプレッドシート登録
 * の順番で処理します。
 */


/**
 * 処理待ちフォルダの先頭1件を処理します。
 *
 * 初回テスト用です。
 */
function processFirstWaitingFile() {
  const lock = LockService.getScriptLock();

  if (!lock.tryLock(3000)) {
    console.log('別の処理が実行中です。少し待ってから再実行してください。');
    return;
  }

  try {
    const context = getSystemContext_();
    const files = context.waitingFolder.getFiles();

    if (!files.hasNext()) {
      console.log('01_処理待ちフォルダにファイルがありません。');
      return;
    }

    const file = files.next();

    console.log('====================================');
    console.log('テスト処理開始:' + file.getName());
    console.log('ファイルID:' + file.getId());
    console.log('MIMEタイプ:' + file.getMimeType());
    console.log('ファイルサイズ:' + file.getSize() + 'バイト');
    console.log('====================================');

    const result = processSingleFile_(file, context);

    console.log('====================================');
    console.log('処理終了');
    console.log('処理ID:' + result.processingId);
    console.log('状態:' + result.status);
    console.log('抽出件数:' + result.transactionCount);

    if (result.errorMessage) {
      console.log('エラー:' + result.errorMessage);
    }

    console.log('====================================');
  } catch (error) {
    console.error('processFirstWaitingFileでエラーが発生しました。\n' + stringifyError_(error));
    throw error;
  } finally {
    lock.releaseLock();
  }
}


/**
 * 処理待ちフォルダをまとめて処理します。
 */
function processWaitingFiles() {
  const lock = LockService.getScriptLock();

  if (!lock.tryLock(1000)) {
    console.log('別の自動処理が実行中のため、今回の実行を終了します。');
    return;
  }

  try {
    const context = getSystemContext_();
    const files = context.waitingFolder.getFiles();

    let processedCount = 0;
    let successCount = 0;
    let reviewCount = 0;
    let errorCount = 0;
    let skippedCount = 0;

    console.log('====================================');
    console.log('処理待ちファイルの一括処理を開始します。');
    console.log('1回の最大処理件数:' + CONFIG.MAX_FILES_PER_RUN);
    console.log('====================================');

    while (files.hasNext() && processedCount < CONFIG.MAX_FILES_PER_RUN) {
      const file = files.next();

      console.log('処理対象:' + file.getName() + '(' + (processedCount + 1) + '件目)');

      const result = processSingleFile_(file, context);

      processedCount++;

      switch (result.status) {
        case '未確認':
          successCount++;
          break;

        case '要確認':
          reviewCount++;
          break;

        case '処理済みのためスキップ':
          skippedCount++;
          break;

        default:
          errorCount++;
          break;
      }

      console.log('処理結果:' + JSON.stringify(result));
    }

    console.log('====================================');
    console.log('一括処理終了');
    console.log('処理件数:' + processedCount);
    console.log('正常登録:' + successCount);
    console.log('要確認:' + reviewCount);
    console.log('エラー:' + errorCount);
    console.log('スキップ:' + skippedCount);
    console.log('====================================');
  } catch (error) {
    console.error('processWaitingFilesでエラーが発生しました。\n' + stringifyError_(error));
    throw error;
  } finally {
    lock.releaseLock();
  }
}


/**
 * 原本ファイル1件を処理します。
 */
function processSingleFile_(sourceFile, context) {
  const startedAt = new Date();
  const sourceFileId = sourceFile.getId();
  const processingId = createProcessingId_(sourceFileId);
  const originalUrl = sourceFile.getUrl();
  const fileName = sourceFile.getName();
  const mimeType = sourceFile.getMimeType();
  const fileSize = sourceFile.getSize();
  const capturedAt = sourceFile.getDateCreated();

  let ocrDocument = { id: '', name: '', url: '' };
  let ocrText = '';
  let aiResult = null;
  let normalizedResult = null;

  let transactionCount = 0;
  let status = 'エラー';
  let errorMessage = '';

  let documentType = inferDocumentTypeFromMime_(mimeType);

  try {
    console.log('------------------------------------');
    console.log('処理開始:' + fileName);
    console.log('処理ID:' + processingId);
    console.log('ファイルID:' + sourceFileId);

    if (isFileAlreadyProcessed_(sourceFileId)) {
      status = '処理済みのためスキップ';
      console.log('既に処理済みのため、登録をスキップします。');

      safeMoveFile_(sourceFile, context.processedFolder);

      return {
        processingId: processingId,
        status: status,
        transactionCount: 0,
        errorMessage: ''
      };
    }

    validateSourceFile_(sourceFile);

    console.log('原本検証完了:' + mimeType + ' / ' + fileSize + 'バイト');
    console.log('OCR開始:' + fileName);

    ocrDocument = createOcrDocument_(sourceFile, context.ocrFolder);

    console.log('OCRドキュメント作成完了:' + ocrDocument.id);

    ocrText = readOcrTextWithRetry_(ocrDocument.id);

    console.log('OCR原文取得完了:' + ocrText.length + '文字');
    console.log('Gemini解析開始:' + fileName);

    aiResult = extractTransactionsWithGemini_(sourceFile, ocrText);

    console.log('Gemini解析完了:' + truncateForCell_(JSON.stringify(aiResult), 2000));

    normalizedResult = normalizeAiResult_(aiResult);
    documentType = normalizedResult.documentType;

    if (!normalizedResult.transactions || !normalizedResult.transactions.length) {
      throw new Error('Geminiから家計データが1件も抽出されませんでした。');
    }

    console.log('正規化完了:' + normalizedResult.transactions.length + '件');

    const appendResult = appendTransactions_(processingId, sourceFile, ocrDocument, normalizedResult);

    transactionCount = appendResult.count;

    if (transactionCount <= 0) {
      throw new Error('家計データシートへ登録された取引がありません。');
    }

    status = appendResult.anyNeedsReview ? '要確認' : '未確認';

    console.log('家計データ登録完了:' + transactionCount + '件');

    markFileAsProcessed_(sourceFileId);

    if (appendResult.anyNeedsReview) {
      console.log('確認が必要なため、03_要確認へ移動します。');
      safeMoveFile_(sourceFile, context.reviewFolder);
    } else {
      console.log('正常処理のため、02_処理済みへ移動します。');
      safeMoveFile_(sourceFile, context.processedFolder);
    }

    console.log('処理完了:' + fileName + ' / ' + transactionCount + '件 / ' + status);
  } catch (error) {
    errorMessage = stringifyError_(error);
    status = 'エラー';

    console.error('処理エラー:' + fileName + '\n' + errorMessage);

    safeMoveFile_(sourceFile, context.reviewFolder);
  } finally {
    const elapsedMilliseconds = new Date().getTime() - startedAt.getTime();

    try {
      appendOcrRecord_({
        processingId: processingId,
        capturedAt: capturedAt,
        documentType: documentType,
        originalUrl: originalUrl,
        ocrText: ocrText,
        aiResult: aiResult,
        status: status,
        note: errorMessage
      });
    } catch (ocrLogError) {
      console.error('01_OCR原文シートへの記録に失敗しました。\n' + stringifyError_(ocrLogError));
    }

    try {
      appendProcessLog_({
        processingId: processingId,
        processedAt: startedAt,
        fileName: fileName,
        documentType: documentType,
        mimeType: mimeType,
        fileSize: fileSize,
        ocrResult: ocrText ? ('成功(' + ocrText.length + '文字)') : '未取得',
        aiResult: aiResult,
        transactionCount: transactionCount,
        status: status,
        elapsedMilliseconds: elapsedMilliseconds,
        errorMessage: errorMessage,
        originalUrl: originalUrl
      });
    } catch (processLogError) {
      console.error('04_検証ログシートへの記録に失敗しました。\n' + stringifyError_(processLogError));
    }

    console.log('処理時間:' + (elapsedMilliseconds / 1000).toFixed(2) + '秒');
    console.log('------------------------------------');
  }

  return {
    processingId: processingId,
    status: status,
    transactionCount: transactionCount,
    errorMessage: errorMessage
  };
}


/**
 * 原本ファイルを検証します。
 */
function validateSourceFile_(sourceFile) {
  if (!sourceFile) {
    throw new Error('処理対象のファイルを取得できませんでした。');
  }

  const mimeType = sourceFile.getMimeType();
  const size = sourceFile.getSize();

  if (CONFIG.ALLOWED_MIME_TYPES.indexOf(mimeType) === -1) {
    throw new Error(
      '未対応のファイル形式です:' + mimeType + '\n対応形式:JPEG、PNG、WebP、PDF'
    );
  }

  if (size <= 0) {
    throw new Error('ファイルサイズが0バイトです。');
  }

  if (size > CONFIG.MAX_FILE_BYTES) {
    throw new Error(
      'ファイルサイズが上限を超えています。' +
      '\n現在:' + size + 'バイト' +
      '\n上限:' + CONFIG.MAX_FILE_BYTES + 'バイト'
    );
  }
}


/**
 * システムで使用するフォルダを取得します。
 */
function getSystemContext_() {
  const properties = PropertiesService.getScriptProperties();

  const waitingId = properties.getProperty('FOLDER_WAITING_ID');
  const processedId = properties.getProperty('FOLDER_PROCESSED_ID');
  const reviewId = properties.getProperty('FOLDER_REVIEW_ID');
  const ocrId = properties.getProperty('FOLDER_OCR_ID');

  if (!waitingId || !processedId || !reviewId || !ocrId) {
    throw new Error('フォルダ設定が登録されていません。\n先にsetupSystemを実行してください。');
  }

  return {
    spreadsheet: getSpreadsheet_(),
    waitingFolder: DriveApp.getFolderById(waitingId),
    processedFolder: DriveApp.getFolderById(processedId),
    reviewFolder: DriveApp.getFolderById(reviewId),
    ocrFolder: DriveApp.getFolderById(ocrId)
  };
}


/**
 * 処理済みファイルIDを保存します。
 */
function markFileAsProcessed_(fileId) {
  PropertiesService.getScriptProperties().setProperty(
    'PROCESSED_FILE_' + fileId,
    new Date().toISOString()
  );
}


/**
 * 既に処理済みか確認します。
 */
function isFileAlreadyProcessed_(fileId) {
  return Boolean(
    PropertiesService.getScriptProperties().getProperty('PROCESSED_FILE_' + fileId)
  );
}


/**
 * すべての処理済み記録を削除します。
 *
 * テストを最初からやり直す場合に使います。
 * スプレッドシートのデータは削除しません。
 */
function clearAllProcessedFileRecords() {
  const properties = PropertiesService.getScriptProperties();
  const allProperties = properties.getProperties();

  let deletedCount = 0;

  Object.keys(allProperties).forEach(function(key) {
    if (key.indexOf('PROCESSED_FILE_') === 0) {
      properties.deleteProperty(key);
      deletedCount++;
    }
  });

  console.log('処理済み記録を' + deletedCount + '件削除しました。');
}

6.Spreadsheet.gs

このファイルでは、家計データ、OCR原文、検証ログをGoogleスプレッドシートへ登録します。

Spreadsheet.gs
/**
 * 家計OCR自動化
 * Spreadsheet.gs
 */


/**
 * 家計データを登録します。
 */
function appendTransactions_(processingId, sourceFile, ocrDocument, normalizedResult) {
  const spreadsheet = getSpreadsheet_();
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.DATA);

  const now = new Date();
  const originalUrl = sourceFile.getUrl();

  const rows = normalizedResult.transactions.map(function(transaction, index) {
    const transactionId = processingId + '-' + Utilities.formatString('%02d', index + 1);
    const confirmationState = transaction.needsReview ? '要確認' : '未確認';
    const targetMonth = transaction.date ? transaction.date.substring(0, 7) : '';

    return [
      transactionId,
      dateStringToDate_(transaction.date),
      transaction.merchant,
      transaction.description,
      transaction.category,
      transaction.amount === '' ? '' : transaction.amount,
      transaction.paymentMethod,
      originalUrl,
      ocrDocument.url,
      '自動OCR+Gemini(原本画像確認)',
      confirmationState,
      targetMonth,
      transaction.reviewReason,
      now
    ];
  });

  if (!rows.length) {
    return { count: 0, anyNeedsReview: true };
  }

  const startRow = sheet.getLastRow() + 1;

  sheet.getRange(startRow, 1, rows.length, SHEET_HEADERS.DATA.length).setValues(rows);
  sheet.getRange(startRow, 2, rows.length, 1).setNumberFormat('yyyy/mm/dd');
  sheet.getRange(startRow, 6, rows.length, 1).setNumberFormat('#,##0');
  sheet.getRange(startRow, 14, rows.length, 1).setNumberFormat('yyyy/mm/dd hh:mm:ss');

  return {
    count: rows.length,
    anyNeedsReview: normalizedResult.transactions.some(function(transaction) {
      return transaction.needsReview;
    })
  };
}


/**
 * OCR原文シートへ記録します。
 */
function appendOcrRecord_(record) {
  const spreadsheet = getSpreadsheet_();
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.OCR);

  const aiResultText = record.aiResult ? JSON.stringify(record.aiResult) : '';

  sheet.appendRow([
    record.processingId,
    record.capturedAt || '',
    documentTypeToJapanese_(record.documentType),
    record.originalUrl,
    '自動取込',
    truncateForCell_(record.ocrText || '', 45000),
    truncateForCell_(aiResultText, 45000),
    record.status,
    truncateForCell_(record.note || '', 5000)
  ]);
}


/**
 * 検証ログへ記録します。
 */
function appendProcessLog_(record) {
  const spreadsheet = getSpreadsheet_();
  const sheet = spreadsheet.getSheetByName(CONFIG.SHEETS.LOG);

  const aiResultText = record.aiResult ? JSON.stringify(record.aiResult) : '';

  sheet.appendRow([
    record.processingId,
    record.processedAt,
    record.fileName,
    documentTypeToJapanese_(record.documentType),
    record.mimeType,
    record.fileSize,
    record.ocrResult,
    truncateForCell_(aiResultText, 45000),
    record.transactionCount,
    record.status,
    (record.elapsedMilliseconds / 1000).toFixed(2) + '秒',
    truncateForCell_(record.errorMessage || '', 5000),
    record.originalUrl
  ]);
}

7.Utils.gs

このファイルでは、AIが返した日付、金額、カテゴリ、支払方法などを整えます。

ICカードがクレジットカードに変換されないように、一般的な「カード」判定より先に、ICカードを判定します。

単純なOCRでは不可能な文脈補正の力 Google Drive OCRの限界で記号の誤認識や行の繋がりが崩れる課題に対しGemini+Utils.gsによる補正で1.18cを1180へ8.460を8460へICをICカードへ表記揺れを統一する比較図
Utils.gs
/**
 * 家計OCR自動化
 * Utils.gs
 */


/**
 * Gemini結果を正規化します。
 */
function normalizeAiResult_(aiResult) {
  if (!aiResult || typeof aiResult !== 'object') {
    throw new Error('Gemini抽出結果がオブジェクトではありません。');
  }

  const sourceTransactions = Array.isArray(aiResult.transactions) ? aiResult.transactions : [];

  return {
    documentType: normalizeDocumentType_(aiResult.documentType),
    transactions: sourceTransactions.map(function(transaction) {
      return normalizeTransaction_(transaction);
    })
  };
}


/**
 * 取引1件を正規化します。
 */
function normalizeTransaction_(transaction) {
  const raw = transaction || {};

  const normalizedDate = normalizeDate_(raw.date);
  const amountResult = normalizeAmount_(raw.amount);
  const merchant = normalizeText_(raw.merchant);
  const description = normalizeText_(raw.description);
  const category = normalizeCategory_(raw.category);
  const paymentMethod = normalizePaymentMethod_(raw.paymentMethod);
  const confidence = normalizeText_(raw.confidence).toLowerCase();

  const reviewReasons = [];

  if (raw.needsReview && raw.reviewReason) {
    reviewReasons.push(normalizeText_(raw.reviewReason));
  }

  if (!normalizedDate) {
    reviewReasons.push('利用日を確認');
  }

  if (amountResult.value === '') {
    reviewReasons.push(amountResult.note || '金額を確認');
  }

  if (confidence === 'low') {
    reviewReasons.push('AIの判定確度が低いため確認');
  }

  const needsReview =
    Boolean(raw.needsReview) ||
    !normalizedDate ||
    amountResult.value === '' ||
    confidence === 'low';

  return {
    date: normalizedDate,
    merchant: merchant,
    description: description,
    category: category,
    amount: amountResult.value,
    paymentMethod: paymentMethod,
    needsReview: needsReview,
    reviewReason: uniqueText_(reviewReasons).join('/'),
    confidence: confidence || 'medium',
    corrections: normalizeText_(raw.corrections)
  };
}


/**
 * 日付をYYYY-MM-DD形式にします。
 */
function normalizeDate_(value) {
  if (value === null || value === undefined) {
    return '';
  }

  const text = String(value)
    .normalize('NFKC')
    .trim()
    .replace(/年/g, '/')
    .replace(/月/g, '/')
    .replace(/日/g, '')
    .replace(/[.\-]/g, '/')
    .replace(/\s+/g, '');

  const match = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);

  if (!match) {
    return '';
  }

  const year = Number(match[1]);
  const month = Number(match[2]);
  const day = Number(match[3]);

  const date = new Date(year, month - 1, day);

  if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
    return '';
  }

  return [
    String(year).padStart(4, '0'),
    String(month).padStart(2, '0'),
    String(day).padStart(2, '0')
  ].join('-');
}


/**
 * AIが返した金額を数値化します。
 */
function normalizeAmount_(value) {
  if (value === null || value === undefined) {
    return { value: '', note: '金額が空欄' };
  }

  const rawText = String(value).normalize('NFKC').trim();

  if (!rawText) {
    return { value: '', note: '金額が空欄' };
  }

  let text = rawText.replace(/[¥¥円\s,]/g, '');

  /*
   * 8.460 → 8460
   */
  if (/^\d+\.\d{3}$/.test(text)) {
    text = text.replace('.', '');
  }

  /*
   * 1.18c → 1180
   */
  if (/^\d+\.\d{2}[cC]$/.test(text)) {
    text = text.replace(/[cC]$/, '0').replace('.', '');
  }

  /*
   * 118c → 1180
   */
  if (/^\d+[cC]$/.test(text)) {
    text = text.replace(/[cC]$/, '0');
  }

  /*
   * 数字中のO・oを0へ補正
   */
  if (/^[0-9OoOo]+$/.test(text)) {
    text = text.replace(/[OoOo]/g, '0');
  }

  if (!/^\d+$/.test(text)) {
    return { value: '', note: '金額を数値化できません:' + rawText };
  }

  const amount = Number(text);

  if (!Number.isSafeInteger(amount) || amount <= 0) {
    return { value: '', note: '金額が不正です:' + rawText };
  }

  return { value: amount, note: '' };
}


/**
 * 支払方法を統一します。
 */
function normalizePaymentMethod_(value) {
  const text = normalizeText_(value);

  if (!text) {
    return '';
  }

  const compact = text.replace(/\s+/g, '');

  if (/口座振/.test(compact)) {
    return '口座振替';
  }

  if (/現金/.test(compact)) {
    return '現金';
  }

  /*
   * 一般的な「カード」より先に判定します。
   */
  if (/ICカード|交通系IC|SUICA|PASMO|ICOCA/i.test(compact)) {
    return 'ICカード';
  }

  if (/QR|PAYPAY|楽天PAY|D払い|AU PAY/i.test(compact)) {
    return 'QRコード決済';
  }

  if (/電子マネー/.test(compact)) {
    return '電子マネー';
  }

  if (/デビット/i.test(compact)) {
    return 'デビットカード';
  }

  if (/クレジット|カード|VISA|MASTER|MASTERCARD|JCB|AMEX/i.test(compact)) {
    return 'クレジットカード';
  }

  return text;
}


/**
 * カテゴリを候補内へ統一します。
 */
function normalizeCategory_(value) {
  const text = normalizeText_(value);

  if (CONFIG.CATEGORIES.indexOf(text) !== -1) {
    return text;
  }

  const categoryMap = {
    食料品: '食費',
    飲食費: '食費',
    雑貨: '日用品',
    電気代: '水道光熱費',
    ガス代: '水道光熱費',
    水道代: '水道光熱費',
    電車: '交通費',
    バス: '交通費',
    病院: '医療費',
    診療費: '医療費'
  };

  return categoryMap[text] || 'その他';
}


/**
 * 書類種類を統一します。
 */
function normalizeDocumentType_(value) {
  const text = normalizeText_(value);
  const allowed = ['receipt', 'handwritten_household_book', 'invoice', 'other'];

  return allowed.indexOf(text) !== -1 ? text : 'other';
}


/**
 * 書類種類を日本語にします。
 */
function documentTypeToJapanese_(value) {
  const map = {
    receipt: 'レシート',
    handwritten_household_book: '手書き家計簿',
    invoice: '領収書・請求書',
    other: 'その他'
  };

  return map[value] || 'その他';
}


/**
 * MIMEタイプから暫定書類種類を判定します。
 */
function inferDocumentTypeFromMime_(mimeType) {
  return 'other';
}


/**
 * YYYY-MM-DDをDateへ変換します。
 */
function dateStringToDate_(dateString) {
  if (!dateString) {
    return '';
  }

  const match = String(dateString).match(/^(\d{4})-(\d{2})-(\d{2})$/);

  if (!match) {
    return '';
  }

  return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}


/**
 * スプレッドシートを取得します。
 */
function getSpreadsheet_() {
  return SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
}


/**
 * シートの行列数を確保します。
 */
function ensureSheetSize_(sheet, requiredRows, requiredColumns) {
  const currentRows = sheet.getMaxRows();
  const currentColumns = sheet.getMaxColumns();

  if (currentRows < requiredRows) {
    sheet.insertRowsAfter(currentRows, requiredRows - currentRows);
  }

  if (currentColumns < requiredColumns) {
    sheet.insertColumnsAfter(currentColumns, requiredColumns - currentColumns);
  }
}


/**
 * ファイル名に使えない文字を置換します。
 */
function sanitizeFileName_(value) {
  return String(value || 'file').replace(/[\\/:*?"<>|]/g, '_').slice(0, 150);
}


/**
 * 文字列を整えます。
 */
function normalizeText_(value) {
  if (value === null || value === undefined) {
    return '';
  }

  return String(value).normalize('NFKC').replace(/\s+/g, ' ').trim();
}


/**
 * 重複した確認事項を削除します。
 */
function uniqueText_(values) {
  const result = [];

  values.forEach(function(value) {
    const text = normalizeText_(value);

    if (text && result.indexOf(text) === -1) {
      result.push(text);
    }
  });

  return result;
}


/**
 * セルの文字数上限を考慮して切り詰めます。
 */
function truncateForCell_(value, maxLength) {
  const text = String(value || '');

  if (text.length <= maxLength) {
    return text;
  }

  return text.substring(0, maxLength) + '\n……以下省略';
}


/**
 * Markdownコードブロックを除去します。
 */
function stripCodeFence_(value) {
  return String(value || '')
    .replace(/^```(?:json)?\s*/i, '')
    .replace(/\s*```$/i, '')
    .trim();
}


/**
 * エラーを文字列化します。
 */
function stringifyError_(error) {
  if (!error) {
    return '不明なエラー';
  }

  if (error.stack) {
    return String(error.stack);
  }

  if (error.message) {
    return String(error.message);
  }

  return String(error);
}


/**
 * 処理IDを作成します。
 */
function createProcessingId_(fileId) {
  const timestamp = Utilities.formatDate(new Date(), CONFIG.TIME_ZONE, 'yyyyMMddHHmmss');
  const suffix = String(fileId || '').slice(-8);

  return 'PROC-' + timestamp + '-' + suffix;
}


/**
 * ファイルを移動します。
 */
function safeMoveFile_(file, destinationFolder) {
  try {
    file.moveTo(destinationFolder);
  } catch (error) {
    console.error('ファイル移動に失敗しました:' + file.getName() + '\n' + stringifyError_(error));
  }
}


/**
 * フォルダ内のファイル数を数えます。
 */
function countFiles_(folder) {
  const files = folder.getFiles();
  let count = 0;

  while (files.hasNext()) {
    files.next();
    count++;
  }

  return count;
}

8.appsscript.json

Apps Scriptの「プロジェクトの設定」で、次の設定をオンにしてください。

「appsscript.json」マニフェスト ファイルをエディタで表示する

表示されたappsscript.jsonを、次の内容へ全文置き換えます。

appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "version": "v3",
        "serviceId": "drive"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/script.scriptapp"
  ]
}

コードを貼り付けたあとの実行手順

すべてのコードを貼り付けたら、次の順番で実行します。

1.Config.gsのIDを変更する

次の2か所を、自分のIDへ変更します。

ROOT_FOLDER_ID: '自分のGoogle DriveフォルダID', SPREADSHEET_ID: '自分のスプレッドシートID',

フォルダURLが次の場合、

https://drive.google.com/drive/folders/XXXXXXXXXXXXXXXX

使用するのは次の部分です。

XXXXXXXXXXXXXXXX

スプレッドシートURLが次の場合、

https://docs.google.com/spreadsheets/d/YYYYYYYYYYYYYYYY/edit

使用するのは次の部分です。

YYYYYYYYYYYYYYYY
STEP1&2 環境設定とAPIキー登録 Config.gsの2箇所を自身の環境に合わせて書き換え APIキーはコード内に直接書き込まずスクリプトプロパティにGEMINI_API_KEYとして保存する図

2.APIキーを登録する

Apps Script左下の歯車マークを押すプロジェクトの設定スクリプト プロパティスクリプト プロパティを追加

次の内容を登録します。

プロパティ
GEMINI_API_KEY自分で取得したGemini APIキー

APIキーそのものは、この記事のコードには入力しません。

3.setupSystemを実行する

Apps Script上部の関数選択から、次を選びます。

setupSystem

「実行」を押します。初回はGoogleアカウントの権限確認が表示されます。使用している自分のGoogleアカウントを選び、必要な権限を許可します。

成功すると、実行ログに次のような内容が表示されます。

親フォルダ取得完了/サブフォルダの準備が完了/スプレッドシート取得完了/必要なシートの作成・取得が完了/初期設定が完了
STEP3 setupSystemの実行 Google Driveに01処理待ち02処理済み03要確認04OCRドキュメント Google Sheetsに00設定01OCR原文02家計データ03月次集計04検証ログが自動作成される図

Google Driveには、次のフォルダが作成されます。

家計OCR自動化
├─ 01_処理待ち
├─ 02_処理済み
├─ 03_要確認
└─ 04_OCRドキュメント

スプレッドシートには、次のシートが作成されます。

00_設定/01_OCR原文/02_家計データ/03_月次集計/04_検証ログ

4.Gemini接続テストを行う

次の関数を選びます。

testGeminiConnection

実行ログに次のように表示されれば接続成功です。

Gemini接続成功
モデル:gemini-3.1-flash-lite

エラーが出る場合は、次を確認してください。

APIキーを正しくコピーしたか/プロパティ名がGEMINI_API_KEYになっているか/Gemini APIキーが有効か/モデル名を変更していないか
STEP4 Drive APIの有効化 つまずきポイントとしてDriveAppではなく高度な拡張サービスであるDrive APIを使用しないとDrive is not definedエラーが発生することを示す図

5.テスト用ファイルを処理待ちへ入れる

Google Driveの次のフォルダへ、画像またはPDFを1枚保存します。

01_処理待ち

最初は、個人情報が含まれていない手書き家計簿や、不要な情報を隠したレシートを使用します。

説明用の手書き例は、次のような内容です。

2026年6月1日 〇〇スーパー 食費 1,280円 カード
2026年6月2日 〇〇ドラッグ 日用品 980円 現金
2026年6月3日 〇〇鉄道 交通費 420円 ICカード

6.processFirstWaitingFileを実行する

次の関数を選びます。

processFirstWaitingFile

正常に処理されると、ログには次のような流れが表示されます。

原本検証完了→OCR開始→OCRドキュメント作成完了→OCR原文取得完了→Gemini解析開始→Gemini解析完了→家計データ登録完了→02_処理済みへ移動
STEP5&6 初回手動テスト テスト画像を01処理待ちフォルダへ保存しprocessFirstWaitingFileを実行するとGemini解析を経て02処理済みへ自動移動しエラー時は03要確認へ移動する図

処理後は、次の場所を確認します。

確認場所確認内容
02_家計データ支出が行ごとに登録されているか
01_OCR原文OCR文字とAI結果が保存されているか
04_検証ログ処理時間やエラーが記録されているか
02_処理済み原本が移動したか
03_要確認読み取りに問題がある原本が移動したか

7.必ず原本と照合する

自動登録されたデータは、最初から「確認済み」にはなりません。

確認状態:未確認 または 確認状態:要確認

として登録されます。特に確認する項目は、次の3つです。

利用日/金額/支払方法

AIが次のように読み違える可能性もあります。

原本:1,050円 → AI:10,050円

処理がエラーなく完了しても、内容が正しいとは限りません。登録後に、原本URLから画像を開き、スプレッドシートの内容と比較します。

STEP7 人間による最終確認 確認状態未確認から始まる運用ルール 利用日金額支払方法の3点を重点的に照合しAIは完璧ではないため原本URLから画像を開いて金額の桁数を確認する図

8.自動処理を有効にする

1枚の手動テストが成功したら、次の関数を実行します。

createFiveMinuteTrigger

これで、5分ごとに01_処理待ちを確認するトリガーが作成されます。

スマートフォンで撮影01_処理待ちへ保存最大約5分後にGASが確認OCR・Gemini・登録処理

トリガーによる実行時刻は、処理状況によって多少前後する可能性があります。また、トリガーは作成したGoogleアカウントの権限で実行されます。

停止したい場合は、次の関数を実行します。

deleteAutomationTriggers
STEP8 フル自動化の有効化 createFiveMinuteTriggerを実行し5分間隔の自動監視トリガーをセット スマホで撮影しGoogle Driveへアップロードすると最大5分以内にGASが自動起動してスプレッドシートへの登録が自動完了する図

よくあるエラー

Cannot call SpreadsheetApp.getUi() from this context

独立型Apps Scriptで、次のコードを使用すると発生します。

SpreadsheetApp.getUi().alert('完了');

今回掲載したコードでは、getUi()を使用していません。実行結果は、実行ログと00_設定シートへ表示します。

GEMINI_API_KEYが登録されていません

スクリプトプロパティを確認してください。

プロパティ名:GEMINI_API_KEY
値:自分で取得したAPIキー

API_KEYやGEMINI_KEYでは、今回のコードから取得できません。

Drive is not defined

Google Drive拡張サービスが有効になっていない可能性があります。次を確認します。

Apps ScriptサービスDrive APIv3

または、appsscript.jsonに次の設定があるか確認します。

{ "userSymbol": "Drive", "version": "v3", "serviceId": "drive" }

Gemini APIエラー:HTTP 400

主な原因として、次の可能性があります。

モデル名が正しくない/JSONスキーマが正しくない/対応していないファイル形式/リクエスト内容が大きすぎる

Config.gsのモデル名を確認します。

gemini-3.1-flash-lite

Gemini 3.1 Flash-Liteは、2026年5月に安定版として公開され、画像・PDF入力と構造化出力に対応しています。

HTTP 429

Gemini APIの利用上限に達した可能性があります。少し時間を空けてから、再実行します。大量のファイルを一度に入れず、まず1枚ずつ試してください。

OCR文字が空になる

次の原因が考えられます。

画像が暗い/文字が小さい/ピントが合っていない/PDFの変換に時間がかかっている/原本が文字として認識しにくい

今回のコードでは、OCRドキュメント作成後に最大6回、本文取得を再試行します。それでも取得できない場合は、原本が03_要確認へ移動します。

よくあるエラーと解決策トラブルシューティング表 HTTP400はモデル名間違いやファイル形式非対応 HTTP429はAPI利用上限到達 OCR文字が空になるのは画像が暗い文字が小さいPDF変換の遅延が主な原因

第3回の確認チェックリスト

すべてチェックできたら +20XP です。

🎯 理解度チェッククイズ(全5問)

各問正解で +12XP。全問正解で「👑全問正解」バッジを獲得できます。

Q1. Config.gsで自分のIDへ変更する必要があるのはどの2つ?

GEMINI_MODELとOCR_LANGUAGE ROOT_FOLDER_IDとSPREADSHEET_ID MAX_FILES_PER_RUNとMAX_FILE_BYTES

Q2. Gemini APIキーはどこに保存し、コードのどこから読み取りますか?

Config.gsの定数に直接書き込む スクリプトプロパティに保存し、PropertiesServiceで読み取る 00_設定シートのセルに書き込む

Q3. Geminiへの金額判定プロンプトで、使用しないよう指示している数字は?

小計、税抜額、消費税、預かり金、お釣り 最終的な支払合計金額 レシートに印字された日付

Q4. Utils.gsで「カード」より先にICカードを判定しているのはなぜ?

ICカードの方が読み取り精度が高いから ICカードがクレジットカードとして誤変換されるのを防ぐため Google Driveの仕様上そうする必要があるから

Q5. 自動登録された家計データの初期状態として正しいのは?

常に「確認済み」として登録される 「未確認」または「要確認」として登録され、人の照合が前提 登録前にすべて自動で確定される

❓ よくある質問

Q. setupSystemを何度も実行すると重複してフォルダやシートが作られますか?
既存の同名フォルダ・シートがあればそれを再利用する作りになっているため、setupSystemを再実行しても重複作成はされません。設定を見直したいときも安心して再実行できます。
Q. processFirstWaitingFileとprocessWaitingFilesの違いは?
processFirstWaitingFileは処理待ちフォルダの先頭1件だけを処理する初回テスト用の関数です。processWaitingFilesは、5分ごとの自動トリガーから呼び出され、1回の実行でMAX_FILES_PER_RUN(既定3件)までまとめて処理します。
Q. Gemini APIエラーでHTTP 400が出たら何を確認すればいいですか?
モデル名がConfig.gsのgemini-3.1-flash-liteのままになっているか、対応していないファイル形式を送っていないか、リクエスト内容が大きすぎないかを確認してください。記事公開後にモデル名が変更・終了している可能性もあります。

解決ドットコム ワンポイント

今回のGASは、AIに正解を決めさせる仕組みではありません。

処理の役割を分けています。

Google Drive OCR =文字を抽出する
Gemini =画像と文字から家計項目を整理する
GAS =処理を順番につなぐ
スプレッドシート =結果を保存・集計する
人 =日付と金額を最終確認する
役割分担AIと人間のハイブリッド体制 AIシステムの役割はOCRテキスト抽出や複雑なレイアウトの文脈理解表記揺れの統一 人間の役割は日付が正しいかの確認最終支払金額の桁数チェックAIが迷ったデータの判断

AIを使うときは、「人の確認をなくせるか」ではなく、次のように考える方法があります。

人がゼロから入力する時間を、確認だけで済む状態へ減らせるか。

今回の検証では、手書き家計簿とレシートの両方で、Driveへの保存からスプレッドシート登録まで動作しました。ただし、読み取り結果を確認する工程は残しています。

🐤
カイピヨくんの一言
自動化は「確認しなくてよい仕組み」ではなく、「確認しやすい下書きを作る仕組み」と考えるピヨ。
自動化の本当のゴールとは ゼロからの手入力作業はAIが代替し人間は作られた下書きを承認するだけの役割へ移行する これで手書き家計簿レシート処理の自動化は完了 詳細な検証ログや集計方法は第4回へ続く
📚 次回(第4回)予告
実際の検証結果、OCRとAIの精度、失敗例、月次集計、グラフ、運用上の注意点、FAQ、チェックリストをまとめます。

\ 最新情報をチェック /