Google Apps ScriptとGeminiで請求書データを全部抽出する【GAS】

以前、AppSheet + Google Apps Script + Gemini APIで請求データをOCRして請求書データを引き抜くものを作成しました。しかし、前回のエントリーでは請求明細までは取得出来ていませんでした。

また、2025年4月9日にAppSheet Enterprise Plusライセンス向けに、AI Taskと呼ばれる新機能が追加され、簡単に請求書のデータを抽出出来るようになったものの、やはり請求明細については簡単に取り出せず。そこで、今回は前回の課題である「請求明細」まで含めた全データを抽出してスプシに書き出すものを、Gemini APIを利用して構築してみました。

今回利用するスプレッドシート等

今回は、現在まだプレビューリリース段階のGemini 2.5 ProのAPIを使って、データを生成AI経由でOCRにかけて、請求書データおよびその中にある請求明細データを一度に取得して、請求書および明細のシートにそれぞれ一括で書き出すものを作成しています。

途中まではAppSheetでアップしたPDFをGeminiでOCRと同じですが、今回のコードはスプシで利用することを前提としています。加工し直せばAppSheetでも十分利用することが可能です。その為、前回とは少々コードを変えていますので注意。

AppSheetのAI Taskで8時間頭捻って明細が結局スマートに取得出来なかったのが、GASだと1時間で簡単に構築出来ました。ノーコードツールは楽な面は楽ですが、やはりちょっと踏み込むとすぐに限界に達するのが難点。

AppSheetでアップしたPDFをGeminiでOCR【GAS】

Google Apps ScriptでGeminiを叩いて音声から字幕生成【GAS】

Gemini APIを利用する利点

AI Task機能は別途AppSheet Enterprise Plusライセンスが必要です。そのため追加で購入が必要な上に、1ライセンスあたり1000クレジット/月が上限となっているため、リクエスト回数が多いと上限に達してしまい使えなくなってしまいます。

一方、GASからGemini APIをリクエストする場合には、リクエストに応じた課金は必要なものの、そのリクエストに大きな制限はありません(プログラム的なQuotaの制約はありますが)。もちろん、CoreプランであってもGASからAPIを叩いてる仕組みなので、Enterprise Plusライセンスは不要です。

概ね現場で利用するGemini APIのリクエストのパターンは決まっているので、1つコードを構築できれば簡単に使い回せるだけでなく、パターンが決まり事ということはメンテナンス性でも優れているので、個人的には無理にAppSheetでロジックを構築するよりもGemini APIを叩くGASアプリを構築したほうが柔軟性も高いと考えています。

また、AI Taskは現在2種類の作業しか出来ませんが、Gemini APIの場合はプロンプト次第で様々なことが可能になり縛られないので、ぜひ頑張ってGASの習得をおすすめします。

事前準備

Gemini API Keyを取得する

GeminiのAPIキーが必要です。以下のエントリーに独立してまとめています。課金されますので利用のしすぎには要注意。また課金されていない場合、学習に利用されてしまう恐れがあるので、きちんとGoogle Cloud上で課金アカウントとの紐付けなどをしておきましょう。

GeminiのAPIキーの取得と学習の可否

スクリプトプロパティに格納する

今回取得したGeminiのAPI Keyはapikeyというスクリプトプロパティの中に格納しています。コード内に直接記述した場合、誤って流出するおそれがあるので、スクリプトプロパティを使うか?共通で利用するJSONファイルなどを用意して、参照して読み込むように、コードとは分離して管理するのが望ましいでしょう。

図:apikeyをスクリプトプロパティに格納する

エンドポイントURLの確認

今回利用してるGemini APIのエンドポイントURLですが、2.5 Proはまだプレビュー段階でLastestが存在しません。よって、変更されたり削除される可能性もあります。とりあえずは以下のURLで構築していますが、都度最新版のエンドポイントURLを確認して、安定版が出たら、そちらを利用するようにしましょう。

const endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=";

ソースコード

冒頭のseikyu変数に請求書PDFのファイルIDを入れておきます。また、スクリプトプロパティからGemini APIのAPIキーを取得してendpointのURLと結合してリクエストしています。

前回の記事との相違点ですが

  • 請求書データと請求明細データを一括で取得し、JSON形式でまとめて出力させています。
  • それぞれのデータにはタイトル行があるので出力時はこのタイトル行は除外しています。また型変換を行って一時配列に格納してから、一括書き出ししています。
  • 請求データは1行なのでappendRowで請求書シートの行末に追記
  • 明細データは複数行なのでgetLastRowにて最終行を取得して明細シートの行末に一括追記

