バイブコーディング×ノーコードで作る名刺管理アプリ!Gemini API連携奮闘記:OCRからJSON抽出までの道のり

2025年5月21日水曜日

Appsheet ChatGPT GAS Gemini API Google Apps Script No Code VibeCoding

バイブコーディング×ノーコードで作る名刺管理アプリ!Gemini API連携奮闘記:OCRからJSON抽出までの道のり



こんにちは!AppSheetとGoogle Apps Script (GAS) を使って、イケてる名刺管理アプリの開発に挑戦中のKazuhiroです。OCR機能にはGoogleの最新AI、Gemini APIを活用しようという野心的なプロジェクト!しかし、その道のりは平坦ではありませんでした…(笑)

今回は、Gemini API (Vision と Chat) を使って名刺画像から情報を抽出し、スプレッドシートに自動入力するGAS開発の奮闘記をお届けします。

最初の壁:APIエンドポイントとモデル指定の迷宮

意気揚々と最初のスクリプトを書き上げ、いざ実行!…すると、いきなり 404 Not Found の洗礼。

Kazuhiro:「あれ?models/gemini-pro が見つからないって言われる… APIバージョン v1beta だよね?」

そう、最初はAPIのバージョン指定 (v1beta なのか v1 なのか) や、利用可能なモデル名 (gemini-pro なのか gemini-1.0-pro なのか、はたまた gemini-1.5-pro なのか…) で大混乱。公式ドキュメントとにらめっこしつつ、試行錯誤の末、最終的には gemini-2.0-flash-lite という軽量かつ高速なモデルに落ち着きました。このモデル選定だけで、かなりの時間を溶かした記憶が…(遠い目)

次なる刺客:手強いJSONパースエラー

モデル問題が解決し、ようやくGemini APIから応答が返ってきた!と喜んだのも束の間、今度は JSON parse error の嵐。

Kazuhiro:「レスポンスは来てるっぽいんだけど、Unexpected token 'j', "json\n{\n..." is not valid JSON ってどういうこと!?」

ログをよく見ると、Gemini APIからの応答文字列の先頭に余計な json というプレフィックスが付いていたり、Markdownのコードブロック(```json ... ```)で囲まれていたり。これらが原因で、GASの JSON.parse() が悲鳴を上げていたのです。

ここからは、地道な文字列クリーニング処理の追加です。


