LINEから書籍リストを作りたい　①画像読み込み・画像連携まで

お疲れさまです、みやもとです。

先日オンラインのハンズオンに参加しました。
LINEで名刺の画像を送ってDifyからChatGPTで分析、分析データをGoogleフォームに連携してスプレッドシートに転記するというものです。

https://linedevelopercommunity.connpass.com/event/337828/

この時ちょっとしたエラーが発生してしまい残念ながら時間内に動かすことはできなかったのですが、今までアカウントを作ったきりでさっぱり役立てられてないDifyの使用例がわかったことだしなんか作ろう！と思った次第です。

:::note info
先述のハンズオンの内容を前提にしており、技術的な解説を省略しているところがあます。
元のハンズオン資料をご参照いただければ幸いです。
https://note.com/jun_ichikawa/n/nf154656e80bf
:::

# 同じ本を二度買う
みやもとは割と本が好きです。
なんかいいなと思ったら買います。
電子書籍もいいのですがやっぱりページをめくる感触が好きで、大きな書店があるとついつい立ち寄ってしまいます。

そういうことをしていると当然家に本が溜まります。
何度も読み返す本もあれば１回読んで満足する本もありますが、困るのは特に後者で「これうちにあったっけ？」が発生すること。
「あったっけ？」と悩むならまだいい方で、「衝動買いした本が実は家にありました」となってくずおれたことも一度や二度ではありません。

これではいけない。

# 書籍情報を取得しよう
ということで、ハンズオンで作ったものを書籍用に読みかえて本題です。

ひとまずフォーム連携は後回しにして、画像を読み込んで書籍情報を返してもらうところまで作ってみましょう。

## LINE Messaging APIの準備
まずはLINE側の準備です。
手順詳細はこちらの記事とか詳しいのでご参照ください。

https://qiita.com/zono345/items/fefeb13ff493ce8b432b

* プロバイダを作る（既存のがあればそっちを使ってもOK）
* チャネルを作る
* チャネルアクセストークンを取得する

ひとまずここまでできればOK。

## Difyの準備
次にDify側の準備。
ワークフローを作成します。
私はハンズオンで使ったものをコピーして修正しましたが、最初から作る場合は「アプリを作成する - 最初から作成」でワークフローを指定すればいけるはず。

フローはこんな感じ。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/f83f453f-6e7f-e525-cf1a-c9851bd71339.png">

開始・LLM・終了の3ステップのみですね。
各ステップについてちょっとだけ補足します。
記載のない項目等はデフォルトのままと思ってください。

### 開始
LINEからGoogle Apps Script経由で送られてくる画像データを定義します。
変数名を「BookCover」にして、入力フィールド編集。

| 項目 | 設定値 |
|:-:|:-:|
| フィールドタイプ | 単一ファイル |
| 変数名 | BookCover |
| ラベル | BookCover |
| サポートされたファイルタイプ | 画像 |
| アップロードされたファイルのタイプ | 両方 |
| 必須 | チェックを入れる |

### LLM
開始で定義したBookCoverをインプットして解析してもらうところです。
こちらはモデルを選択し、モデルに対する設定を行います。

<table>
<tr>
<th colspan="2">
項目
</th>
<th>
設定値
</th>
</tr>
<tr>
<td colspan="2">
モデル
</td>
<td>
Gemini 1.5 Pro
</td>
</tr>
<tr>
<td>
</td>
<td>
JSON Schema
</td>
<td>
オン（内容は後述）
</td>
</tr>
<tr>
<td colspan="3">
コンテキスト
</td>
</tr>
<tr>
<td rowspan="2">
</td>
<td>
SYSTEM
</td>
<td>
書籍画像情報からタイトルと著者名を取得し、JSON形式で出力してください。
</td>
</tr>
<tr>
<td>
USER
</td>
<td>
開始.BookCover
</td>
</tr>
<tr>
<td colspan="2">
出力変数
</td>
<td>
text(String)
</td>
</tr>
</table>

ハンズオンではChatGPTでしたが、普段LINEBOT作るときに使っているのもあって今回はGeminiにしました。
ハンズオンの後あれこれいじりすぎてChatGPTの使用上限を超えたせいもあります。

ChatGPTとGeminiだとJSON Schemaで指定する内容が微妙に違うようで、モデルだけ変えて実行したらエラーになりました。

:::note warn
2024/12/25追記：
この記事の投稿後に再度使ってみたところ、userにtextの指定が無いというエラーになりました。
ドキュメント上は任意のはずなのですが、どうもGemini側でtextパラメータが必須扱いになったようです。
:::