さらに応用して、書き出しデータを会計ソフトにインポート出来るようなスタイルで出力(勘定科目や借方・貸方などの生成)をしてあげる会計ソフト連動や、また履歴書データの読み込みならばそのままSmartHRなどにAPI連携でインポートなども可能になるかと思います。

現時点でAppSheetのAI Taskがいまいちな出来なので、GASで普通に取り込みをやらせたほうが短時間でスマートに開発が可能です。

//請求書ファイル
const seikyu = "請求書PDFのファイルのIDをここに入れる";
 
//Gemini 2.5 ProエンドポイントURL
const endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=";
 
//ファイル名から特定しGeminiに読み取らせる
function geminiReader() {
  //プロパティの値を取得する
  let prop = PropertiesService.getScriptProperties();
  let apikey = prop.getProperty("apikey");
 
  //リクエストURLを構築する
  let url = endpoint + apikey;
 
  // ファイルを特定しファイルIDを取得
  let file = DriveApp.getFileById(seikyu);
  let fileblob = file.getBlob();
 
  //base64エンコードする
  let pdf = Utilities.base64Encode(fileblob.getBytes());  
 
  //プロンプトを構築する
  let prompt = `あなたは経理の担当者です。アップロードされたPDFデータより数字や文字を読み取って文字起こしをしてください。
    #重要事項
    - 不明な項目がある場合は、正直に「N/A」と記入してください。
    - 取得した値はダブルコーテーションで括ってください
    - 回答は配列に格納して返してください
    - 出力事例に従ったデータだけを返してください。余計な解説や説明は一切不要です。
 
    #請求書の項目の説明
    1. 請求元の会社名
    2. 請求書発行日付
    3. 請求金額合計
    4. Tの文字からはじまる14桁の適格請求書発行事業者に発行される番号
    5. 消費税8%部分の金額
    6. 消費税10%部分の金額
 
    #請求書の中の明細についての説明
    請求書の中にある請求に対する明細については4つの項目を取得してください
    1. 品目や品名などの請求に対する項目名を「品目」として取得
    2. 数量はその数を取得
    3. 単価はその品目に対する単価にあたる金額を取得
    4. 金額は数量 * 単価の結果の金額を取得
    5. 明細は複数行ある場合があるのでそれを考慮して配列で取得
 
    #出力する内容
    出力事例に従ったJSON形式でデータは返してください。請求書の項目についてはseikyuという中に出力し、明細についてはmeisaiという中に出力してください。
 
    #出力事例
    {
      "seikyu" : [
        ["請求元会社名","請求書発行日付","請求金額合計","インボイス番号", "消費税8%", "消費税10%"],
        ["株式会社xxxx", "2024/5/3", "45000", "T1234123412341", "2800",""3500]
      ],
      meisai : [
        ["品目","数量","単価","金額"],
        ["◯▲お品代","2","2500","5000"],
        ["付属費用","1","1000","1000"],
      ]
    }
  `;
 
  //payloadを構築する
  let payload = {
    'contents': {
      'parts': [
        {
          'inlineData': {
            'mimeType': 'application/pdf',
            'data': pdf
          }
        },
        {
          'text': prompt
        }
      ]
    },
    'generation_config': {
      'temperature': 0,
      'topP': 0,
      'maxOutputTokens': 8192
    }
  };
 
  //リクエストオプション
  let options = {
    'method': 'POST',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload)
  };
 
  //Geminiにリクエスト
  let response = UrlFetchApp.fetch(url, options);
  let json = JSON.parse(response.getContentText());
 
  if (json && json.candidates && json.candidates.length > 0) {
    let result = json.candidates[0].content.parts[0].text;
    
    //余計な文字を除外する
    let ret = result.replace("```json","");
    ret = ret.replace("```","");
    ret = ret.replace("\n","");
 
    //請求と明細データを別個に取り出す
    let jsondata = JSON.parse(ret)
    let seikyudata = jsondata.seikyu;
    let meisaidata = jsondata.meisai;
    
    //取得データをスプシに書き出し
    let ss = SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName("請求書");
    let sheet2 = ss.getSheetByName("明細");
 
    //uuidを生成する
    let uuid = Utilities.getUuid();
 
    //請求データを整形する
    let target = seikyudata[1]
    let seikyuarr = [
      uuid,
      target[3],
      target[1],
      target[0],
      Number(target[2]),
      Number(target[4]),
      Number(target[5])
    ]
 
    //請求データを書き出す
    sheet.appendRow(seikyuarr);
 
    //明細データを書き出す
    let meisaiarr = [];
 
    for(let i = 1;i<meisaidata.length;i++){
      //レコードを1個取り出す
      let rec = meisaidata[i];
 
      //uuidを生成
      let uuid2 = Utilities.getUuid()
 
      //一時配列を用意
      let temparr = [
        uuid2,
        uuid,
        rec[0],
        Number(rec[1]),
        Number(rec[2]),
        Number(rec[3])
      ]
     
      //明細配列に追加
      meisaiarr.push(temparr);
    }
 
    //明細側に追記する
    let endrow = Number(sheet2.getLastRow()) + 1;
    let lastColumn = meisaiarr[0].length; //カラムの数を取得する
    let lastRow = meisaiarr.length;       //行の数を取得する
    sheet2.getRange(endrow,1,lastRow,lastColumn).setValues(meisaiarr);
 
    //処理完了
    return true;
  } else {
    return false;
  }
}

