0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

レシートを撮るだけで家計簿に自動反映。Python × Gemini APIで完全無料AI活用システムを作った

0
Posted at

【完全無料】レシートを撮るだけで家計簿に自動反映するシステムをPython + Gemini APIで作った

この記事でわかること

  • クレジットカード登録なしで、Gemini APIを完全無料運用する方法
  • PWA(スマホのホーム画面アプリ化)を、専用アプリ開発なしで実現する方法
  • GitHub Actionsを使った、サーバーレスでの定期バッチ処理の組み方
  • 個人開発でよく踏む、AIモデルの廃止・GitHubのSecret Scanning・PWAのキャッシュ、3つの落とし穴と対処法

対象読者

  • PythonでAIを使った個人開発をしてみたいが、何を作ればいいか迷っている人
  • できるだけお金をかけずにAI活用のポートフォリオを作りたい人
  • サーバーを持たずに「スマホアプリっぽいもの」を作りたい人

はじめに

日々の買い物のレシートを手入力で家計簿につけるのが面倒だったので、「撮影するだけで自動的に日付・店名・商品名・金額・勘定科目まで読み取ってスプレッドシートに記録してくれる仕組み」を作りました。

今回のシステムは、以下の3つの要件で設計しています。

  • Python × AI を使うこと
  • 完全無料で設計・運用できること
  • スマホからアプリのように使えること

完成イメージ

  1. スマホでレシートを撮影(ホーム画面に追加したPWAから起動)
  2. 自動でGoogleドライブにアップロード
  3. 裏側でPythonが定期実行され、Gemini APIで画像を解析
  4. 商品ごとに「日付・店名・商品名・金額・勘定科目」をスプレッドシートに自動記録

全体のアーキテクチャ

[スマホ(PWA)]
↓ 撮影 → Googleドライブへアップロード
[Google Drive]
↓ 5分おきに検知
[GitHub Actions(Python)]
↓ 画像を渡す
[Gemini API]
↓ 構造化データ(JSON)を返す
[Googleスプレッドシート]
↓ 商品ごとに1行を追記

なぜ完全無料で成立するのか

要素 使ったもの 無料の理由
スマホからの入口 PWA(GitHub Pages) 静的ホスティングで課金要素なし
定期実行環境 GitHub Actions 無料枠の範囲内でcron実行
AI解析 Gemini API(Flash系モデル) クレジットカード未登録なら課金されず、上限到達時はエラーになるだけ
データ保存 Googleスプレッドシート 無料

ポイント: Gemini APIは請求先アカウント(クレジットカード)をリンクしない限り、無料ティアのまま固定されます。無料枠の上限を超えても自動課金されることはなく、一時的にAPIが使えなくなるだけです。

実装① スマホ側の入口(PWA)

ホーム画面に追加すると、専用アプリのように使える「PWA(Progressive Web App)」を採用しました。中心となるのはカメラ起動とGoogleドライブへの直接アップロードです。

async function uploadToDrive(file, token) {
  const metadata = {
    name: "receipt_" + new Date().toISOString().replace(/[:.]/g, "-") + ".jpg",
    parents: [FOLDER_ID]
  };

  const boundary = "-------receipttracker" + Date.now();
  const body = new Blob([
    `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n`,
    `--${boundary}\r\nContent-Type: ${file.type}\r\n\r\n`,
    await file.arrayBuffer(),
    `\r\n--${boundary}--`
  ]);

  await fetch(
    "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer " + token,
        "Content-Type": `multipart/related; boundary=${boundary}`
      },
      body: body
    }
  );
}

認証にはGoogle Identity Services(ブラウザ完結型のOAuth)を使い、drive.fileスコープ(そのアプリが作成したファイルにのみアクセスできる、比較的安全な権限)に絞っています。サーバーを持たず、認証情報もサーバーに保存しない構成なので、バックエンドを一切書かずに「スマホからGoogleドライブに直接アップロードするアプリ」が作れます。

実装② Python側(GitHub Actionsでのバッチ処理)

5分おきにGitHub Actions上でPythonスクリプトを実行し、以下を行います。

  1. Drive APIで未処理の画像を検索
  2. Geminiに画像を渡して構造化データを取得
  3. スプレッドシートに書き込み
  4. 処理済みフォルダへ移動(二重処理防止)

Geminiへの投げ方(工夫ポイント)

レシート1枚に複数の商品が載っているケースに対応するため、ネスト構造のJSONで出力を強制しています。

