MENU

LINE×GAS×Difyで作る!領収書自動管理アプリ開発

当ページのリンクには広告が含まれています。
目次

はじめに:レシートの山をAIで自動化しよう

「経費精算のためにレシートを取っておくけど、整理が面倒で溜まる一方…」
「家計簿アプリに入力するのが手間ですぐに挫折してしまう…」

そんな悩みを、AIの力で解決してみませんか?

今回は、普段使っているLINE、無料で使えるプログラム環境Google Apps Script(GAS)、そしてノーコードAI開発ツールDifyを連携させて、「領収書を撮影して送るだけで、スプレッドシートに自動記帳してくれるアプリ」を作成します。

システムの全体像と仕組み

今回構築するシステムの処理フローは以下の通りです。

  1. LINE:ユーザーが領収書の画像を送信。(LINEからGASへwebhookが送信される)
  2. GAS:画像を受け取り、DifyのAPIへ転送。(GASがそのリクエストを受け取り、画像データをDifyに送信)
  3. Dify:AI(GPT-4o-mini等)が画像を解析し、店名・金額・日付などをJSONデータとして抽出。
  4. GAS:解析結果を受け取り、Googleスプレッドシートに追記&画像をGoogleドライブに保存。
  5. LINE:完了メッセージをユーザーに返信。
システム構成図

STEP 1:LINE Botの準備

まずは入り口となるLINE公式アカウントを作成します。

  • LINE Developersでチャネルを作成(Messaging API)。
  • チャネルアクセストークン(長期)を発行してメモ。
  • Webhook設定は後ほど行います。

Line Bot の作り方の詳しい説明は、以下の記事で作成していますので参照頂ければと思います。

STEP 2:Difyで画像解析AIを作成

Difyを使って、画像を読み取ってJSONデータを返すAIアプリを作成します。

  1. Difyで「最初から作成」→「チャットボット」を選択。
  2. モデル設定で、画像認識が可能なモデル(例:gpt-4o-mini)を選択。
  3. システムプロンプトに以下の指示を入力します。
あなたはレシート解析AIです。アップロードされた画像から以下の情報を抽出し、JSON形式のみで出力してください。余計な会話は不要です。

出力フォーマット: { "store": "店名", "date": "YYYY-MM-DD", "total": 1000, "items": [ {"name": "商品A", "price": 500}, {"name": "商品B", "price": 500} ] }

作成後、右上の「APIアクセス」からAPIキーエンドポイントURLをコピーしておきます。

STEP 3:GASで連携システムを構築

Googleドライブで新規に「Google Apps Script」を作成し、LINEとDify、スプレッドシートをつなぐコードを記述します。

・まっさらなスプレッドシートを作成
・スプレッドシートを開いて、拡張機能>Apps Scriptを開く

いずれにしろスプレッドシート使うのですが、自分はファイルとして操作したかったので単独のScriptファイルとして作成しました。

1. 定数の設定

スクリプトプロパティ(環境変数)に、LINEとDifyのトークンを設定するか、コード内の定数として定義します。

const LINE_TOKEN = 'YOUR_LINE_ACCESS_TOKEN'; 
const DIFY_API_KEY = 'YOUR_DIFY_API_KEY'; 
const DIFY_URL = 'https://api.dify.ai/v1/chat-messages'; 
const FOLDER_ID = 'YOUR_GOOGLE_DRIVE_FOLDER_ID'; // 画像保存用 
const SHEET_ID = 'YOUR_SPREADSHEET_ID'; // データ保存用

2. メイン処理(doPost)の実装

LINEからのWebhookを受け取り、処理を振り分けるメイン関数です。

作成したLINE公式アカウントにメッセージ(画像)を送ると指定したURL(このあと設定)にWebhookイベントオブジェクトを含むHTTP POSTリクエストが送られてきます。今回はPOSTリクエストを受け取るためにdoPost関数を使います。