図:請求書PDFから明細部分を取り出せました

AppSheetのAI Taskで請求書からデータ抽出

実践的サンプル

概要

2025年8月5日、6日に掛けて開催される「Google Cloud Next Tokyo 2025」の公開されてるフロアガイドおよびセッションリストのPDFを元に、4,5P目にあるブレイクアウトセッションの情報を抽出してデータベース書き出ししてみようと思いチャンレンジしてみました。

この複雑な表からコピペで手打ちしてリストを作るなんてことになったら、気が遠くなる上に滅入る作業間違いなしです。これをGeminiに読み取らせて、スプレッドシートに書き出すミッションです。

AppSheetのXYマップを使って畑を管理する

抽出する仕様

今回のPDFは縦横の集計表形式のイベントリスト。横にRoomの名前、縦に時間帯があり、それぞれのブロック内に細かな抽出すべきデータに、アイコンが1個。これらから以下の条件で取り出します。

  1. Room名には該当するRoom1〜Room12までの値を取り出す(但し、Room9だけはリストに無い)
  2. 日付は固定なので最上部の日付を取って2025/mm/dd形式として取り出す
  3. 開始時間と終了時間は左サイド側にあるのでここから、それぞれ取り出す
  4. ブロック内情報は以下のように取り出す
    1. イベント名はブロックの2行目にあるのでこれを取り出す
    2. 企業名が3行目にある
    3. 演者名が4行名にある
    4. 但し企業名、演者名は複数ある場合があるので、これらは1つのデータにまとめる
    5. アイコン名は最下部に凡例が記載されている。アイコンからこのアイコン名を取り出す
  5. 最後にこれらをJSONの配列形式として出力させて、スプシに書き出す

今回のサンプルコードは、こちらのスプレッドシートの中に作成しています。

ソースコード

4ページ目、5ページ目をPDF分割サイトで分割して取り出し、ドライブに格納しています。それぞれのIDを記載し、書き出し先のスプレッドシートのIDを記載する。そのシートの抽出シートに一括書き出しを行います。

前述の仕様を満たすデータの抽出を行わせるプロンプトを用意して、あとは実行。所要時間はコードの修正含めて20分程度。前述のPDFからのコピペならば丸一日は嫌々ながらやることになることを考えると、GeminiのOCR機能は流石だなと思います。

//チェック対象PDF
const file1 = "1枚目の調査対象のPDFファイルのID";
const file2 = "2枚目の調査対象のPDFファイルのID";

//書き込み先スプレッドシートID
const ssid = "書き出し先のスプレッドシートのIDをここに入れる";
const sheetname = "抽出";
 
//Gemini 2.5 ProエンドポイントURL
const endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=";
 
