何を自動化したか
請求書や領収書の整理は、メールの添付を保存して月ごとに分け、紙の分は日付や金額を書き写す、という手作業になりやすい。ためると月末や申告の前にまとめて探すことになる。
この教室では2本の定期処理にしている。毎朝の officeFileInvoices が請求書・領収書らしいメールの添付をGoogleドライブの年/月フォルダへ保存して台帳シートに記録し、4時間ごとの officeOcrInbox が「OCR受信箱」に入れた書類の画像・PDFをGeminiで読み取って、種別・日付・金額・発行元を別の台帳に書く。作業時間は計測していないので、削減量は数字では示さない。
仕組み(データの流れ)
| 処理 | 実行 | 読む場所 | 書く場所 |
|---|---|---|---|
officeFileInvoices | 毎朝6時台(autoDailyMorning) | Gmail。直近7日・添付あり・請求書/領収書/invoice/receipt を含むスレッドを最大20件 | ドライブ「経理書類/年/月」、シート「経理書類」 |
officeOcrInbox | 4時間ごと(autoEvery4Hours) | ドライブ「OCR受信箱」の画像・PDFを1回5件まで(1日最大30件) | シート「書類OCR」。ファイルは改名して「OCR処理済み」へ |
フォルダはすべてドライブの「ブリッジ自動化」の下にある。2本は独立していて、メールの添付が自動でOCRにかかることはない。処理済みかどうかは台帳ではなく、ラベルとフォルダの移動で見分ける。処理したスレッドにはGmailのラベル「ブリッジ自動化/経理済み」が付き、読み取った書類は「OCR受信箱」から「OCR処理済み」へ移るので、Gmailとドライブの画面で確かめられる。「書類OCR」の領収書・請求書の金額は、毎月1日の月次レポートで経費として合計される。
コード(要点)
毎朝の添付保存。検索、ラベルの確認、年/月フォルダへの保存、台帳への記録、ラベル付けの順に進む。
function officeFileInvoices() {
var label = officeGetLabel('ブリッジ自動化/経理済み');
var threads = GmailApp.search('has:attachment (請求書 OR 領収書 OR invoice OR receipt) newer_than:7d', 0, 20);
var sheet = autoSheet('経理書類', ['処理日時', '出所', '件名/ファイル名', '保存先URL']);
var now = new Date();
var yearFolder = autoSubFolder(autoFolder('経理書類'), Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy'));
var monthFolder = autoSubFolder(yearFolder, Utilities.formatDate(now, 'Asia/Tokyo', 'MM'));
var saved = 0;
threads.forEach(function(thread) {
// 処理済みラベルが付いたスレッドはスキップ
if (thread.getLabels().some(function(l) { return l.getName() === label.getName(); })) return;
thread.getMessages().forEach(function(msg) {
msg.getAttachments().forEach(function(att) {
try {
var file = monthFolder.createFile(att.copyBlob());
file.setName(Utilities.formatDate(msg.getDate(), 'Asia/Tokyo', 'yyyyMMdd') + '_' + att.getName());
sheet.appendRow([new Date(), msg.getFrom(), msg.getSubject() + ' / ' + att.getName(), file.getUrl()]);
saved++;
} catch (e) {
Logger.log('添付保存失敗: ' + e.message);
}
});
});
thread.addLabel(label);
});
Logger.log('経理書類: ' + saved + '件保存');
}
function officeGetLabel(name) {
return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}
autoFolder・autoSubFolder・autoSheet もラベルと同じく、名前で探して無ければ作る共通関数で、初回の実行でフォルダ・シート・ラベルがそろう。保存の失敗は添付1件ごとに捕まえ、残りは続ける。
4時間ごとのOCR。画像とPDF以外は飛ばす。
function officeOcrInbox() {
var inbox = autoFolder('OCR受信箱');
var done = autoFolder('OCR処理済み');
var sheet = autoSheet('書類OCR', ['処理日時', 'ファイル名', '種別', '日付', '金額', '発行元', '概要', 'ファイルURL']);
var files = inbox.getFiles();
var processed = 0;
while (files.hasNext() && processed < 5) { // Gemini呼び出し節約のため1回5件まで
var file = files.next();
var mime = file.getMimeType();
if (mime.indexOf('image/') !== 0 && mime !== 'application/pdf') {
Logger.log('OCR対象外のためスキップ: ' + file.getName() + ' (' + mime + ')');
continue;
}
try {
var raw = autoGeminiVision(file.getBlob(), [
'この書類を読み取り、次のJSONで返してください。読み取れない項目は空文字。',
'{"type":"領収書|請求書|申込書|テスト|手紙|その他","date":"yyyy-MM-dd","amount":"金額(数字のみ)","issuer":"発行元・差出人","summary":"内容の要約1文"}',
].join('\n'));
var info;
try {
info = autoParseJson(raw);
} catch (e) {
// JSONにならなくても読み取り結果は残す(要約欄に生テキスト)
info = { type: 'その他', date: '', amount: '', issuer: '', summary: raw.slice(0, 100) };
}
sheet.appendRow([new Date(), file.getName(), info.type || '', info.date || '', info.amount || '', info.issuer || '', info.summary || '', file.getUrl()]);
// 内容に基づいてリネームして処理済みへ移動
// …(省略)…
file.moveTo(done);
processed++;
} catch (e) {
Logger.log('OCR失敗(' + file.getName() + '): ' + e.message);
}
}
// …(省略)…
}
応答がJSONとして読めなくても、生のテキストを概要欄に残して進む。記録のあとは、省略した部分でファイル名を「日付_種別_発行元_元の名前」に変えてから「OCR処理済み」へ移す。Geminiの呼び出しで失敗したファイルは「OCR受信箱」に残り、次の回に読み直される。1回5件の上限は処理できた件数で数え、失敗した分は数に入らない。読めないファイル(壊れたPDFなど)は取り出すまで毎回読み直されるので、同じファイルの失敗が続いたら「OCR受信箱」から出す。
Geminiへの送信は共通関数で、ファイルをBase64にして inline_data で送る(抜粋)。
/** 画像・PDFの読み取り(OCR・内容理解)。blob は Drive/Gmail の Blob */
function autoGeminiVision(blob, prompt) {
var url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ AUTO_GEMINI_TEXT_MODEL + ':generateContent?key=' + autoGeminiKey();
var res = autoGeminiFetch_(url, {
method: 'POST',
contentType: 'application/json',
payload: JSON.stringify({
contents: [{ parts: [
{ text: prompt },
{ inline_data: { mime_type: blob.getContentType(), data: Utilities.base64Encode(blob.getBytes()) } }
] }],
generationConfig: { temperature: 0.1, maxOutputTokens: 2000, thinkingConfig: { thinkingBudget: 0 } }
}),
muteHttpExceptions: true
});
var json = JSON.parse(res.getContentText());
// …(省略)…
return json.candidates[0].content.parts.map(function(p) { return p.text || ''; }).join('').trim();
}
gemini-2.5系は既定で「思考」にもトークンを使い、出力が途中で切れることがある。thinkingBudget: 0 はそれを避ける設定だ。省略部分では、応答に candidates が無ければ失敗を記録してから例外を投げる。記録は毎晩のまとめ通知に載る(トリガー集約の記事。同じ種類の失敗の繰り返しは毎晩ではなく間を空けて通知され、全件は台帳と同じスプレッドシートの「通知抑制ログ」シートに残る)。ブラウザから直接送る版はレシートOCR家計簿で扱った。
ハマった点と回避策
モデルの廃止で読み取りが止まる
共通関数のコメントには、過去に gemini-1.5-flash の廃止で404になった前歴が記録されている。そこで2026-07-05から、モデル名をスクリプトプロパティ AUTO_GEMINI_TEXT_MODEL で上書きできるようにしてある(未設定なら gemini-2.5-flash)。OCRもテキスト生成と同じこの設定を使うので、モデル名はコードを直さずに差し替えられる(生成の設定が新しいモデルで通るかは、差し替えた後の実行で確かめる)。毎晩のまとめ通知にもこの手順を書いてある。
作業フォルダは空に見えても消さない
2026-07-02にドライブの整理候補をまとめた報告には、「ブリッジ自動化」の下の作業フォルダについて「空でもトリガーが使うので残す」と明記してある。OCR受信箱は処理が進むほど空になるのが正常だが、空のフォルダは整理で消されやすい。コードは名前で探して無ければ作るだけで、消されたことを知らせる処理は無い。同名のフォルダが2つあると getFoldersByName が返す最初の1つを使うため、どちらに保存されるかはコードからは決まらない。
目印はスレッド単位、年/月は実行日で決まる
事故の記録は無いが、コードを読み返すと注意点が2つある。1つは、ラベルがスレッド単位で付くこと。処理済みのスレッドに後から届いた添付は飛ばされ、保存に失敗してもラベルは付く(失敗はログにだけ残る)。ラベルを外せば、直近7日・20件の検索範囲にある限り翌朝拾い直すが、前に保存した分も重複する。もう1つは、年/月フォルダが実行した日で決まること。月末日の実行より後に届いたメールは翌月のフォルダに入るので、受信日はファイル名の先頭の日付で見る。
導入手順
- Gemini APIキーを取得し、スクリプトプロパティ
GEMINI_API_KEYに保存する。このキーは課金を有効にした有料枠で使う。規約(Gemini API 追加利用規約)には、無料枠では送った内容がGoogleの製品やサービスの改善に使われ、人が読むこともあると書かれており、無料枠には個人情報を送らないよう求めている。申込書やテストの写真を読ませるこの処理は、有料枠にしてから動かす。この教室も有料枠で使っている。 - 本文のコードと、そこから呼ぶ共通関数を1つのGASプロジェクトに置く。共通関数は自分で用意する。
autoGeminiKeyはスクリプトプロパティGEMINI_API_KEYを読んで返し(無ければ例外)、AUTO_GEMINI_TEXT_MODELはプロパティAUTO_GEMINI_TEXT_MODELの値(未設定なら'gemini-2.5-flash')を入れておく変数。autoHubは台帳のスプレッドシートを返し、無ければ作ってIDをスクリプトプロパティAUTOMATION_HUB_IDに保存する。autoSheet・autoFolder・autoSubFolderはシートやフォルダを名前で探し、無ければ作る。autoParseJsonは応答から{…}(または[…])を取り出してJSON.parseし、autoGeminiFetch_は混雑時に再試行するUrlFetchApp.fetchにあたる。 - GASエディタで
officeFileInvoicesとofficeOcrInboxを1回ずつ手で実行し、権限を承認する。フォルダ・シート・ラベルはこのとき作られる。officeFileInvoicesの初回の実行で、直近7日の該当メール(最大20スレッド)がその場で処理され、添付の保存とラベル付けまで進む。 - Gmailの検索式を、自分に届く請求書メールに合わせて調整する(
from:やsubject:を足すなど)。 - 件名に「請求書」と入れたPDF付きのメールを自分宛に送り、
officeFileInvoicesを実行して、ファイル・台帳の行・ラベルができるかを見る。 - 領収書の写真を「OCR受信箱」に入れて
officeOcrInboxを実行し、「書類OCR」に1行増えてファイルが移るかを見る。 - 毎朝(
officeFileInvoices)と4時間ごと(officeOcrInbox)の時間主導型トリガーを1本ずつ作る(この教室ではトリガーを増やさず、autoDailyMorningにautoRunSafely('請求書・領収書の整理', officeFileInvoices);、autoEvery4HoursにautoRunSafely('OCR受信箱の処理', officeOcrInbox);を1行ずつ置いている)。
よくある質問
Q. メールで届いた請求書のPDFもOCRされますか
されない。メールの添付は「経理書類」フォルダへの保存と台帳への記録までで、中身を読ませたい書類は「OCR受信箱」に入れる。
Q. 署名のロゴ画像まで保存されませんか
されることがある。getAttachments() は本文に埋め込まれた画像も既定で含む(公式リファレンス)。今のコードは区別しておらず、除くなら getAttachments({ includeInlineImages: false }) を使う。
筆者の本とnote(有料)で、まとめて読む
- Kindle『個人塾長、AIを雇う。』(500円・Kindle Unlimited対応)一人塾の自動化を、何をどの順で進めたか・失敗も含めて1冊にまとめた実録
- note『個人塾をGASで自動化した実録(実コード付き)』(1,800円)教室で動かしているGAS×AIの仕組みを、手順とコードつきでまとめた有料記事
- English edition: How I Automated My One-Man Tutoring School with AI ($6.99)