function doPost(e) { 
  const json = JSON.parse(e.postData.contents); 
 const event = json.events[0]; 
  const replyToken = event.replyToken;

  // 画像メッセージ以外は無視 
  if (event.message.type !== 'image') { replyLine(replyToken, 'レシートの画像を送ってください。'); return; }

  try { // 1. LINEから画像データを取得 const imageBlob = getLineImage(event.message.id);

  // 2. Googleドライブに保存
  const file = DriveApp.getFolderById(FOLDER_ID).createFile(imageBlob);

  // 3. Difyに画像を送信して解析
  const resultJson = callDifyApi(imageBlob);

  // 4. スプレッドシートに書き込み
  saveToSheet(resultJson, file.getUrl());

  // 5. 完了メッセージを返信
  replyLine(replyToken, `登録しました!\n店名: ${resultJson.store}\n金額: ${resultJson.total}円`);
  } 
  catch (err) { replyLine(replyToken, 'エラーが発生しました: ' + err.message); } 
}

2-2: 具体的な処理内容

main.gs

/**
 * LINE webhook からのリクエスト受け取り処理
 * @param {GoogleAppsScript.Events.DoPost} e フォーム送信時のイベントオブジェクト
 * @return {GoogleAppsScript.Content.TextOutput} レスポンスとして返すメッセージ
 */
function doPost(e) {

  if (!e){
    Logger.log('None data');
    return ContentService.createTextOutput('データがありません');
  }

  // LINEからのメッセージデータ取得
  const json = JSON.parse(e.postData.contents);
  const replyToken = json.events[0].replyToken;
  const message = json.events[0].message;
  const messageType = message.type;

  if (typeof replyToken === 'underfined') {
    return ContentService.createTextOutput('リプライトークンが見つかりません');
  }
  if (messageType !== 'image'){
    replyMessageToLine(replyToken, '画像を送ってください');
  }

  try{
    // 画像データ準備
    const imageBlob =  getImageBlobByLineMessage(message);
    const extension = getExtensionByMimeType(imageBlob.getContentType());
    const fileName = generateTimestampedFileName(extension);

    // Difyアプリ利用
    const uploadResponse = uploadImageToDify(imageBlob,fileName);
    const messageResponse = sendMessageToDify('レシート画像', uploadResponse.id);
    const answerJson = JSON.parse(messageResponse.answer);

    // GoogleDriveに保存
    const file = saveImageToDrive(
      imageBlob,
      fileName
    );

    // スプレッドシート書き込み
    const answerArray = generateSheetDataFromJson(answerJson, file.getUrl());
    writeMultiDataToSpreadsheet(answerArray);
    // メッセージをリプライ
    const resultMessage = 'レシートデータの保存が完了しました!\n\n' +
                    '保存した画像URL: ' + file.getUrl() + '\n\n' +
                    '読み取ったデータ: ' + JSON.stringify(answerJson);
    replyMessageToLine(replyToken, resultMessage);

  } catch(error){
    replyMessageToLine(replyToken, error.message);
  }
}

パラメーターについて

必要な下記2つを取り出し

  • replyToken:LINEで応答メッセージを送る際に必要なトークン
  • message.type:メッセージのタイプ(テキスト/画像/動画/音声ファイル/位置情報/スタンプ)
  const replyToken = json.events[0].replyToken;
  const message = json.events[0].message;
  const messageType = message.type;

message.type はテキストならtext、画像ならimage、動画ならvideoとなりますので、今回は画像を送ってきて欲しいのでそれ以外は「違う」と教えてあげるようにします。

応答返却(replyMessageToLine関数)について

この後、何度も応答メッセージの送信処理を使うので、別ファイルに独自関数として作成しておきました。
GASはファイルを分けても特にimportなど不要で認識してくれます。
そのため関数名つける時は、気を付ける必要がありますね!

LINE.gs

/**
 * LINEでリプライメッセージを投稿する
 * @param {string} replyToken リクエストに含まれていたリプライトークン
 * @param {string} messageText 送りたいメッセージ
 * @return {UrlFetchApp.HTTPResponse} リクエスト結果のHTTPレスポンス
 */
