💡 この記事でわかること
この記事では、次のような「自動家計簿」を構築します。
✅ 支出を入力するだけでAIがカテゴリ(食費・通信・交通など)を自動分類
✅ 重複データを自動検出して除外
✅ 月ごとの支出・収入を自動集計
✅ スプレッドシートだけで動作(外部ツール不要)
🧩 準備するもの
-
Googleアカウント
-
OpenAIのAPIキー(※ChatGPT Plusとは別)
-
新しいGoogleスプレッドシート
⚙️ Googleスプレッドシートを作成
1️⃣ 新しいスプレッドシートを開く
2️⃣ シート名を「家計簿」に変更
3️⃣ 1行目に以下のヘッダを入力
| 日付 | 内容 | 金額 | 種別 | カテゴリ | メモ | 処理済 | 行ID |
🧠 AI自動分類スクリプト
スプレッドシート上部メニューから
拡張機能 → Apps Script を開き、下記のコードをすべて貼り付けて保存。
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
/**************************************************** * 家計簿 AI自動化スクリプト(分類+重複+月次集計)確定版 * シート: 「家計簿」(本体), 「集計」(④で自動生成) * 1行目ヘッダ: 日付, 内容, 金額, 種別, カテゴリ, メモ, 処理済, 行ID * 必須: プロジェクトのプロパティ → スクリプトのプロパティ * OPENAI_API_KEY を設定 ****************************************************/ const CFG = { SHEET: '家計簿', SUMMARY_SHEET: '集計', HEADER: ['日付','内容','金額','種別','カテゴリ','メモ','処理済','行ID'], DONE: '済', COL: {DATE:1, DESC:2, AMT:3, TYPE:4, CAT:5, NOTE:6, DONE:7, ID:8}, // 1-based OPENAI: { URL: 'https://api.openai.com/v1/chat/completions', MODEL: 'gpt-4o-mini', SYSTEM: 'あなたは家計簿のカテゴリ分類器です。内容と金額を見て、日本の家計簿で一般的な短いカテゴリ名を1語で返してください(例:食費, 日用品, 交通, 住居, 通信, サブスク, 医療/保険, 交際/娯楽, 衣類/美容, 税金, 事業, 収入)。出力はカテゴリ名のみ。' } }; /* ========= メニュー ========= */ function onOpen(){ SpreadsheetApp.getUi() .createMenu('家計簿(AI)') .addItem('① 初期化','initSheet') .addItem('② AIで全行分類','aiClassifyAll') .addItem('③ 重複チェック&ID付与','assignIds') .addItem('④ 集計を再生成','rebuildSummary') .addSeparator() .addItem('未分類だけAI再分類','reclassifyUncategorized') .addSeparator() .addItem('列幅を自動調整','fitColumns') .addToUi(); } /* ========= 初期化 ========= */ function initSheet(){ const sh = getSheet_(); sh.clear(); sh.getRange(1,1,1,CFG.HEADER.length).setValues([CFG.HEADER]); sh.setFrozenRows(1); formatArea_(sh); SpreadsheetApp.getUi().alert('初期化完了。2行目以降に「日付/内容/金額」を入れて②→③→④の順で。'); } /* ========= AIで全行分類 ========= */ function aiClassifyAll(){ const key = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); if(!key){ SpreadsheetApp.getUi().alert('OPENAI_API_KEY が未設定です'); return; } const sh = getSheet_(); const last = sh.getLastRow(); if (last < 2) { SpreadsheetApp.getUi().alert('データがありません'); return; } const vals = sh.getRange(2,1,last-1,CFG.HEADER.length).getValues(); for (let i=0;i<vals.length;i++){ const desc = str_(vals[i][CFG.COL.DESC-1]); const amt = toNum_(vals[i][CFG.COL.AMT-1]); if (!desc && amt===0) continue; // 種別(正=収入 / 負=支出)を自動補正 vals[i][CFG.COL.TYPE-1] = amt>=0 ? '収入' : '支出'; // AI分類(失敗時は目印を残す) const cat = callAI_(key, desc, amt); vals[i][CFG.COL.CAT-1] = (cat && cat.replace(/[。\s ]/g,'')) || '未分類(AIなし)'; Utilities.sleep(150); // 軽いレート緩和 } sh.getRange(2,1,vals.length,CFG.HEADER.length).setValues(vals); formatArea_(sh); SpreadsheetApp.getUi().alert('AI分類 完了。'); } /* ========= 未分類だけAI再分類 ========= */ function reclassifyUncategorized(){ const key = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); if(!key){ SpreadsheetApp.getUi().alert('OPENAI_API_KEY が未設定です'); return; } const sh = getSheet_(); const last = sh.getLastRow(); if (last < 2) return; const vals = sh.getRange(2,1,last-1,CFG.HEADER.length).getValues(); let updated = 0; for (let i=0;i<vals.length;i++){ const catNow = str_(vals[i][CFG.COL.CAT-1]); const desc = str_(vals[i][CFG.COL.DESC-1]); let amt = toNum_(vals[i][CFG.COL.AMT-1]); if ((!catNow || catNow.startsWith('未分類')) && (desc || amt!==0)){ vals[i][CFG.COL.TYPE-1] = amt>=0 ? '収入' : '支出'; // 念のため補正 const cat = callAI_(key, desc, amt); vals[i][CFG.COL.CAT-1] = (cat && cat.replace(/[。\s ]/g,'')) || '未分類(AIなし)'; updated++; Utilities.sleep(150); } } sh.getRange(2,1,vals.length,CFG.HEADER.length).setValues(vals); SpreadsheetApp.getUi().alert(`未分類のみAI再分類:${updated}件更新`); } /* ========= 重複チェック&ID付与 ========= */ function assignIds(){ const sh = getSheet_(); const last = sh.getLastRow(); if (last < 2) { SpreadsheetApp.getUi().alert('データがありません'); return; } const rg = sh.getRange(2,1,last-1,CFG.HEADER.length); const vals = rg.getValues(); const seen = new Set(); for (let i=0;i<vals.length;i++){ const d = vals[i][CFG.COL.DATE-1]; const desc= str_(vals[i][CFG.COL.DESC-1]); const amt = toNum_(vals[i][CFG.COL.AMT-1]); if (!desc && amt===0 && !str_(d)) continue; const id = makeId_(d, desc, amt); if (seen.has(id)){ vals[i][CFG.COL.DONE-1] = '重複'; } else { seen.add(id); vals[i][CFG.COL.ID-1] = id; vals[i][CFG.COL.DONE-1] = vals[i][CFG.COL.DONE-1] || CFG.DONE; } } rg.setValues(vals); SpreadsheetApp.getUi().alert('行ID付与&重複マーキング 完了。'); } /* ========= 集計(家計簿 → 集計シート生成) ========= */ function rebuildSummary(){ const ss = SpreadsheetApp.getActiveSpreadsheet(); const src = getSheet_(); const last = src.getLastRow(); if (last < 2) { SpreadsheetApp.getUi().alert('集計対象データがありません'); return; } const vals = src.getRange(2,1,last-1,CFG.HEADER.length).getValues(); // 月次サマリ(収入/支出/差額)& 月×カテゴリ合計 const monthly = new Map(); // ym -> {inc, out} const catAgg = new Map(); // ym||cat -> sum(支出は負・収入は正) const tz = Session.getScriptTimeZone(); const toYM = (d)=>{ if (Object.prototype.toString.call(d) === '[object Date]'){ return Utilities.formatDate(d, tz, 'yyyy-MM'); } const s = String(d || ''); let m = s.match(/(\d{4})[\/.\-](\d{1,2})[\/.\-](\d{1,2})/); if (m) return `${m[1]}-${('0'+m[2]).slice(-2)}`; m = s.match(/(\d{4})年(\d{1,2})月/); if (m) return `${m[1]}-${('0'+m[2]).slice(-2)}`; m = s.match(/(\d{1,2})[\/.\-](\d{1,2})/); // 年欠損→今年 if (m) return `${(new Date()).getFullYear()}-${('0'+m[1]).slice(-2)}`; return '不明'; }; for (const r of vals){ const d = r[CFG.COL.DATE-1]; const amt = toNum_(r[CFG.COL.AMT-1]); const typ = str_(r[CFG.COL.TYPE-1]); const cat = str_(r[CFG.COL.CAT-1]) || '未分類'; if (!amt && !str_(r[CFG.COL.DESC-1])) continue; const ym = toYM(d); if (!monthly.has(ym)) monthly.set(ym,{inc:0, out:0}); const m = monthly.get(ym); if (typ==='収入' || amt>0) m.inc += Math.abs(amt); else m.out += Math.abs(amt); const key = `${ym}||${cat}`; catAgg.set(key, (catAgg.get(key)||0) + amt); } let dst = ss.getSheetByName(CFG.SUMMARY_SHEET); if (!dst) dst = ss.insertSheet(CFG.SUMMARY_SHEET); else dst.clear(); // 月次表 const header1 = ['年月','収入','支出','差額(収入-支出)']; const ymList = Array.from(monthly.keys()).sort(); const rows1 = ymList.map(ym=>{ const x = monthly.get(ym); return [ym, x.inc, x.out, x.inc - x.out]; }); dst.getRange(1,1,1,header1.length).setValues([header1]); if (rows1.length) { dst.getRange(2,1,rows1.length,header1.length).setValues(rows1); dst.getRange(2,2,rows1.length,3).setNumberFormat('#,##0;[Red]-#,##0'); } // カテゴリ別(月×カテゴリ) const startRow = 2 + Math.max(1, rows1.length) + 2; dst.getRange(startRow-1,1).setValue('カテゴリ別(月×カテゴリ合計/収入は正・支出は負)'); const header2 = ['年月','カテゴリ','金額']; const rows2 = Array.from(catAgg.entries()) .sort((a,b)=>a[0].localeCompare(b[0])) .map(([k,v])=>{ const [ym, cat] = k.split('||'); return [ym, cat, v]; }); if (rows2.length){ dst.getRange(startRow,1,1,header2.length).setValues([header2]); dst.getRange(startRow+1,1,rows2.length,header2.length).setValues(rows2); dst.getRange(startRow+1,3,rows2.length,1).setNumberFormat('#,##0;[Red]-#,##0'); } dst.autoResizeColumns(1,5); SpreadsheetApp.getUi().alert('集計を再生成しました。'); } /* ========= OpenAI呼び出し ========= */ function callAI_(key, desc, amount){ const body = { model: CFG.OPENAI.MODEL, messages: [ { role:'system', content: CFG.OPENAI.SYSTEM }, { role:'user', content: `内容: ${desc}\n金額: ${amount}\nカテゴリ名のみを返してください。` } ], temperature: 0, max_tokens: 32 }; for (let attempt=0; attempt<2; attempt++){ const res = UrlFetchApp.fetch(CFG.OPENAI.URL, { method: 'post', payload: JSON.stringify(body), contentType: 'application/json', headers: { Authorization: `Bearer ${key}` }, muteHttpExceptions: true, timeout: 20000 }); const code = res.getResponseCode(); if (code === 200){ const js = JSON.parse(res.getContentText()); return js?.choices?.[0]?.message?.content?.trim() || null; } // 429/5xxは軽くリトライ if (code === 429 || code >= 500) Utilities.sleep(600); else break; } return null; } /* ========= 表示整形 ========= */ function formatArea_(sh){ try{ const head = sh.getRange(1,1,1,CFG.HEADER.length); head.setFontWeight('bold').setBackground('#eeeeee'); const last = Math.max(2, sh.getLastRow()); sh.getRange(2,CFG.COL.DATE,last-1,1).setNumberFormat('yyyy-mm-dd'); sh.getRange(2,CFG.COL.AMT, last-1,1).setNumberFormat('#,##0;[Red]-#,##0').setHorizontalAlignment('right'); sh.getRange(1,1,last,CFG.HEADER.length).setWrap(false); sh.autoResizeColumns(1,CFG.HEADER.length); }catch(e){ Logger.log('formatArea_ error: '+e); } } function fitColumns(){ const sh = SpreadsheetApp.getActiveSheet(); sh.getRange(1,1,sh.getMaxRows(),CFG.HEADER.length).setWrap(false); sh.autoResizeColumns(1,CFG.HEADER.length); } /* ========= ヘルパ ========= */ function getSheet_(){ const ss = SpreadsheetApp.getActiveSpreadsheet(); return ss.getSheetByName(CFG.SHEET) || ss.insertSheet(CFG.SHEET); } function str_(v){ return (v==null)?'':String(v).trim(); } function toNum_(v){ if (typeof v === 'number') return v; if (v==null || v==='') return 0; const n = Number(String(v).replace(/[,\s ]/g,'')); return isNaN(n) ? 0 : n; } function makeId_(dateCell, desc, amt){ const tz = Session.getScriptTimeZone(); let sDate = ''; if (Object.prototype.toString.call(dateCell) === '[object Date]'){ sDate = Utilities.formatDate(dateCell, tz, 'yyyy-MM-dd'); } else { sDate = String(dateCell||''); } const base = `${sDate}||${desc}||${amt}`; const raw = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, base, Utilities.Charset.UTF_8); return raw.map(b=>('0'+(b & 0xff).toString(16)).slice(-2)).join('').slice(0,16); } |
🔑 APIキー設定
1️⃣ Apps Script の右上歯車 → プロジェクトのプロパティ
2️⃣ 「スクリプトのプロパティ」タブ
3️⃣ OPENAI_API_KEY
を追加(値にあなたのAPIキー)
4️⃣ 保存
🧾 サンプルデータで動作確認
日付 | 内容 | 金額 |
---|---|---|
2025/10/01 | マクドナルド 渋谷店 | -850 |
2025/10/02 | 給与振込 ○○株式会社 | 250000 |
2025/10/03 | 東京電力 電気料金 | -6200 |
2025/10/04 | Netflix 月額課金 | -990 |
2025/10/05 | JR定期券購入 | -14000 |
1️⃣ 貼り付け
2️⃣ メニュー「家計簿(AI) → ② AIで全行分類」
3️⃣ カテゴリがAIで自動分類
4️⃣ 「③ 重複チェック&ID付与」
※以下は未課金なのでカテゴリは未分類になります。
5️⃣ 「④ 集計を再生成」→「集計」シートに月次サマリが出力
📊 集計結果の例
🚨 よくあるエラーと解決策
症状 | 原因 | 対処 |
---|---|---|
429: insufficient_quota | API残高ゼロ | OpenAI Billingページで5ドル課金 |
APIキー未設定エラー | スクリプトのプロパティ未登録 | 設定手順を再確認 |
カテゴリ空欄 | ChatGPT Plusのみ契約 | API側に残高を追加する必要あり |
権限ダイアログが出ない | 実行権限未付与 | スクリプト初回実行で許可 |
✅ まとめ
-
ChatGPT Plus だけでは連携できない。APIキーを使う。
-
スプレッドシート内で AI が自動分類・集計まで完結。
-
収支を貼るだけで月次の家計簿が完成。