[LINE×GAS×Dify連携]領収書管理アプリを作ってみたpert3　〜スプレッドシート書き込み〜

## はじめに
前回の続きになります。

少し長いので3つに分割しています。
こちらはPert3 です。

↓Pert1（前々回） はこちら↓

https://qiita.com/mkthrkw/items/c3fd56a8cc21f13e3cb8

↓Pert2（前回） はこちら↓

https://qiita.com/mkthrkw/items/c6139f6a67cb695a3bb2


普段使っているLINE、無料で使えるGoogle Apps Script（以下、GAS）、簡単にLLMのアプリが作れると話題のDifyを連携して領収書管理アプリを作っていきます。


## 記事の内容
• LINEとGASを連携させ、ユーザーのアップロードした画像を処理する方法
• GASを使って画像をDifyに送信する方法
• 領収書の画像からデータを抽出し、Googleスプレッドシートに保存する方法
※Difyは最低限しか使っていない（使わなくてもよい）ので、詳しい使い方は他記事を参考にしてください。
※本格的というより、とりあえずやってみたいよーと思ってる方向けです。


## Step 6: GoogleDriveへ画像を保存

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

```javascript:constants.gs
/**
 * Google Drive
 */
// 保存先GoogleDriveのフォルダID
const FOLDER_ID = '1234567890abcdefghijklmnopqrstuvwxyz';
```
```javascript: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関数

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

```javascript:constants.gs
/**
 * スプレッドシート
 */
// スプレッドシートIDを指定
const SPREADSHEET_ID = 'abcdefghijklmnopqrstuvwxyz';
// シート名を指定
const SHEET_NAME = 'db';
```

```javascript: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行目|A1|B1|C1|
|2行目|A2|B2|C2|
|3行目|A3|B3|C3|


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

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

```json
{
    "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"
}
```

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

|timestamp|purchase_date|time|store|items_name|items_quantity|items_price|total|payment_method|image_url|
|--|--|--|--|--|--|--|--|--|--|
|データ|データ|データ|データ|n item|n item|n item|データ|データ|データ|
|||||データ|データ|データ||||
|||||データ|データ|データ||||
|||||データ|データ|データm||||

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

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


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

#### getSpreadsheetHeaderIndexObject関数

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

```javascript: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行目の配列に内容を書き込み

```javascript: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;
}
```

#### 書き込み処理

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

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


## Step 8: 結果リプライ処理

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

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


## Step 9: 一連のテスト

個別の処理関数は各ファイルに分割して、メインの処理ファイルは
全部をつなぎ合わせるとこのようになりました。

```javascript: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);
  }
}
```

実際にラインから画像を送ってみると...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/35719feb-b090-b301-128b-0b806538fc8c.png)

きました👏

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/868a183c-b271-69c5-6672-a4ae1f0cd778.png)


## さいごに
- 画像からテキストデータを読み取って保存するだけというシンプルな内容ですが、以外と画像処理に手こずってしまいました
- GASを使ってWebhookを処理するのはとっても簡単ですが、署名検証出来ないため他の選択肢を考える必要がありそうです
- 今回は、久しぶりにGASを触ってみて、あれやこれやと勉強になったのでよかったです