```JSON
{
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "description": "書籍画像から読み取ったタイトル"
    },
    "author": {
      "type": "string",
      "description": "書籍画像から読み取った著者名"
    }
  },
  "required": ["title", "author"]
}
```

### 終了
LLMの出力変数を返しておしまいです。

| 項目 | 設定値 |
|:-:|:-:|
| 出力変数 | text |

全部終わったらメニューの「公開する」を実行し、「APIリファレンスにアクセス」でAPI情報のページに移動しましょう。
サイト右上で稼働中なのを確認出来たらAPIキーを生成してコピーしておきます。

## Google Apps Scriptの準備
ようやくコードを書きます。
新しいプロジェクトを作成してまずはコード。

```JavaScript:コード.gs
const prop = PropertiesService.getScriptProperties().getProperties();

const LINEAPI_TOKEN = prop.LINEAPI_TOKEN;
const DIFY_API_KEY = prop.DIFY_API_KEY;

/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  let event = JSON.parse(e.postData.contents).events[0];
  let replyToken = event.replyToken;
  let msgType = event.message.type;
  try {
    if (event.message && msgType === 'image') {
      let messageId = event.message.id;
      Logger.log('画像メッセージ受信: messageId=%s', messageId);
      let imageBlob = getImageFromLINE(messageId);
      Logger.log('画像取得成功。サイズ: %d bytes', imageBlob.getBytes().length);

      let base64Image = Utilities.base64Encode(imageBlob.getBytes());
      Logger.log('画像をbase64エンコード完了: length=%d', base64Image.length);

      let { title, author } = analyzeBookCover(base64Image);
      Logger.log('解析結果: title=%s, author=%s', title, author);

      replyMessage(replyToken, `書籍情報:\nタイトル: ${title}\n著者: ${author}`);
      Logger.log('返信完了');
    } else if (event.message && msgType=='text') {
      Logger.log('動作確認用のテキスト応答');
      replyMessage(replyToken, 'text:' + event.message.text);
    } else {
      Logger.log('画像メッセージでないため、メッセージを返信します。');
      replyMessage(replyToken, '書籍画像を送信してください。');
    }
  } catch (error) {
    Logger.log(`エラーが発生しました: ${error.stack || error}`);
    return ContentService.createTextOutput(JSON.stringify({ content: 'error' })).setMimeType(ContentService.MimeType.JSON);
  }
}


/**
 * LINEから画像を取得
 * @param {String} messageId - メッセージID
 */
function getImageFromLINE(messageId) {
  Logger.log('getImageFromLINE start: messageId=%s', messageId);
  let url = 'https://api-data.line.me/v2/bot/message/' + messageId +'/content';
  let response = UrlFetchApp.fetch(url, {
    headers: { 'Authorization': 'Bearer ' + LINEAPI_TOKEN },
  });
  Logger.log('LINE画像取得レスポンスステータス: %s', response.getResponseCode());
  Logger.log('getImageFromLINE end');
  return response.getBlob();
}


/**
 * Difyで書籍を解析
 * @param {String} base64Image - エンコード後の画像データ
 */
function analyzeBookCover(base64Image) {
  Logger.log('analyzeBookCover start');
  let fileId = uploadFileToDify(base64Image);
  Logger.log('DifyファイルID取得: %s', fileId);

  let result = runDifyWorkflow(fileId);
  Logger.log('Difyワークフロー実行結果: title=%s, author=%s', result.title, result.author);
  Logger.log('analyzeBookCover end');
  return result;
}


/**
 * Difyにファイルをアップロード
 * @param {String} base64Image - エンコード後の画像データ
 */
function uploadFileToDify(base64Image) {
  Logger.log('uploadFileToDify start');
  let url = 'https://api.dify.ai/v1/files/upload';
  let user = 'line-user'; // 任意のユーザーIDを設定
  let imageBytes = Utilities.base64Decode(base64Image);
  Logger.log('画像バイト長: %d', imageBytes.length);

  let blob = Utilities.newBlob(imageBytes, 'image/jpeg', 'book_cover.jpg');
  Logger.log('Blob作成完了: type=%s, name=%s', blob.getContentType(), blob.getName());

  let formData = {
    'file': blob,
    'user': user
  };

  let options = {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + DIFY_API_KEY
    },
    payload: formData,
    muteHttpExceptions: true
  };

  let response = UrlFetchApp.fetch(url, options);
  let status = response.getResponseCode();
  let responseText = response.getContentText();
  Logger.log('Difyファイルアップロードレスポンスステータス: %d', status);
  Logger.log('Difyファイルアップロードレスポンスボディ: %s', responseText);

  let result = JSON.parse(responseText);

  if (!result.id) {
    Logger.log('ファイルアップロード失敗: %s', JSON.stringify(result));
    throw new Error('ファイルアップロードに失敗しました。');
  }

  Logger.log('uploadFileToDify end');
  return result.id; // file_idを返す
}


/**
 * Difyのワークフローを実行し、結果から情報を取得
 * @param {String} fileId - ファイルID
 */
function runDifyWorkflow(fileId) {
  Logger.log('runDifyWorkflow start: fileId=%s', fileId);
  let url = 'https://api.dify.ai/v1/workflows/run';
  let user = 'line-user'; // 任意のユーザーID
  let payload = {
    "inputs": {
      // Difyワークフローで定義されたインプット変数名
      "BookCover": {
        "transfer_method": "local_file",
        "upload_file_id": fileId,
        "type": "image"
      }
    },
    "response_mode": "blocking",
    "user": user
  };
  Logger.log('ワークフロー実行ペイロード: %s', JSON.stringify(payload));

  let options = {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + DIFY_API_KEY,
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  let response = UrlFetchApp.fetch(url, options);
  let status = response.getResponseCode();
  let responseText = response.getContentText();
  Logger.log('Difyワークフローレスポンスステータス: %d', status);
  Logger.log('Difyワークフローレスポンスボディ: %s', responseText);

  let result = JSON.parse(responseText);
  
  if (result && result.data && result.data.outputs) {
    Logger.log('Difyワークフローoutputs全体: %s', JSON.stringify(result.data.outputs));
    let outputs = result.data.outputs;

    // textフィールドにJSON文字列が入っているため、これをパース
    if (outputs.text) {
      try {
        let parsed = JSON.parse(outputs.text);
        let title = parsed.title || '不明';
        let author = parsed.author || '不明';
    Logger.log('取得した出力: title=%s, author=%s', title, author);
    Logger.log('runDifyWorkflow end');
    return { title, author };
      } catch (parseError) {
        Logger.log('textフィールドのJSONパースに失敗しました: %s', parseError);
        throw new Error('書籍解析結果のパースに失敗しました。');
      }
    } else {
      Logger.log('textフィールドが存在しませんでした。');
      throw new Error('書籍解析結果が取得できませんでした。');
    }
  } else {
    Logger.log('ワークフローの結果が期待した形式でありません: %s', JSON.stringify(result));
    throw new Error('書籍解析結果の取得に失敗しました。');
  }
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} messageText - 返却テキスト
 */
function replyMessage(token, messageText){
  let message = {
                    'replyToken' : token,
                    'messages' : [{
                      'type': 'text',
                      'text': messageText
                    }]
                  }
        , options = {
                    'method' : 'post',
                    'headers' : {
                      'Content-Type': 'application/json; charset=UTF-8',
                      'Authorization': 'Bearer ' + LINEAPI_TOKEN,
                    },
                    'muteHttpExceptions': true,
                    'payload' : JSON.stringify(message)
                  };
  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', options);
}
```
実はハンズオンの時はLINE側からの検証でひっかかり、終了まで何が原因か見つけることができませんでした。
そのため、このコードを書く前に自分で以前作ったLINEBOTのコードで連携だけ確認→ハンズオンのコードを書きうつして必要な個所だけ修正するという手順を取っています。
そのせいで若干書き方に自作部分の名残が残って統一性がなかったりしますが、処理の流れはほぼハンズオンでいただいた内容そのままです。
変数名とかメッセージ内容をちょろっと変えたくらい。

あとはプロジェクトの設定からスクリプトプロパティを追加してウェブアプリとしてデプロイしましょう。

| プロパティ名 | 設定値 |
|:-:|:-:|
| LINEAPI_TOKEN | LINE Messaging APIで作成したチャネルアクセストークン |
| DIFY_API_KEY | DIFYで作成したAPIキー |

デプロイしたらウェブアプリのURLをコピーしてLINE Messaging APIに戻ります。

## ふたたびLINE Messaging API
戻ったら

* Webhookの使用をオンにする
* Webhook URLにコピーしたURLを張り付けて設定
* 検証でOKになることを確認

これで完了です。

# 動作確認
適当に手元にあった本を撮影して試してみました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/c381124a-3635-bb1c-73ad-7abf04d56eae.png" width="70%">

ちゃんと読み取れました！