//ファイル名から特定しGeminiに読み取らせる
function geminiReader() {
  //プロパティの値を取得する
  let prop = PropertiesService.getScriptProperties();
  let apikey = prop.getProperty("apikey");
 
  //リクエストURLを構築する
  let url = endpoint + apikey;
 
  // ファイルを特定しファイルIDを取得
  let file = DriveApp.getFileById(file1);
  let fileblob = file.getBlob();
 
  //base64エンコードする
  let pdf = Utilities.base64Encode(fileblob.getBytes());  
 
  //プロンプトを構築する
  let prompt = `あなたは総務の文書読み取りの達人です。アップロードされたPDFデータより次項のルールに従ってデータを読み取り、JSON形式の配列で出力してください。

    #重要事項
    - 不明な項目がある場合は、正直に「N/A」と記入してください。
    - 取得した値はダブルコーテーションで括ってください
    - 回答は配列に格納して返してください
    - 出力事例に従ったデータだけを返してください。余計な解説や説明は一切不要です。
 
    #PDFに記載されてる情報
    1. あるイベントのセッションについての情報が時間帯ごと部屋ごとに記載されています
    2. 横にRoom1からRoom12までの部屋の名前
    3. 縦に時間帯(インターバル等は除く)
    4. 各ブロックにセッションのタイトルや演者名、会社名が含まれている
    5. 演者名と会社名は複数入ってる場合がある(この場合は可能な限り「、」で区切って1つのデータとする
    6. 各ブロックは、イベントタイトル、会社名、演者名の順に並んでいる
    7. アイコン名はPDFの一番下に凡例が記載されているので、該当する名前を抽出する
    8. セッションは全部で60ブロック以上ある
 
    #読み取るべき値についての説明
    PDFの中にあるイベントに関する以下の内容について、値を取得してください
    1. Room : 上部にあるRoom1からRoom12の該当する値
    2. 日付 : 上部にある日付を取得する(2025/mm/ddの形式)
    3. 開始時間 : 左側の縦にならんでる開始時間
    4. 終了時間 : 左側の縦にならんでる終了時間
    5. イベントタイトル : 各Roomの各時間帯のブロックに記載されてる2行目のデータ(D1-AIML-02みたいなイベント番号は不要)
    6. 会社名 : 各Roomの各時間帯のブロックに記載されてる3行目のデータ(場合によっては複数下に続く)
    7. 演者名 : イベントセッションの演者の人の名前(場合によっては複数下に続く)
    8. アイコン名 : 各Roomの各時間帯のブロックの右上にあるアイコン。凡例はPDFの最下部にあり、該当するアイコン名を取得する
 
    #出力する内容
    出力事例に従ったJSON形式でデータは返してください。
 
    #出力事例
    {
    "event" : [
     ["Room","日付","開始時間", "終了時間", "イベントタイトル","会社名","演者名","アイコン名"],
     ["Room1", "2025/8/5", "12:00", "12:30", "Gemini革命","株式会社XXXXX、Google Cloud","佐倉野餅氏、柏野望","AI と機械学習"]
    ],   
   }
  `;
 
  //payloadを構築する
  let payload = {
    'contents': {
      'parts': [
        {
          'inlineData': {
            'mimeType': 'application/pdf',
            'data': pdf
          }
        },
        {
          'text': prompt
        }
      ]
    },
    'generation_config': {
      'temperature': 0,
      'topP': 0,
      'maxOutputTokens': 40000
    }
  };
 
  //リクエストオプション
  let options = {
    'method': 'POST',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload)
  };
 
  //Geminiにリクエスト
  let response = UrlFetchApp.fetch(url, options);
  let json = JSON.parse(response.getContentText());
 
  if (json && json.candidates && json.candidates.length > 0) {
    let result = json.candidates[0].content.parts[0].text;
    
    //余計な文字を除外する
    let ret = result.replace("```json","");
    ret = ret.replace("```javascript","");
    ret = ret.replace("json","");
    ret = ret.replace("```","");
    ret = ret.replace("\n","");
 
    //請求と明細データを別個に取り出す
    let jsondata = JSON.parse(ret)
    let eventdata = jsondata.event;
    
    //取得データをスプシに書き出し
    let ss = SpreadsheetApp.openById(ssid)
    let sheet = ss.getSheetByName(sheetname);

    //書き出し用配列
    let meisai = []

    //取得データを整備する
    for(let i = 0;i<eventdata.length;i++){
      //レコードを一個取り出す
      let rec = eventdata[i];

      //uuidを生成する
      let uuid = Utilities.getUuid();

      //一時配列にデータを加える
      let temparr = [
        uuid,
        rec[0],
        rec[1],
        rec[2],
        rec[3],
        rec[4],
        rec[5],
        rec[6],
        rec[7]
      ]

      //書き出し用配列に追加
      meisai.push(temparr)
    }

    //明細側に追記する
    let lastColumn = meisai[0].length; //カラムの数を取得する
    let lastRow = meisai.length;       //行の数を取得する
    sheet.getRange(1,1,lastRow,lastColumn).setValues(meisai);

 
    //処理完了
    return true;
  } else {
    return false;
  }
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)