バイブコーディング×ノーコードで作る名刺管理アプリ!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連携で「なんかうまくいかないな…」という時は、焦らず、ログをしっかり確認して、一つずつ原因を潰していくのが近道かもしれません。
この名刺管理アプリ、まだまだ進化させていく予定ですので、ご期待ください!
0 件のコメント:
コメントを投稿