はじめに
以前、LINE + GAS + Geminiを組み合わせた家計簿システムを構築しました。
このアーキテクチャは非常に汎用性が高かったので、今回はその基盤をそのまま流用して、毎年確定申告で一番面倒な医療費控除の集計 を自動化した話と、それに伴いAPIを有料(Pay-as-you-go)に切り替えた理由について書きます。
相変わらずGeminiと相談しつつ実装してみました。
1. アーキテクチャの横展開
基本的な構造(LINE Messaging API -> GAS -> Gemini API -> Sheets)は変えず、プロンプトとスプレッドシートの処理層だけを医療費特化に差し替えました。
医療費特化のプロンプト
基本的には医療費控除で使う項目を並べて自動で入力する形にしています。
残念な点として、OCRの文字認識で令和8年という日付が、どうも2021年や2024年と誤認されるケースがありました。
これを防ぐため、プロンプトでふんわり制御をかけています。
また病院名も取得するたびに揺らぎが出るのはしょうがないと思っています。揺らぎがなくせればいいんですけども。
現在は【${year}年(令和${reiwa}年)】です。
医療機関の領収書画像を解析し、以下の情報をJSON形式で返してください。
1. date: 支払日 (YYYY/MM/DD)
2. facility: 病院・診療所・薬局などの名称
- 【重要】「(医)」「医療法人」「(株)」などの法人格は除外してください。
- 「〇〇受付」などの付帯情報も除外し、純粋な施設名のみに正規化してください。
- 例: 「(医) 山田クリニック 総合受付」 → 「山田クリニック」
3. category: 「診療・治療」「医薬品購入」「介護サービス」「その他」から1つ選択
4. amount: 支払金額(数値のみ、カンマ抜き)
5. is_self_medication: セルフメディケーション税制対象なら true、なければ false
返却は純粋なJSONのみ。Markdown装飾( \`\`\`json )は厳禁。`;
`;
2. 今回も責任分界点の意識
前回の実装ではGAS側で集計ループを回していましたが、今回はスプレッドシート側の関数に集計を任せる設計に変更しました。
合計値を出す計算をさせる場合スプレッドシートを都度見る必要があり記録するタイミングでの遅延がまま気になったためです。
-
集計用シート:
- A列に
UNIQUE、B列にSUMIF、C列に全体のSUMを配置しました
- A列に
-
メリット:
- GAS側のコードがシンプルになり、同期ズレやタイムアウトのリスクが激減しました
- またBotは計算済みのセルを読み取ってLINEに返すだけという責務の分離を実現しました
3.1 Gemini APIを有料化した理由
今回、満を持してGoogle AI Studioの支払設定(有料枠)へ移行しました。理由は2点です。
-
プライバシーの確保:
- 医療費控除にはレシートと違い個人情報がある程度書かれているためそこが無料版だとネックでした
- いっそ有料版(Pay-as-you-go)にすることで、入力データがモデルの学習へ利用されないようにしました
-
クォータ制限の解除:
- そこまで関係はないですが、無料枠のレート制限で止まらない安定性を得ることもできました
3.2 LINE側のプライバシー設定も忘れずに
Line側もトーク内容の利用をする可能性があるようなのでこれを機にオフにしてみました。
-
LINEアプリの設定:
プライバシー管理>情報の提供>トーク内容を利用をオフ - これにより、Botとのやり取りがLINE側のサービス向上のための学習等に利用されるのを防げるようです
また、LINE側のサーバーに画像がキャッシュされるのがどうしても気になる場合は、GASの処理が終わった直後にトーク履歴から画像を送信取り消しするのも手かもしれません。
4. 【おまけ】API費用を資産運用の利回りで相殺し、リスクも遮断する
APIを有料化するにあたり、仮想通貨連携のプリペイドカードを持っていたので決済として採用して実質コストと運用リスクを軽減することを考えてみました。
-
利回りでの充当
- 約100ドルのUSDTステーキング報酬(月1ドル程度)をそのままAPI利用料へスライドするようにしてみました
-
物理的な遮断
- Google Cloudは「まとめ払い」が基本で少額の都度決済ができません
- あえて残高を1ドル程度に絞ったプリペイドを登録しておくことで、バグによる高額請求が発生しても「残高不足による決済エラー」でAPIを強制停止させ、被害を最小限に食い止めます
今回のコード
/**
* 医療費控除集計Bot (LINE + GAS + Gemini API)
* * 事前準備:
* 1. スプレッドシートの「スクリプトプロパティ」に以下の3つを設定してください。
* - LINE_TOKEN: LINE Messaging APIのチャネルアクセストークン
* - GEMINI_API_KEY: Google AI Studioで取得したAPIキー
* - SHEET_NAME: 保存先シート名(例: 医療費2026)
* 2. 「集計用」という名前のシートを作成し、計算式を設定しておいてください。
*/
// ==========================================
// 1. 設定管理
// ==========================================
const props = PropertiesService.getScriptProperties();
const config = {
LINE_TOKEN: props.getProperty('LINE_TOKEN'),
GEMINI_API_KEY: props.getProperty('GEMINI_API_KEY'),
SHEET_NAME: props.getProperty('SHEET_NAME') || '医療費2026'
};
/**
* プロンプト生成関数
* ハードコーディングを避け、実行時の「現在年」を動的に埋め込みます。
*/
function getPrompt() {
const now = new Date();
const year = now.getFullYear();
const reiwa = year - 2018; // 2026年なら令和8年
return `現在は【${year}年(令和${reiwa}年)】です。
医療機関の領収書または薬局のレシート画像を解析し、以下の情報をJSON形式で返してください。
1. date: 支払日 (YYYY/MM/DD)
- 年の記載がない場合は「${year}」と補完してください。
- OCR誤認(2021年や2024年など)に注意し、現在の文脈(${year}年)を最優先してください。
2. facility: 病院・診療所・薬局などの名称
3. category: 「診療・治療」「医薬品購入」「介護サービス」「その他」から1つ選択
4. amount: 支払金額(数値のみ、カンマ抜き)
5. is_self_medication: セルフメディケーション税制対象なら true、なければ false
返却は純粋なJSONのみ。Markdown装飾( \`\`\`json )は厳禁。`;
}
// ==========================================
// 2. メイン処理 (Webhook)
// ==========================================
function doPost(e) {
let replyToken = "none";
try {
const contents = JSON.parse(e.postData.contents);
const event = contents.events[0];
if (!event) return;
replyToken = event.replyToken;
if (event.message.type === 'image') {
const imageBlob = getLineContent(event.message.id);
// 動的プロンプトを使用して解析
const prompt = getPrompt();
const resultText = callGeminiApi(imageBlob, prompt);
// JSONのクリーンアップとパース
const cleanJson = resultText.replace(/```json/gi, "").replace(/```/g, "").trim();
const data = JSON.parse(cleanJson);
// スプレッドシート保存と集計データの取得
const saveResult = saveToSheet(data);
if (saveResult.isDuplicate) {
sendLineReply(replyToken, `⚠️ 重複の可能性があるためスキップしました。\n(${data.date} / ${data.amount}円)`);
return;
}
// 支払先リストの生成
let summaryList = "データなし";
if (saveResult.facilityMap && Object.keys(saveResult.facilityMap).length > 0) {
summaryList = Object.keys(saveResult.facilityMap)
.map(name => `・${name}: ${saveResult.facilityMap[name].toLocaleString()}円`)
.join('\n');
}
const msg = [
`✅ 登録完了`,
`日付: ${data.date}`,
`施設: ${data.facility}`,
`金額: ${Number(data.amount).toLocaleString()}円`,
`------------------`,
`📊 【支払先別累計】`,
summaryList,
`------------------`,
`💰 今年の総計: ${saveResult.grandTotal.toLocaleString()}円`
].join('\n');
sendLineReply(replyToken, msg);
}
} catch (err) {
console.error(err);
if (replyToken !== "none") sendLineReply(replyToken, "⚠️実行エラーが発生しました。詳細はログを確認してください。");
}
}
// ==========================================
// 3. 外部API連携
// ==========================================
function getLineContent(messageId) {
const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
return UrlFetchApp.fetch(url, {
method: 'get',
headers: { 'Authorization': `Bearer ${config.LINE_TOKEN.trim()}` }
}).getBlob();
}
function callGeminiApi(blob, prompt) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${config.GEMINI_API_KEY}`;
const payload = {
contents: [{
parts: [
{ text: prompt },
{ inline_data: { mime_type: "image/jpeg", data: Utilities.base64Encode(blob.getBytes()) } }
]
}],
generationConfig: {
response_mime_type: "application/json",
temperature: 0.1 // 安定性を高めるため低めに設定
}
};
const response = UrlFetchApp.fetch(url, {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const json = JSON.parse(response.getContentText());
if (json.error) throw new Error("Gemini API Error: " + json.error.message);
return json.candidates[0].content.parts[0].text;
}
// ==========================================
// 4. スプレッドシート操作
// ==========================================
function saveToSheet(data) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let dataSheet = ss.getSheetByName(config.SHEET_NAME);
let summarySheet = ss.getSheetByName("集計用");
if (!dataSheet) {
dataSheet = ss.insertSheet(config.SHEET_NAME);
dataSheet.appendRow(["支払日", "支払先", "区分", "金額", "セルフメディ", "登録日時"]);
}
const lastRow = dataSheet.getLastRow();
let allData = (lastRow > 1) ? dataSheet.getRange(2, 1, lastRow - 1, 4).getValues() : [];
const inputDate = String(data.date).replace(/-/g, '/');
// 重複チェック (日付と金額のセットで判定)
const isDuplicate = allData.some(row => {
let sheetDate = (row[0] instanceof Date) ? Utilities.formatDate(row[0], "JST", "yyyy/MM/dd") : String(row[0]).replace(/-/g, '/');
return sheetDate === inputDate && String(row[3]) === String(data.amount);
});
if (isDuplicate) return { isDuplicate: true };
// 排他制御をかけて書き込み
const lock = LockService.getScriptLock();
if (lock.tryLock(10000)) {
try {
dataSheet.appendRow([
inputDate,
data.facility,
data.category,
data.amount,
data.is_self_medication ? "対象" : "-",
new Date()
]);
SpreadsheetApp.flush();
} finally {
lock.releaseLock();
}
}
// 集計用シートからの読み取り
const facilityMap = {};
let grandTotal = 0;
if (summarySheet) {
const lastRowSummary = summarySheet.getLastRow();
if (lastRowSummary >= 1) {
const summaryData = summarySheet.getRange(1, 1, lastRowSummary, 2).getValues();
summaryData.forEach(row => {
if (row[0]) facilityMap[String(row[0])] = Number(row[1]) || 0;
});
}
grandTotal = Number(summarySheet.getRange("C1").getValue()) || 0;
}
return { isDuplicate: false, grandTotal: grandTotal, facilityMap: facilityMap };
}
function sendLineReply(replyToken, text) {
const url = "https://api.line.me/v2/bot/message/reply";
UrlFetchApp.fetch(url, {
method: "post",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${config.LINE_TOKEN.trim()}`
},
payload: JSON.stringify({
replyToken: replyToken,
messages: [{ type: "text", text: text }]
})
});
}