def extract_receipt_data(image_bytes, mime_type):
    prompt = """
あなたはレシート画像を解析する会計アシスタントです。
画像から以下の情報を読み取り、JSON形式だけで出力してください。

{
  "date": "YYYY-MM-DD形式の日付。読み取れなければnull",
  "store": "店名",
  "items": [
    {
      "name": "商品名",
      "amount": 金額(数値のみ),
      "category": "食費・日用品・交通費・交際費・その他 のいずれか"
    }
  ]
}

itemsには、レシートに書かれている商品を1つずつ全て含めてください。
"""
    response = gemini_client.models.generate_content(
        model="gemini-3.6-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            prompt,
        ],
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
        ),
    )
    return json.loads(response.text)

ポイントは2つです。

  • プロンプトで「JSON形式だけで」と指示する
  • さらに response_mime_type="application/json" でAPI側の出力形式自体を強制する

この二重の縛りにより、「AIの返答が崩れてパースエラーになる」という実運用でありがちな問題をかなり抑えられます。

1件ずつ失敗しても止まらない設計

for file in files:
    try:
        image_bytes = download_file(file["id"])
        data = extract_receipt_data(image_bytes, file["mimeType"])
        append_to_sheet(data, file["name"])
        move_to_processed(file["id"], processed_folder_id)
    except Exception as e:
        print(f"エラー発生、スキップします: {file['name']} ({e})")
        continue

try/exceptで1件ごとに独立してエラーハンドリングすることで、「1枚だけ画像が壊れていた」「AI無料枠のレート制限に一時的に引っかかった」といった単発の問題で、残り全部の処理が止まらないようにしています。

ハマったポイント3選

個人開発で無料構成を追求すると、こうした「地味だけど刺さる」問題によく出会います。同じ轍を踏まないよう、詳しく共有します。

1. Geminiのモデルが突然使えなくなった

This model models/gemini-2.5-flash is no longer available to new users.

Gemini 2.5系モデルが廃止スケジュールに入っており、gemini-3.6-flashへの移行が必要でした。AIモデルは頻繁に世代交代するため、モデル名を設定ファイルなどに外出ししておくと運用が楽になりそうです。

2. GitHubの個人アクセストークンをコードに埋め込んだら即座に無効化された

スマホからの即時反映を狙い、PWAから直接GitHub Actionsを起動する構成(個人アクセストークンをクライアント側コードに埋め込む形)を試しましたが、pushするたびにトークンが「期限切れ」のような状態になる現象に遭遇しました。

原因は、GitHubのSecret Scanningが、公開リポジトリにコミットされたGitHub発行のトークンを検出すると、GitHub自身が「漏洩したトークン」とみなして自動的に無効化する仕組みになっているためでした。「Push protectionを許可して通す」ことはできても、その後のスキャンでの自動失効までは防げません。

→ 対策として、クライアント側のコードに秘密情報を置く設計自体をやめ、GitHub Actionsの定期実行(cron)間隔を5分に短縮する方式に切り替えました。「即時性」を諦める代わりに、安全性とシンプルさを優先しています。

3. PWAのコードを更新してもスマホに反映されない

index.htmlを何度書き換えてpushしても、スマホ側には古い表示が残り続ける現象がありました。原因はservice-worker.jsのキャッシュです。

const CACHE_NAME = "receipt-tracker-v1"; // このバージョン番号が変わらないと更新されない

service-worker.js自体の中身が変わらない限り、ブラウザは「更新なし」と判断してキャッシュを使い続けます。対策として、HTMLページ本体は「ネットワーク優先、失敗時のみキャッシュにフォールバック」という戦略に変更しました。

self.addEventListener("fetch", (event) => {
  if (event.request.mode === "navigate") {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

まとめ

  • Python(GitHub Actions) + Gemini API(無料枠)で、完全無料のAI活用システムが組める
  • クライアント側コードに秘密情報を置く設計は、思わぬセキュリティ機構(Secret Scanningなど)と衝突しやすい。無理に即時性を追わず、定期実行で妥協するのも十分現実的な選択肢
  • PWAはサーバーレスでスマホアプリ的な体験を作れる一方、キャッシュ管理やiOS特有の制約に注意が必要
  • AIの出力形式は、プロンプトだけでなくAPIの機能(response_mime_typeなど)でも二重に縛ると安定する

今回作った仕組みは領収書に限らず、「画像や文章から構造化データを抽出して業務を自動化する」という応用が効くパターンだと思います。

もし参考になれば、いいね・ストックをいただけると励みになります🙏

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?