# Freee API自動化実践ガイド|Vertex AIで会計データ分類を実現した開発手法 ## 目次 - [イントロダクション](#イントロダクション) - [プロジェクト概要](#プロジェクト概要) - [技術スタック](#技術スタック) - [実装フェーズ](#実装フェーズ) - [Phase 1: 環境構築とGoogle Sheets連携](#phase-1-環境構築とgoogle-sheets連携) - [Phase 2: Freee API OAuth2認証の完全自動化](#phase-2-freee-api-oauth2認証の完全自動化) - [Phase 3: Vertex AI (Gemini)によるカテゴリ自動分類](#phase-3-vertex-ai-geminiによるカテゴリ自動分類) - [Phase 4: 統合と自動化](#phase-4-統合と自動化) - [重要な技術的課題と解決策](#重要な技術的課題と解決策) - [システムアーキテクチャ](#システムアーキテクチャ) - [運用と自動化](#運用と自動化) - [まとめ](#まとめ) --- ## イントロダクション こんにちは。株式会社PlanitAIでCTOを務めている金です。 「Freeeの会計データを自動的にGoogle Sheetsに同期して、AIで自動分類できないだろうか?」 そんな素朴な疑問から始まったこのプロジェクト。OAuth2のトークン管理から、Vertex AIの統合、重複データの処理まで、試行錯誤を重ねた結果、完全自動化されたシステムにたどり着きました。 調べるだけじゃなく実際に実装して、問題を解決して、実用的なシステムを作ってみる。このブログを通じて、「理論だけじゃなく手を動かして検証する」当社のエンジニアリング文化が少しでも伝われば嬉しいです。 --- ## プロジェクト概要 ### 目標 Freee会計サービスの取引データを自動的に取得し、Vertex AI (Gemini)で費用カテゴリを分類し、Google Sheetsに保存する完全自動化システムを構築する。 ### 実現した機能 - ✅ Freee APIのOAuth2認証完全自動化(リフレッシュトークンの永続化) - ✅ Vertex AI (Gemini 2.5 Pro)による取引の自動カテゴリ分類 - ✅ Google Sheetsへの重複チェック付き自動同期 - ✅ 一度の認証で継続的に動作する無人運用 --- ## 技術スタック ``` 言語・ランタイム: TypeScript + Node.js API統合: - Freee API (OAuth2) - Google Sheets API (サービスアカウント) - Vertex AI API (Gemini 2.5 Pro) 主要ライブラリ: - axios (HTTP通信) - googleapis (Google API) - @google-cloud/vertexai (AI統合) - dotenv (環境変数管理) ``` --- ## 実装フェーズ ### Phase 1: 環境構築とGoogle Sheets連携 #### プロジェクト初期化 ```bash npm init -y npm install typescript @types/node ts-node --save-dev npm install dotenv googleapis npx tsc --init ``` #### サービスアカウント設定 Google Cloud Consoleでサービスアカウントを作成し、JSONキーをダウンロード。Google Sheets APIを有効化して、スプレッドシートに編集権限を付与。 ```typescript // src/googleSheetClient.ts import { google } from 'googleapis'; import * as path from 'path'; import * as dotenv from 'dotenv'; dotenv.config(); const auth = new google.auth.GoogleAuth({ keyFile: path.resolve(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH!), scopes: ['https://www.googleapis.com/auth/spreadsheets'], }); export const getGoogleSheetClient = async () => { return google.sheets({ version: 'v4', auth }); }; ``` **ポイント**: サービスアカウントを使用することで、OAuth2のユーザー認証フローを回避し、完全自動化が可能になる。 --- ### Phase 2: Freee API OAuth2認証の完全自動化 Freee APIはOAuth2を使用しているため、アクセストークンの管理が必要。さらに、Freeeは**ローテーティングリフレッシュトークン**方式を採用しており、新しいアクセストークンを発行するたびにリフレッシュトークンも更新される。 #### 課題: トークンの永続化 リフレッシュトークンを`.env`ファイルに保存しただけでは、スクリプトを再実行するたびに古いトークンを使用してしまい、認証エラーが発生する。 #### 解決策: トークンマネージャーの実装 ```typescript // src/tokenManager.ts import * as fs from 'fs'; import * as path from 'path'; const TOKEN_FILE_PATH = path.join(__dirname, '..', 'tokens.json'); interface TokenData { refresh_token: string; updated_at: string; } export const saveRefreshToken = (refreshToken: string): void => { const tokenData: TokenData = { refresh_token: refreshToken, updated_at: new Date().toISOString() }; fs.writeFileSync(TOKEN_FILE_PATH, JSON.stringify(tokenData, null, 2), 'utf-8'); }; export const loadRefreshToken = (): string | null => { if (!fs.existsSync(TOKEN_FILE_PATH)) return null; const data = fs.readFileSync(TOKEN_FILE_PATH, 'utf-8'); return JSON.parse(data).refresh_token; }; ``` #### Axiosインターセプターによる自動更新 ```typescript // src/freeeClient.ts (抜粋) import { saveRefreshToken, loadRefreshToken } from './tokenManager'; let currentRefreshToken = loadRefreshToken() || process.env.FREEE_REFRESH_TOKEN; const refreshAccessToken = async (): Promise => { const response = await axios.post( 'https://accounts.secure.freee.co.jp/public_api/token', new URLSearchParams({ grant_type: 'refresh_token', client_id: FREEE_CLIENT_ID!, client_secret: FREEE_CLIENT_SECRET!, refresh_token: currentRefreshToken!, }) ); const newAccessToken = response.data.access_token; const newRefreshToken = response.data.refresh_token; // 新しいリフレッシュトークンを保存 if (newRefreshToken && newRefreshToken !== currentRefreshToken) { saveRefreshToken(newRefreshToken); currentRefreshToken = newRefreshToken; } return newAccessToken; }; // リクエストインターセプター freeeApiClient.interceptors.request.use(async (config) => { if (!accessToken) { accessToken = await refreshAccessToken(); } config.headers.Authorization = `Bearer ${accessToken}`; return config; }); // レスポンスインターセプター(401エラー時に再試行) freeeApiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401 && !error.config._retry) { error.config._retry = true; accessToken = await refreshAccessToken(); error.config.headers.Authorization = `Bearer ${accessToken}`; return freeeApiClient(error.config); } return Promise.reject(error); } ); ``` **成果**: 一度の手動認証で、`tokens.json`にリフレッシュトークンが保存され、以降は完全に自動化される。 --- ### Phase 3: Vertex AI (Gemini)によるカテゴリ自動分類 #### Vertex AI設定 ```bash npm install @google-cloud/vertexai ``` Google Cloud ConsoleでVertex AI APIを有効化し、課金を設定。 ```typescript // src/vertexAiClient.ts import { VertexAI } from '@google-cloud/vertexai'; import * as path from 'path'; const PROJECT_ID = 'spatial-cargo-456805-u2'; const LOCATION = 'asia-northeast1'; const vertexAI = new VertexAI({ project: PROJECT_ID, location: LOCATION, googleAuthOptions: { keyFilename: path.resolve(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH!), }, }); const model = vertexAI.getGenerativeModel({ model: 'gemini-2.5-pro', }); ``` #### カテゴリ分類の実装 ```typescript const CATEGORIES = [ '食費', '交通費', '通信費', '事務用品', '賃料', '人件費', '広告宣伝費', '接待費', '手数料', 'その他' ]; export const classifyTransaction = async (deal: any): Promise => { const prompt = ` 次の取引内容を分析して、最も適切なカテゴリを一つ選択してください。 取引情報: - 日付: ${deal.issue_date} - タイプ: ${deal.type === 'income' ? '収入' : '支出'} - 金額: ${deal.amount}円 - 説明: ${deal.ref_number || 'なし'} 可能なカテゴリ: ${CATEGORIES.join(', ')} 以下の形式で回答してください: カテゴリ: [選択したカテゴリ] `.trim(); const result = await model.generateContent(prompt); const text = result.response.candidates?.[0]?.content?.parts?.[0]?.text || ''; const categoryMatch = text.match(/カテゴリ:\s*(.+)/); if (categoryMatch && CATEGORIES.includes(categoryMatch[1].trim())) { return categoryMatch[1].trim(); } return 'その他'; }; // バッチ処理(レート制限対策で500ms遅延) export const classifyTransactionsBatch = async (deals: any[]): Promise> => { const categories = new Map(); for (let i = 0; i < deals.length; i++) { const category = await classifyTransaction(deals[i]); categories.set(deals[i].id, category); if (i < deals.length - 1) { await new Promise(resolve => setTimeout(resolve, 500)); } } return categories; }; ``` **実行結果例**: ``` 取引: AWS 3,503円 → カテゴリ: 手数料 取引: ランチ 16,680円 → カテゴリ: 食費 ``` --- ### Phase 4: 統合と自動化 #### Google Sheetsへの自動保存(重複チェック付き) ```typescript // src/googleSheetClient.ts export const appendFreeeDeals = async ( deals: any[], categories?: Map, sheetName: string = 'Freee取引履歴' ) => { const sheets = await getGoogleSheetClient(); // シートが存在しない場合は作成 await ensureSheetExists(sheetName); // ヘッダーがない場合は追加 if (!await checkHeaderExists(sheetName)) { await sheets.spreadsheets.values.append({ spreadsheetId: GOOGLE_SHEET_ID, range: `${sheetName}!A1`, valueInputOption: 'USER_ENTERED', requestBody: { values: [['取引ID', '日付', 'タイプ', '金額', '状態', 'パートナー', '説明', 'カテゴリ', '登録日時']] }, }); } // 既存の取引IDを取得(重複チェック用) const existingIds = await getExistingDealIds(sheetName); const newDeals = deals.filter(deal => !existingIds.has(deal.id.toString())); if (newDeals.length === 0) { return { added: 0, skipped: deals.length }; } // データ行を作成 const rows = newDeals.map(deal => [ deal.id.toString(), deal.issue_date || '', deal.type === 'income' ? '収入' : '支出', deal.amount || 0, deal.status === 'settled' ? '完了' : deal.status, deal.partner_id || '', deal.ref_number || '', categories ? (categories.get(deal.id) || 'その他') : '未分類', new Date().toISOString(), ]); await sheets.spreadsheets.values.append({ spreadsheetId: GOOGLE_SHEET_ID, range: `${sheetName}!A:I`, valueInputOption: 'USER_ENTERED', requestBody: { values: rows }, }); return { added: newDeals.length, skipped: deals.length - newDeals.length }; }; ``` #### メインスクリプト ```typescript // src/index.ts import { getDeals } from './freeeClient'; import { appendFreeeDeals } from './googleSheetClient'; import { classifyTransactionsBatch } from './vertexAiClient'; const main = async () => { console.log('🚀 Freee → Vertex AI → Google Sheets 自動同期開始...\n'); const companyId = parseInt(process.env.FREEE_BUSINESS_ID || '', 10); const today = new Date(); const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); const startDate = firstDayOfMonth.toISOString().split('T')[0]; const endDate = today.toISOString().split('T')[0]; // 1. Freeeから取引データ取得 const deals = await getDeals(companyId, startDate, endDate); console.log(`✅ ${deals.length}件の取引を取得\n`); // 2. Vertex AIでカテゴリ分類 const categories = await classifyTransactionsBatch(deals); console.log(`✅ ${categories.size}件のカテゴリ分類完了\n`); // 3. Google Sheetsに保存 const result = await appendFreeeDeals(deals, categories); console.log(`✅ ${result.added}件追加、${result.skipped}件スキップ\n`); }; main().catch(console.error); ``` --- ## 重要な技術的課題と解決策 ### 1. ローテーティングリフレッシュトークンの管理 **課題**: Freee APIは新しいアクセストークンを発行するたびに新しいリフレッシュトークンを返し、古いトークンは無効化される。 **解決策**: `tokens.json`ファイルに最新のリフレッシュトークンを永続化し、トークン更新時に自動的に上書きする仕組みを実装。 ### 2. Vertex AIのモデル選択とコスト **課題**: 最初は`gemini-1.5-flash`を使用したが、プロジェクトIDやリージョンの問題で404エラーが発生。 **解決策**: `gemini-2.5-pro`に変更し、`asia-northeast1`リージョンを明示的に指定。課金も有効化。 ### 3. 重複データの処理 **課題**: スクリプトを複数回実行すると、同じ取引が重複して追加される。 **解決策**: Google Sheetsから既存の取引IDをすべて取得し、新しいデータのみをフィルタリングして追加。 ### 4. APIレート制限 **課題**: Vertex AIを連続で呼び出すとレート制限に引っかかる可能性がある。 **解決策**: バッチ処理で各リクエスト間に500msの遅延を追加。 --- ## システムアーキテクチャ ``` ┌─────────────┐ │ Freee API │ ─► OAuth2認証(自動トークン更新) └─────────────┘ │ ▼ ┌──────────────────┐ │ 取引データ取得 │ ─► 今月の取引履歴 └──────────────────┘ │ ▼ ┌──────────────────┐ │ Vertex AI │ ─► Gemini 2.5 Proで自動分類 │ (Gemini) │ (食費、交通費、手数料など) └──────────────────┘ │ ▼ ┌──────────────────┐ │ Google Sheets │ ─► 重複チェック後に自動保存 └──────────────────┘ ``` **ファイル構成**: ``` /src ├── index.ts # メインスクリプト ├── freeeClient.ts # Freee API クライアント ├── googleSheetClient.ts # Google Sheets クライアント ├── vertexAiClient.ts # Vertex AI クライアント └── tokenManager.ts # トークン永続化 /credentials └── service-key.json # Googleサービスアカウントキー tokens.json # 自動生成されるトークンファイル .env # 環境変数 ``` --- ## 運用と自動化 ### Cronで定期実行 ```bash # 毎日午前9時に実行 0 9 * * * cd /path/to/project && npx ts-node src/index.ts >> /var/log/freee-sync.log 2>&1 ``` ### GitHub Actionsで自動化 ```yaml name: Freee Sync on: schedule: - cron: '0 0 * * *' # 毎日UTC 0時 workflow_dispatch: jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '18' - run: npm install - run: npx ts-node src/index.ts env: FREEE_CLIENT_ID: ${{ secrets.FREEE_CLIENT_ID }} FREEE_CLIENT_SECRET: ${{ secrets.FREEE_CLIENT_SECRET }} FREEE_BUSINESS_ID: ${{ secrets.FREEE_BUSINESS_ID }} # その他のシークレット... ``` ### コスト最適化 - **モデル選択**: 簡単な分類には`gemini-1.5-flash`を使用することでコスト削減可能 - **キャッシング**: 同じパートナーIDの取引は以前の分類結果を再利用 - **バッチ処理**: 複数の取引を一つのプロンプトにまとめることも検討可能 --- ## まとめ このプロジェクトを通じて、以下を実現しました: ✅ **完全自動化されたOAuth2認証** ローテーティングリフレッシュトークンの永続化により、一度の認証で継続的に動作 ✅ **AIによる自動カテゴリ分類** Vertex AI (Gemini 2.5 Pro)を使用して、取引内容から適切なカテゴリを推論 ✅ **重複のない自動同期** Google Sheetsに既存データをチェックして、新しい取引のみを追加 ✅ **無人運用** Cronやクラウドスケジューラーで定期実行し、完全自動化を実現 **技術的な学び**: - OAuth2のローテーティングトークン管理の重要性 - Vertex AIのリージョンとプロジェクト設定の注意点 - APIレート制限への対策 - TypeScriptでの非同期処理とエラーハンドリング ここまでで、疑問から始まった旅は一区切りを迎えました。 でも、これは終わりではなくスタートラインです。 実際に手を動かすことで、本当の理解が得られます。 もしこの記事が何かのヒントになったなら、ぜひ試してみてください。 --- **リポジトリ**: このプロジェクトの完全なソースコードは社内リポジトリで管理されています。 **著者**: 金 (株式会社PlanitAI CTO) **作成日**: 2025年11月27日