function replyMessageToLine(replyToken, messageText){
  const url = LINE_URL + '/reply';
  const options = {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': messageText,
      }],
    }),
  }

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

定数について

replyMessageToLine関数の中で使っている下記2つの定数をさらに別ファイルにしています。

constants.gs

/**
 * LINE
 */
// LINE developersのメッセージ送受信設定に記載の アクセストークン
const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_TOKEN");
// LINEのURL
const LINE_URL = 'https://api.line.me/v2/bot/message';

トークンはうっかりが非常に危険なので、プロジェクトのスクリプトプロパティに設定して取り出すようにしています。

image.png

3. Dify API呼び出し関数の実装

Difyの「ファイルアップロードAPI」と「チャットメッセージAPI」を組み合わせて使用します。

※Difyへの画像送信は、multipart/form-data形式で行う必要があるため、GASでの実装には工夫が必要です(詳細なコードは複雑になるため割愛しますが、Utilities.newBlobなどを駆使してリクエストを作成します)。

STEP 4:デプロイと動作確認

  1. GASエディタ右上の「デプロイ」→「新しいデプロイ」を選択。
  2. 種類を「ウェブアプリ」、アクセスできるユーザーを「全員」にしてデプロイ。
  3. 発行されたウェブアプリURLをコピーし、LINE DevelopersのWebhook URLに設定して有効化します。

これで準備完了です!LINEでレシート画像を送ってみましょう。

動作確認

STEP 5: Difyの設定

チャットボットのChatflowで作成します

5-1: LLMの設定

作成されたワークフローの中のLLMの部分を設定していきます
モデルは画像を扱える必要があるため、デフォルトのgpt-4o-miniを使っていきます。

画像データを渡して内容を読み取って特定の形式で返してくれるようにプロンプト部分を入れていきます
・XMLで記述
・何をするタスクかを記載する
・返却する形式を指定する
・細かな注意点や、実例も入れている

