見出し画像

[コード公開] GASとOpenAI Visionでレシート管理を半自動化してみた。

こんにちは、note AI creativeの田中です。

OpenAIのAPIには画像を解析できる「Vision」機能があることをご存知でしょうか。
https://platform.openai.com/docs/guides/vision

先日発表されたGPT-3.5-Turboより60%安い「GPT-4o mini」でもこのVision機能が使えるということで、GASと組み合わせて「レシート画像を読み取って自動でスプレッドシートに追記する仕組み」を作ってみました。


全体の流れ

全体の流れ
  1. レシート画像をGoogleドライブにアップロード(手動)

  2. Google App Script(以下、GAS)が画像をドライブから取得

  3. GASからOpenAIのAPI(Vision)にリクエストし、画像から店名・金額・日付を取得

  4. OpenAIのレスポンスをもとに結果をスプレッドシートに書き込み

事前準備

レシートアップロード用のフォルダを2つ作成する
「レシートをアップロードする先のフォルダ」と「スプシ記載済レシート画像を保管しておく用のフォルダ」の2つ作成しておきます。
自分の場合は、「領収書」フォルダとその配下に「シート記載済」フォルダを作っています。

フォルダ例

同じレシート画像をGASで何度も処理してしまわないよう、処理が終わったらシート記載済フォルダに移動させます。

スプレッドシート作成、ヘッダー準備
結果を書き込むようのスプレッドシートを事前に作成しておきます。シートには「date」「shop」「price」「支払いした人」のヘッダーを用意しておきます。

スプシのヘッダー

iPhoneで撮影した画像がJPEGで保存されるよう設定
iPhoneの「高効率」のカメラ設定で保存されるHEIF形式は、OpenAIのAPIが処理できないため、JPEGで保存されるように事前設定が必要です。
iPhoneの「設定」>「カメラ」>「フォーマット」で「互換性優先」を選択します。これで、撮影した画像はJPEGで保存されるようになります。

GASを実装

スプレッドシート上の「拡張機能」>「App Script」からGASのエディターを起動します。

GASエディターを起動

OpenAIのAPIキーを「プロジェクトの設定」>「スクリプトプロパティ」で「OPENAI_API_KEY」で入れておきます。

OpenAIのAPIキーの設定

以下に実際のコードを記載しますが、一部の変数は自身で設定が必要です。

  • sourceFolderId:レシート画像を保存するフォルダのID

  • destinationFolderId:シートに記載済のレシートファイルを移動させるフォルダのID

  • sheetName:シート名(例:「シート1」など)

↑sheetName
  • payerName:名前(例:「けんぞう」など)

  • spreadsheetId:スプレッドシートのID

ちなみに、スプレッドシートやフォルダーのIDはURLの中に含まれる一意の識別子になります。↓の黒塗りされているところ。

IDの説明
function main() {
  var sourceFolderId = '<ここにレシートを保存するフォルダーIDを記載>';
  var destinationFolderId = '<ここに移動先のフォルダーIDを記載>';
  var sheetName = '<ここにシート名>';
  var payerName = '<ここに支払いした人の名前>';
  processFiles(sourceFolderId, destinationFolderId, sheetName, payerName);
}