// --- "json " プレフィックス除去 および Markdownフェンス除去ロジック ---
responseText = responseText.trim();
if (responseText.startsWith("json")) {
  responseText = responseText.substring(4).trim(); // "json " の部分を削除
}
responseText = responseText.replace(/^```[a-zA-Z]*\r?\n/, '')  // 先頭の ```json\n や ```\n を削除
  .replace(/\r?\n```$/, '')            // 末尾の \n``` を削除
  .replace(/```$/, '')                 // 末尾の ``` を削除 (改行がない場合)
  .trim();

この処理を extractBusinessCardFieldsFromText 関数に組み込むことで、ようやくGemini APIが返してくる「飾り気のある」JSON文字列を、素直なJSONオブジェクトに変換できるようになりました。

忘れちゃいけない:Google Driveの画像検索

AppSheetからアップロードされた名刺画像は、Google Driveに保存されます。GASでこの画像を取得する際、当初は file.getUrl() === url で比較していましたが、AppSheetが生成するURLとDriveのファイルURLが完全一致しないケースがある(または、より確実な方法としてファイル名で検索する)という懸念から、以下のように修正しました。


function getImageBlobFromUrl(url, folder) {
  const fileName = url.split('/').pop(); // URLの最後の部分(ファイル名)を抽出
  const files = folder.getFilesByName(fileName);
  if (files.hasNext()) return files.next().getBlob();
  Logger.log("ファイル未検出: " + fileName);
  return null;
}

これで、AppSheetのカラムに記録された画像パス(例:名刺交換ログ_Images/xxxxxxxx.名刺画像(表).xxxxxx.png)からファイル名を正確に抽出し、Drive内の画像ファイルを特定できるようになりました。

そして完成へ!最終GASスクリプト

これらの試行錯誤を経て、ついに名刺OCR処理GASが完成しました!
以下が、その主要部分を含む最終版のスクリプトです。


/**
 * 名刺交換ログ1行を対象に、Gemini Vision API+Chat APIで
 * OCRテキストおよび項目情報を自動入力する最終処理
 * (gemini-2.0-flash-lite モデル使用版)
 */
function processBusinessCardRowWithGemini() {
  const sheetId   = '1pGekeIOm9PaqFPTqmMdpOUIjCthH5rZlqBJZFC-0gf0';
  const folderId  = '1HoxQJIKCmpMbJdqOx1T1zw5zJyueEVSH';
  const sheetName = '名刺交換ログ';
  const apiKey    = 'YOUR_API_KEY'; // ご自身のAPIキーに置き換えてください

  const ss      = SpreadsheetApp.openById(sheetId);
  const sheet   = ss.getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();
  // ヘッダー取得&trim
  const headers = sheet.getRange(1,1,1,sheet.getLastColumn())
                       .getValues()[0].map(h=>h.toString().trim());

  const idx    = name => headers.indexOf(name);
  const frontImgCol = idx("名刺画像(表)");
  const backImgCol  = idx("名刺画像(裏)");
  const ocrFrontCol = idx("OCRテキスト_表");
  const ocrBackCol  = idx("OCRテキスト_裏");
  const statusCol   = idx("OCR済");

  // K〜Q列(1始まり)
  const colK = 11, colL = 12, colM = 13,
        colN = 14, colO = 15, colP = 16, colQ = 17;

  const folder = DriveApp.getFolderById(folderId);

  for (let r = 2; r <= lastRow; r++) {
    const row = sheet.getRange(r,1,1,sheet.getLastColumn()).getValues()[0];
    if (row[statusCol] === "済") continue;
    const frontUrl = row[frontImgCol], backUrl = row[backImgCol];
    if (!frontUrl && !backUrl) continue;

    // OCR
    let ocrF="", ocrB="";
    if (frontUrl) {
      const blob = getImageBlobFromUrl(frontUrl, folder);
      if (blob) ocrF = ocrWithGemini(blob, apiKey);
    }
    if (backUrl) {
      const blob = getImageBlobFromUrl(backUrl, folder);
      if (blob) ocrB = ocrWithGemini(blob, apiKey);
    }
    if (ocrF) sheet.getRange(r, ocrFrontCol+1).setValue(ocrF);
    if (ocrB) sheet.getRange(r, ocrBackCol+1).setValue(ocrB);

    // 項目抽出
    const extracted = extractBusinessCardFieldsFromText(ocrF, apiKey);
    Logger.log(`Row ${r} => Extracted JSON: ${JSON.stringify(extracted)}`);

    if (extracted["氏名"])           sheet.getRange(r, colK).setValue(extracted["氏名"]);
    if (extracted["メールアドレス"]) sheet.getRange(r, colL).setValue(extracted["メールアドレス"]);
    if (extracted["会社名"])         sheet.getRange(r, colM).setValue(extracted["会社名"]);
    if (extracted["部署"])           sheet.getRange(r, colN).setValue(extracted["部署"]);
    if (extracted["役職"])           sheet.getRange(r, colO).setValue(extracted["役職"]);
    if (extracted["電話番号(代表)"]) sheet.getRange(r, colP).setValue(extracted["電話番号(代表)"]);
    if (extracted["電話番号(個人)"]) sheet.getRange(r, colQ).setValue(extracted["電話番号(個人)"]);

    sheet.getRange(r, statusCol+1).setValue("済");
    SpreadsheetApp.flush();
  }
}

function getImageBlobFromUrl(url, folder) {
  const fileName = url.split('/').pop();
  const files = folder.getFilesByName(fileName);
  if (files.hasNext()) return files.next().getBlob();
  Logger.log("ファイル未検出: " + fileName);
  return null;
}

function ocrWithGemini(blob, apiKey) {
  const base64Image = Utilities.base64Encode(blob.getBytes());
  const payload = {
    contents:[{ parts:[{ inlineData:{ mimeType:blob.getContentType(), data:base64Image }}]}],
    generationConfig:{ temperature:0.2, maxOutputTokens:2048 }
  };
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=${apiKey}`;
  const res = UrlFetchApp.fetch(url, { method:"post", contentType:"application/json", payload:JSON.stringify(payload) });
  return JSON.parse(res.getContentText())?.candidates?.[0]?.content?.parts?.[0]?.text || "";
}

function extractBusinessCardFieldsFromText(text, apiKey) {
  const prompt = `
次のOCRテキストから、以下の項目を抽出してください:
氏名、メールアドレス、会社名、部署、役職、電話番号(代表)、電話番号(個人)

【重要】
- 必ずJSON形式でのみ返してください。
- 空の値は "" としてください。
- JSON以外は出力しないでください。

{
  "氏名": "",
  "メールアドレス": "",
  "会社名": "",
  "部署": "",
  "役職": "",
  "電話番号(代表)": "",
  "電話番号(個人)": ""
}

OCRテキスト:
${text}
`;
  const payload = {
    contents:[{ parts:[{ text:prompt }]}],
    generationConfig:{ temperature:0.2, maxOutputTokens:1024 }
  };
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=${apiKey}`;
  const res = UrlFetchApp.fetch(url, { method:"post", contentType:"application/json", payload:JSON.stringify(payload) });
  let responseText = JSON.parse(res.getContentText())?.candidates?.[0]?.content?.parts?.[0]?.text || "";

  Logger.log("Geminiからの生レスポンス: " + responseText);

  responseText = responseText.trim();
  if (responseText.startsWith("json")) {
    responseText = responseText.substring(4).trim();
  }
  responseText = responseText.replace(/^```[a-zA-Z]*\r?\n/, '')
    .replace(/\r?\n```$/, '')
    .replace(/```$/, '')
    .trim();

  Logger.log("クリーニング後のレスポンス: " + responseText);

  try {
    return JSON.parse(responseText);
  } catch (e) {
    Logger.log("JSON parse error: " + e);
    Logger.log("最終的なパース対象文字列: " + responseText);
    return {};
  }
}

まとめ

いやー、長かった!でも、これで名刺画像の情報をGemini APIで自動的にデータ化する仕組みが整いました。エラーログとにらめっこしながら、一つ一つ問題を解決していく過程は大変でしたが、その分、動いたときの感動はひとしおです。

皆さんもGASと外部API連携で「なんかうまくいかないな…」という時は、焦らず、ログをしっかり確認して、一つずつ原因を潰していくのが近道かもしれません。

この名刺管理アプリ、まだまだ進化させていく予定ですので、ご期待ください!