{{#context#}}
<instruction>
    <instructions>
        このタスクでは、レシートの画像から内容を読み取ってレシートに記載している情報をjson形式で抽出します。

        レシート画像からうまく情報が読み取れない場合は"画像読み取りNG"とだけ回答してください。
        レシートの情報は以下の形式で抽出してください:
        {
            "store":発行したお店や会社の名前,
            "purchase_date":発行した日付,
            "time":発行時間,
            "items": [
            {
                "name":商品名,
                "quantity": 個数,
                "price":価格(税込)
            },
            ],
            "total": 合計金額(税込),
            "payment_method": 支払い方法
        }




        レシートの内容を読み取る際には、文脈を考慮し、正確な情報を抽出するようにしてください。
        内容を計算して正しい情報を抽出出来ているかを確認してください。
        価格は税込みの価格を抽出してください。
        小計金額、合計金額も税込みの価格を抽出してください。
        割引金額はマイナスの値で抽出してください。
        支払い方法は、クレジットカード、デビットカード、電子マネー、現金などの情報を抽出してください。
        レシート内に該当のデータが存在しない場合は、そのデータを空欄としてください。
        例として、以下のようなデータを抽出してください。
        データ抽出出来た場合はjsonのみのデータを返却してください。返却データにはバッククォートやシングルクォートは含めないでください。

    </instructions>
    <examples>
        {
            "store": "とっても美味しいお弁当店",
            "purchase_date": "2024-09-26",
            "time": "20:17",
            "items": [
                {
                "name": "ステーキ弁当",
                "quantity": 2,
                "price": 1320
                },
                {
                "name": "和風ドレッシング",
                "quantity": 1,
                "price": 0
                },
                {
                "name": "バラエティセット",
                "quantity": 1,
                "price": 650
                }
            ],
            "total": 1720,
            "payment_method": "PayPay"
        }
    </examples>
</instruction>
image.png

出来上がったら公開をしてください。

STEP 6: GoogleDriveへ画像を保存

Difyを通して無事画像の解析が出来たら、画像をGoogleDriveへ保存します。
こちらも別ファイルへ関数を作成。

constants.gs

/**
 * Google Drive
 */
// 保存先GoogleDriveのフォルダID
const FOLDER_ID = '1234567890abcdefghijklmnopqrstuvwxyz';

Drive.gs

/**
 * 画像Blobを受け取り、Google Driveに保存します。
 * @param {Blob} imageBlob 画像データのBlob
 * @param {string} fileName 保存するファイルの名前
 * @return {GoogleAppsScript.Drive.File} 保存されたファイルオブジェクト
 */
function saveImageToDrive(imageBlob, fileName) {
  // フォルダを取得
  const folder = DriveApp.getFolderById(FOLDER_ID);
  // ファイルを指定フォルダに保存
  return  folder.createFile(imageBlob).setName(fileName);
}

STEP 7: スプレッドシートへ書き込み

7-1: writeMultiDataToSpreadsheet関数

データをスプレッドシートへ書き込む関数を作成します。
配列データを渡し書き込む関数です。
こちらも別ファイルへ関数を作成。

constants.gs

/**
 * スプレッドシート
 */
// スプレッドシートIDを指定
const SPREADSHEET_ID = 'abcdefghijklmnopqrstuvwxyz';
// シート名を指定
const SHEET_NAME = 'db';

spreadsheet.gs

/**
 * 複数データを一括で書き込む
 * @param {Array} array 保存する配列データ
 * @return void
 */
function writeMultiDataToSpreadsheet(array) {
  // スプレッドシートとシートを取得
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
  // 現在の最終行を取得
  const lastRow = sheet.getLastRow();
  // スプレッドシートの次の行にPOSTデータを書き出す
  sheet.getRange(lastRow + 1, 1, array.length, array[0].length).setValues(array);
}

このような配列を渡すと

[
    ["A1","B1","C1"],
    ["A2","B2","C2"],
    ["A3","B3","C3"],
]

下記のような形で書き込みします

A列B列C列
1行目A1B1C1
2行目A2B2C2
3行目A3B3C3

7-2: 書き込みデータについて

今回受け取るJsonはitemsが配列になっているこのようなものになっています。

{
    "store":"とっても美味しいお弁当店",
    "purchase_date":"2023-10-25",
    "time":"20:51",
    "items":[
        {
            "name":"肉野菜炒め弁当",
            "quantity":1,
            "price":590
        },
        {
            "name":"鰻天丼",
            "quantity":1,
            "price":690
        },
        {
            "name":"から揚げ",
            "quantity":1,
            "price":180
        }
    ],
    "total":1430,
    "payment_method":"PayPay"
}

そのため、下記のような形で書き込むことにしました。

timestamppurchase_datetimestoreitems_nameitems_quantityitems_pricetotalpayment_methodimage_url
データデータデータデータn itemn itemn itemデータデータデータ
データデータデータ
データデータデータ
データデータデータm

itemsの部分は1行目に何個のデータがあるか
2行目以降に各itemデータを記載

併せて、下記2つを追加しています
timestamp :書き込み日時
image_url :保存したGoogleDriveのURL

7-3: 書き込み用の配列生成

getSpreadsheetHeaderIndexObject関数

この後の処理で出てくる内容ですが、どのカラムが何列目なのかを取得する関数
今後もカラムを変えても対応出来るようにスプレッドシートの1行目から取得する

spreadsheet.gs

/**
 * スプレッドシートの1行目からカラム名と列番号を取り出し、keyがカラム名、valueが列番号となるオブジェクトを作成
 * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet 対象となるスプレッドシートのシートオブジェクト
 * @returns {Object} カラム名をキー、列番号を値とするマッピングオブジェクト
 * 
 * 出力されるオブジェクトの例:
 * {
 *   'date': 1,
 *   'store': 2,
 *   'items_name': 3,
 *   'items_price': 4,
 *   'total': 5
 * }
 */
function getSpreadsheetHeaderIndexObject() {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
  const headerRow = sheet.getRange('1:1').getValues()[0].filter((value) => value);
  const indexObject = {};
  headerRow.forEach((value, index) => {
    indexObject[value] = index;
  });
  return indexObject;
}

generateSheetDataFromJson関数

下記の流れで配列を生成します。
・前項のgetSpreadsheetHeaderIndexObject関数を使って、カラムのインデックスを取得
・itemsの処理用のitem_〇〇のインデックスも抽出
・空の配列作成関数と、item配列作成関数を準備
・1行目の空の配列を準備して、時間とURLとアイテム数を書き込み
・JSONをループ処理
 - itemsはアイテム用配列を作って配列ごと追加
 - それ以外は1行目の配列に内容を書き込み

spreadsheet.gs

/**
 * JSONデータからスプレッドシートに書き込むための配列を生成します。
 * @param {Object} jsonData スプレッドシートに書き込む元となるJSONデータ
 * @returns {Array} スプレッドシートに書き込むための2次元配列
 * 
 * JSONデータの構造:
 * {
 *   'date': string,
 *   'store': string,
 *   'items': [
 *     {
 *       'name': string,
 *       'price': number
 *     }
 *   ],
 *   'total': number,
 * }
 * 
 * 出力される配列の形式:
 * [
 *   [date, store, '', '', total],
 *   ['', '', items_name1, items_price1, ''],
 *   ['', '', items_name2, items_price2, '']
 * ]
 */
function generateSheetDataFromJson(jsonData, fileUrl = '') {

  const headerIndex = getSpreadsheetHeaderIndexObject();
  const itemHeader = Object.keys(headerIndex).filter((key) => {
    return key.includes('items');
  });

  const getNewArray = () => {
    return new Array(Object.keys(headerIndex).length).fill(null);
  };

  const getItemArray = (item) => {
    const itemArray = getNewArray();
    Object.entries(item).map(([key, value]) => {
      itemArray[headerIndex['items_' + key]] = value;
    });
    return itemArray;
  }

  const result = [getNewArray()];
  result[0][headerIndex['timestamp']] = new Date();
  result[0][headerIndex['image_url']] = fileUrl;
  itemHeader.map((item_key) => {
    result[0][headerIndex[item_key]] = jsonData.items.length + ' item';
  });


  Object.entries(jsonData).map(([key, value]) => {
    if(key === 'items'){
      value.map((item) => {
        result.push(getItemArray(item));
      })
    } else {
      result[0][headerIndex[key]] = value;
    }
  });

  return result;
}

書き込み処理

これで配列作成→スプレッドシート書き込みと行えるようになりました

// スプレッドシート書き込み
const answerArray = generateSheetDataFromJson(answerJson, file.getUrl());
writeMultiDataToSpreadsheet(answerArray);

STEP 8: 結果リプライ処理

ここまで来たらあとは、結果を応答メッセージとして送るだけです。
pert1で作成したreplyMessageToLine関数を使って、送りましょう。

// メッセージをリプライ
const resultMessage = 'レシートデータの保存が完了しました!\n\n' +
                '保存した画像URL: ' + file.getUrl() + '\n\n' +
                '読み取ったデータ: ' + JSON.stringify(answerJson);
replyMessageToLine(replyToken, resultMessage);

まとめ

Difyの画像認識能力とGASの連携機能を活用することで、個人レベルでも高機能な「経費精算自動化システム」を作ることができました。

今回は「領収書」をテーマにしましたが、この仕組みは「名刺管理」や「手書きメモのデジタル化」など、様々な業務に応用可能です。ぜひ、身近な業務の自動化にチャレンジしてみてください。

【推奨】業務システム化に有効なアイテム

生成AIを学ぶ

システム化のパートナー

VPSサーバの選定

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA


目次