function processFiles(sourceFolderId, destinationFolderId, sheetName, payerName) {
  var spreadsheetId = '<ここにスプレッドシートIDを記載>';
  
  var sourceFolder = DriveApp.getFolderById(sourceFolderId);
  var destinationFolder = DriveApp.getFolderById(destinationFolderId);
  var files = sourceFolder.getFilesByType('image/jpeg');

  while (files.hasNext()) {
    var file = files.next();
    var base64Image = convertToBase64(file);

    // OpenAIのAPIにリクエスト
    var response = callOpenAIAPI(base64Image);

    // choices[0].message.content のみを取り出してJSONをパース
    var content = response.choices[0].message.content;
    var jsonData = JSON.parse(content);

    // スプレッドシートに書き込む
    writeToSpreadsheet(spreadsheetId, sheetName, jsonData, payerName);

    // ファイル名をリネーム
    var newFileName = jsonData.date.replace(/\//g, '-') + ' ' + jsonData.shop + '.jpeg';
    file.setName(newFileName);

    // ファイルを別フォルダに移動
    moveFileToFolder(file, destinationFolder);
  }
}

function moveFileToFolder(file, destinationFolder) {
  destinationFolder.addFile(file);
  var sourceFolder = file.getParents().next();
  sourceFolder.removeFile(file);
}

function writeToSpreadsheet(spreadsheetId, sheetName, jsonData, payerName) {
  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);
  
  // ヘッダー行を取得
  var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  
  // 各ヘッダーに対応する列を特定
  var shopCol = headers.indexOf('shop') + 1;
  var dateCol = headers.indexOf('date') + 1;
  var priceCol = headers.indexOf('price') + 1;
  var payerCol = headers.indexOf('支払いした人') + 1;
  
  // 新しい行を追加してデータを書き込む
  var lastRow = sheet.getLastRow();
  sheet.getRange(lastRow + 1, shopCol).setValue(jsonData.shop);
  sheet.getRange(lastRow + 1, dateCol).setValue(jsonData.date);
  sheet.getRange(lastRow + 1, priceCol).setValue(jsonData.price);
  sheet.getRange(lastRow + 1, payerCol).setValue(payerName);
}

function getPromptText() {
  return `
画像は領収書の写真です。
画像を解析して、店名・日付・金額を以下のJSONフォーマットで出力して下さい。

# JSONフォーマット
{ "shop": "店名(String)", "date": "日付(YYYY/mm/dd)", "price": "金額(Integer)" }

# 出力例
{ "shop":"松屋", "date":"2023/02/12", "price": 1000 }
`;
}

function convertToBase64(file) {
  var blob = file.getBlob();
  var base64Data = Utilities.base64Encode(blob.getBytes());
  return base64Data;
}

function callOpenAIAPI(base64Image) {
  var scriptProperties = PropertiesService.getScriptProperties();
  var apiKey = scriptProperties.getProperty('OPENAI_API_KEY');
  var url = 'https://api.openai.com/v1/chat/completions';

  var payload = {
    model: "gpt-4o-mini",
    messages: [
      {
        role: "user",
        content: [
          {
            type: "text",
            text: getPromptText()
          },
          {
            type: "image_url",
            image_url: {
              url: "data:image/jpeg;base64," + base64Image
            }
          }
        ]
      }
    ],
    max_tokens: 300,
    response_format: { "type": "json_object" }
  };

  var options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + apiKey
    },
    payload: JSON.stringify(payload)
  };

  var response = UrlFetchApp.fetch(url, options);
  return JSON.parse(response.getContentText());
}

テスト実行してみる

上記の変数を全てセットしたうえで、領収書のフォルダにレシート画像をアップロードしてテスト実行してみます。
GASエディターで「main」を選択して実行をクリックすると実行できます。

GASエディター

成功すれば、シート上で以下のように入力された状態になるはずです。

サイゼリアの記録

ファイルはリネームされた状態で、処理済のフォルダーに移動しているはずです。

処理済のファイルが移動している様子

スケジュール実行する

定期的にGASが起動するように設定します。この設定によって、「Googleドライブに画像さえあげておけば、あとはGASが自動でスプシに転記してくれる」状態を作れます。

GASエディター上で、画面右側の「トリガー」>「トリガーを追加」から以下のように設定します。

トリガーの設定

複数人のレシート管理に対応

レシートをアップロードするフォルダを別にすれば、複数人のレシート管理に対応できます。

function main() {
  var sourceFolderIdA = '<Aさんのレシートを保存するフォルダーIDを記載>';
  var destinationFolderIdA = '<Aさんの移動先のフォルダーIDを記載>';
  var sheetName = '<ここにシート名>';
  var payerName = '<ここに支払いした人の名前>';
  processFiles(sourceFolderIdA, destinationFolderIdA, sheetName, payerName);

  var sourceFolderIdB = '<Bさんのレシートを保存するフォルダーIDを記載>';
  var destinationFolderIdB = '<Bさんの移動先のフォルダーIDを記載>';
  processFiles(sourceFolderIdB, destinationFolderIdB, sheetName, payerName);
}
複数名の入力

▼noteの技術記事が読みたい方はこちら


この記事が気に入ったらサポートをしてみませんか?