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

再生速度:1.0x1.25x1.5x2.0x
前回は、家計簿OCR自動化に必要な次の環境を準備しました。
今回は、実際に動作確認したGASコードを、ファイルごとに全文掲載します。
完成すると、次の処理を行えるようになります。
今回使用するGoogle Drive拡張サービスは、Apps ScriptからDrive APIを利用するための機能です。使用前に、Apps Scriptのサービスまたはappsscript.jsonで有効化する必要があります。
Gemini 3.1 Flash-Liteは、テキスト、画像、PDFなどの入力と構造化出力に対応しています。ただし、JSON形式が正しくても、日付や金額の内容まで正しいとは限りません。Google公式も、構造化出力後に値を検証するよう案内しています。
そのため、今回の仕組みでは自動登録後も、原本と金額を人が確認します。
今回作成するGASファイル
Apps Scriptの左側にある「+」から、次のスクリプトファイルを作成してください。
また、プロジェクト設定から表示した次のファイルも使用します。
ファイル追加時は、入力欄にConfigと入力します。Apps Script側で.gsが付くため、Config.gsと入力すると、環境によってはConfig.gs.gsと表示されることがあります。
1.Config.gs
このファイルでは、フォルダID、スプレッドシートID、Geminiモデル、カテゴリなどを設定します。
次の2か所を、自分のIDへ変更してください。
SPREADSHEET_ID: 'ここにスプレッドシートID',
APIキーは、このコードには入力しません。
/**
* 家計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_設定シートへ記録します。
/**
* 家計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自動化
* 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公式も、キーをソースコードへ埋め込んだり、公開したりしないよう案内しています。
/**
* 家計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、スプレッドシート登録、ファイル移動を順番に実行します。
/**
* 家計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スプレッドシートへ登録します。
/**
* 家計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自動化
* 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を、次の内容へ全文置き換えます。
{
"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へ変更します。
フォルダURLが次の場合、
使用するのは次の部分です。
スプレッドシートURLが次の場合、
使用するのは次の部分です。
2.APIキーを登録する
次の内容を登録します。
| プロパティ | 値 |
|---|---|
| GEMINI_API_KEY | 自分で取得したGemini APIキー |
APIキーそのものは、この記事のコードには入力しません。
3.setupSystemを実行する
Apps Script上部の関数選択から、次を選びます。
「実行」を押します。初回はGoogleアカウントの権限確認が表示されます。使用している自分のGoogleアカウントを選び、必要な権限を許可します。
成功すると、実行ログに次のような内容が表示されます。
Google Driveには、次のフォルダが作成されます。
├─ 01_処理待ち
├─ 02_処理済み
├─ 03_要確認
└─ 04_OCRドキュメント
スプレッドシートには、次のシートが作成されます。
4.Gemini接続テストを行う
次の関数を選びます。
実行ログに次のように表示されれば接続成功です。
モデル:gemini-3.1-flash-lite
エラーが出る場合は、次を確認してください。
5.テスト用ファイルを処理待ちへ入れる
Google Driveの次のフォルダへ、画像またはPDFを1枚保存します。
最初は、個人情報が含まれていない手書き家計簿や、不要な情報を隠したレシートを使用します。
説明用の手書き例は、次のような内容です。
2026年6月2日 〇〇ドラッグ 日用品 980円 現金
2026年6月3日 〇〇鉄道 交通費 420円 ICカード
6.processFirstWaitingFileを実行する
次の関数を選びます。
正常に処理されると、ログには次のような流れが表示されます。
処理後は、次の場所を確認します。
| 確認場所 | 確認内容 |
|---|---|
| 02_家計データ | 支出が行ごとに登録されているか |
| 01_OCR原文 | OCR文字とAI結果が保存されているか |
| 04_検証ログ | 処理時間やエラーが記録されているか |
| 02_処理済み | 原本が移動したか |
| 03_要確認 | 読み取りに問題がある原本が移動したか |
7.必ず原本と照合する
自動登録されたデータは、最初から「確認済み」にはなりません。
として登録されます。特に確認する項目は、次の3つです。
AIが次のように読み違える可能性もあります。
処理がエラーなく完了しても、内容が正しいとは限りません。登録後に、原本URLから画像を開き、スプレッドシートの内容と比較します。
8.自動処理を有効にする
1枚の手動テストが成功したら、次の関数を実行します。
これで、5分ごとに01_処理待ちを確認するトリガーが作成されます。
トリガーによる実行時刻は、処理状況によって多少前後する可能性があります。また、トリガーは作成したGoogleアカウントの権限で実行されます。
停止したい場合は、次の関数を実行します。
よくあるエラー
Cannot call SpreadsheetApp.getUi() from this context
独立型Apps Scriptで、次のコードを使用すると発生します。
今回掲載したコードでは、getUi()を使用していません。実行結果は、実行ログと00_設定シートへ表示します。
GEMINI_API_KEYが登録されていません
スクリプトプロパティを確認してください。
値:自分で取得したAPIキー
API_KEYやGEMINI_KEYでは、今回のコードから取得できません。
Drive is not defined
Google Drive拡張サービスが有効になっていない可能性があります。次を確認します。
または、appsscript.jsonに次の設定があるか確認します。
Gemini APIエラー:HTTP 400
主な原因として、次の可能性があります。
Config.gsのモデル名を確認します。
Gemini 3.1 Flash-Liteは、2026年5月に安定版として公開され、画像・PDF入力と構造化出力に対応しています。
HTTP 429
Gemini APIの利用上限に達した可能性があります。少し時間を空けてから、再実行します。大量のファイルを一度に入れず、まず1枚ずつ試してください。
OCR文字が空になる
次の原因が考えられます。
今回のコードでは、OCRドキュメント作成後に最大6回、本文取得を再試行します。それでも取得できない場合は、原本が03_要確認へ移動します。
第3回の確認チェックリスト
すべてチェックできたら +20XP です。
🎯 理解度チェッククイズ(全5問)
各問正解で +12XP。全問正解で「👑全問正解」バッジを獲得できます。
Q1. Config.gsで自分のIDへ変更する必要があるのはどの2つ?
GEMINI_MODELとOCR_LANGUAGE ROOT_FOLDER_IDとSPREADSHEET_ID MAX_FILES_PER_RUNとMAX_FILE_BYTESQ2. Gemini APIキーはどこに保存し、コードのどこから読み取りますか?
Config.gsの定数に直接書き込む スクリプトプロパティに保存し、PropertiesServiceで読み取る 00_設定シートのセルに書き込むQ3. Geminiへの金額判定プロンプトで、使用しないよう指示している数字は?
小計、税抜額、消費税、預かり金、お釣り 最終的な支払合計金額 レシートに印字された日付Q4. Utils.gsで「カード」より先にICカードを判定しているのはなぜ?
ICカードの方が読み取り精度が高いから ICカードがクレジットカードとして誤変換されるのを防ぐため Google Driveの仕様上そうする必要があるからQ5. 自動登録された家計データの初期状態として正しいのは?
常に「確認済み」として登録される 「未確認」または「要確認」として登録され、人の照合が前提 登録前にすべて自動で確定される❓ よくある質問
解決ドットコム ワンポイント
今回のGASは、AIに正解を決めさせる仕組みではありません。
処理の役割を分けています。
Gemini =画像と文字から家計項目を整理する
GAS =処理を順番につなぐ
スプレッドシート =結果を保存・集計する
人 =日付と金額を最終確認する
AIを使うときは、「人の確認をなくせるか」ではなく、次のように考える方法があります。
今回の検証では、手書き家計簿とレシートの両方で、Driveへの保存からスプレッドシート登録まで動作しました。ただし、読み取り結果を確認する工程は残しています。
自動化は「確認しなくてよい仕組み」ではなく、「確認しやすい下書きを作る仕組み」と考えるピヨ。
実際の検証結果、OCRとAIの精度、失敗例、月次集計、グラフ、運用上の注意点、FAQ、チェックリストをまとめます。


