領収書OCRをGeminiとChrome拡張で実装♪
はじめに
直近Googleから発表されたGemini 2.0 FlashのOCR精度が高いという情報を得ました。
実際弊社澤木のZenn記事によると、斜めに写った帳票もなんなく認識してくれている様子でした。
以前何度か領収書OCRの仕組みを記事にしてきましたが、よりライトに使いやすくを考えて、今回はGemini 2.0 FlashとChrome拡張を組み合わせて領収書OCRの仕組みを実装してみました。
実装イメージ
Google Chromeで拡張機能を実行すると下記画面が開いて、領収書を撮影して、Gemini 2.0 Flashへ送ってOCRしてもらう実装をしました。
アーキテクチャ
実装したアーキテクチャは下記の通りとなります。
- 「領主書」を用意して、PCのカメラを使ってChrome拡張経由で撮影する
- 撮影したものを「Gemini 2.0 Flash」へ送信して、OCR結果を取得する
- OCR結果はCSVファイル形式になっており、CSVファイルとしてPCへ保存する
- 撮影した「領収書」もPCへ画像として保存する
実装方法
フォルダ構成
フォルダ構成は下記となります。
my-gemini-ocr-extension/
├── manifest.json
├── popup.html
├── popup.js
├── options.html
└── options.js
manifest.json
Chrome拡張機能の定義で一般的にはパーミッション、起動するスクリプトなどを記載します。
今回は、定義情報や使用するHTMLを指定しています。
manifest.json
{
"name": "Gemini OCR Extension",
"description": "PCカメラで撮影し、Gemini 2.0 Flash APIでOCRを行うChrome拡張",
"version": "1.0.0",
"manifest_version": 3,
"permissions": [
"storage"
],
"action": {
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
popup.html / popup.js
拡張ポップアップ。ここでカメラ撮影UIやOCRリクエスト呼び出しを行います。
popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>領収書OCR</title>
<style>
/* ポップアップ自体の幅をある程度固定しておく */
body {
width: 360px; /* お好みで変更してください */
margin: 12px;
font-family: sans-serif;
}
h1, h2 {
margin: 0.5em 0;
}
button {
margin: 4px 0;
padding: 6px 12px;
}
/* 動画/画像の枠のスタイル */
#video, #capturedImage {
display: block;
width: 100%;
max-height: 240px;
object-fit: cover;
border: 1px solid #ccc;
margin-bottom: 8px;
background: #eee;
}
/* テキストエリアの横幅を合わせる */
#ocrResult {
width: 100%;
box-sizing: border-box;
height: 100px;
}
.section {
margin-bottom: 16px;
}
/* ボタングループのスタイル */
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 撮り直しボタンの強調スタイル */
#resetBtn {
background-color: #f5f5f5;
border: 1px solid #ddd;
}
</style>
</head>
<body>
<h1>領収書 OCR</h1>
<!-- カメラ映像/撮影画像エリア -->
<div class="section">
<video id="video" autoplay></video>
<img id="capturedImage" alt="撮影画像プレビュー" style="display:none;" />
<!-- 追加: Canvas要素をDOMに最初から配置 -->
<canvas id="canvas" style="display:none;"></canvas>
</div>
<!-- ボタン群 - クラス追加とレイアウト調整 -->
<div class="section button-group">
<button id="captureBtn">撮影</button>
<!-- 撮り直しボタン -->
<button id="resetBtn" style="display:none;">撮り直し</button>
<button id="ocrBtn" disabled>OCR解析</button>
<button id="downloadImgBtn" disabled>画像を保存</button>
</div>
<!-- OCR結果表示 -->
<div class="section">
<h2>OCR結果</h2>
<textarea id="ocrResult" placeholder="ここにCSVが表示されます"></textarea><br />
<button id="downloadTxtBtn" disabled>CSVを保存</button>
</div>
<script src="popup.js"></script>
</body>
</html>
popup.js
// popup.js
let currentStream = null;
// 要素取得
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const captureBtn = document.getElementById('captureBtn');
const ocrBtn = document.getElementById('ocrBtn');
const downloadImgBtn = document.getElementById('downloadImgBtn');
const downloadTxtBtn = document.getElementById('downloadTxtBtn');
const capturedImage = document.getElementById('capturedImage');
const ocrResult = document.getElementById('ocrResult');
// 撮り直しボタン取得
const resetBtn = document.getElementById('resetBtn');
// カメラ起動関数(再利用するため関数化)
function startCamera() {
// 既存のストリームがあれば停止
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
}
// カメラを起動
return navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
currentStream = stream;
video.srcObject = stream;
video.style.display = 'block';
// UI状態をリセット
capturedImage.style.display = 'none';
resetBtn.style.display = 'none';
ocrBtn.disabled = true;
downloadImgBtn.disabled = true;
return stream;
})
.catch(err => {
console.error('Camera access error:', err);
alert('カメラへのアクセスに失敗しました: ' + err.message);
});
}
// 1. 初期カメラ起動
startCamera();
// 2. 撮影(キャプチャ) → Canvasに描画 → 動画を非表示 + 画像プレビューを表示
captureBtn.addEventListener('click', () => {
if (!video.srcObject) {
alert('カメラが起動していません。');
return;
}
// videoのサイズを取得しcanvasへ描画
const width = video.videoWidth;
const height = video.videoHeight;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, width, height);
// 撮影した画像をDataURLに変換してimgタグに表示
const dataUrl = canvas.toDataURL('image/png');
capturedImage.src = dataUrl;
// カメラ映像を停止&非表示にする
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
currentStream = null;
}
video.style.display = 'none';
video.srcObject = null;
// 代わりに撮影画像表示
capturedImage.style.display = 'block';
// その他ボタン活性化
ocrBtn.disabled = false;
downloadImgBtn.disabled = false;
// 追加: 撮り直しボタン表示
resetBtn.style.display = 'block';
});
// 3. OCRボタン押下時に画像を送信 → 結果を取得
ocrBtn.addEventListener('click', async () => {
if (!canvas) {
alert('画像が撮影されていません。');
return;
}
try {
// OCR処理中の表示
ocrBtn.disabled = true;
ocrBtn.textContent = '処理中...';
// CanvasからBase64を取得
const dataUrl = canvas.toDataURL('image/png');
const base64Data = dataUrl.split(',')[1]; // 先頭 "data:image/png;base64," を取り除く
// APIキーをストレージから取得
const { geminiApiKey } = await chrome.storage.local.get('geminiApiKey');
if (!geminiApiKey) {
alert('先にオプションページでAPIキーを設定してください。');
return;
}
// ▼ ここでプロンプトを定義
const promptText = `
あなたは優秀な経理担当者です。受け取った領収書を画像解析して文字や金額を起こしてください。
## 重要事項
- わからない項目がある場合は、正直に「N/A」と記入してください。
- 1枚の画像に複数の領収書が含まれている場合は、それぞれの領収書ごとに別々のCSVを作成してください。
- 各値はダブルクォーテーションで囲ってください
- 回答はCSVのみで出力してください。
- 標準的でない形式や追加情報がある場合は、各行の注記として記載してください。
## 項目の説明
- 支払先会社名
- 発行日
- 支払金額税込
- 通貨
- 登録番号
## 出力形式
以下の項目をCSV形式で出力してください。
## 出力項目(優先順位順)
1. 支払先会社名
2. 発行日
3. 支払金額税込
4. 通貨
5. 登録番号
6. 注記
## 項目の出力項目の説明
1. 支払先会社名: 支払先の会社名。宛名や請求先ではないので注意してください。
2. 発行日: 領収書を発行した日付(YYYY-MM-DD形式で出力)
3. 支払金額税込: 税込みの合計金額(カンマ区切りで記入、小数点以下2桁まで)
- 税抜き金額のみ記載の場合は、課税対象額を加算して計算してください
4. 通貨: 支払金額の通貨(例:JPY、USD、EUR)
5. 登録番号:税務署に認められた適格請求書発行事業者に発行される番号です。 すでに法人番号がある事業者の場合は「T+法人番号」が登録番号となります。 法人番号を持っていない事業者に関しては、「T+13桁の固有番号」が登録番号です。 (T0000000000000形式で出力)
6. 注記
【項目の出力例】
"支払先会社名","発行日","支払金額税込","通貨","登録番号","注記"
"株式会社テスト","2024-07-15","150000","JPY","T1234567890123","初回取引"
`.trim();
// Gemini 2.0 Flash APIのエンドポイント
// ※ APIキーをクエリパラメータに含める形にしています
const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + geminiApiKey;
// リクエストボディ (Gemini 2.0 Flash APIの仕様に合わせて修正)
const requestBody = {
contents: [
{
parts: [
{
// Prompt部に先ほどの指示文をセット
text: promptText
},
{
// 続けて画像をインラインデータとして送信
inline_data: {
mime_type: "image/png",
data: base64Data
}
}
]
}
]
};
const ocrResponse = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!ocrResponse.ok) {
// レスポンスがエラーの場合
const errorData = await ocrResponse.json().catch(() => ({}));
console.error('OCR request failed:', errorData);
throw new Error(`OCR request failed: ${ocrResponse.status}`);
}
const ocrData = await ocrResponse.json();
let ocrText = '';
if (ocrData.candidates && ocrData.candidates.length > 0) {
const firstCandidate = ocrData.candidates[0];
if (firstCandidate.content?.parts) {
// parts配列を結合(複数パートがある場合はまとめて連結)
ocrText = firstCandidate.content.parts.map(part => part.text).join('\n');
}
}
// テキストエリアに出力
ocrResult.value = ocrText;
// CSVダウンロード用ボタンを有効に
downloadTxtBtn.disabled = false;
ocrBtn.textContent = 'OCR解析';
ocrBtn.disabled = false;
} catch (error) {
console.error('OCR処理エラー:', error);
alert('OCR処理中にエラーが発生しました: ' + error.message);
ocrBtn.textContent = 'OCR解析';
ocrBtn.disabled = false;
}
});
// 4. 撮影画像の保存
downloadImgBtn.addEventListener('click', () => {
if (!canvas) {
alert('画像が撮影されていません。');
return;
}
try {
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
// タイムスタンプ付きファイル名
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-');
a.download = `receipt_${timestamp}.png`;
a.click();
} catch (error) {
console.error('画像保存エラー:', error);
alert('画像の保存中にエラーが発生しました');
}
});
// 5. OCR結果(CSV)の保存
downloadTxtBtn.addEventListener('click', () => {
const content = ocrResult.value || '';
if (!content.trim()) {
alert('保存するデータがありません。');
return;
}
try {
const blob = new Blob([content], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// タイムスタンプ付きファイル名
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-');
a.download = `ocr_result_${timestamp}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV保存エラー:', error);
alert('CSVの保存中にエラーが発生しました');
}
});
// 6. 撮り直しボタンの機能
resetBtn.addEventListener('click', () => {
// 状態をリセット
capturedImage.style.display = 'none';
ocrBtn.disabled = true;
downloadImgBtn.disabled = true;
// カメラを再起動
startCamera()
.then(() => {
console.log('カメラ再起動成功');
resetBtn.style.display = 'none';
})
.catch(err => {
console.error('カメラ再起動エラー:', err);
alert('カメラの再起動に失敗しました。ページをリロードしてください。');
});
});
// 7. ページ離脱時のクリーンアップ
window.addEventListener('beforeunload', () => {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
}
});
options.html / options.js
拡張オプション設定ページ。ここでGemini 2.0 FlashのAPIキーなどユーザー固有の設定を保存します。
options.html
<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Gemini OCR Extension - Options</title>
</head>
<body>
<h1>Gemini 2.0 Flash API Key 設定</h1>
<label for="apiKey">API Key:</label>
<input type="text" id="apiKey" />
<button id="saveKey">保存</button>
<script src="options.js"></script>
</body>
</html>
options.js
// options.js
document.getElementById('saveKey').addEventListener('click', () => {
const key = document.getElementById('apiKey').value.trim();
if (key) {
chrome.storage.local.set({ geminiApiKey: key }, () => {
alert('APIキーを保存しました');
});
}
});
使い方
ここまで実装したソースコードを準備した上で、Chrome拡張として取り込んで設定していきましょう。
Chrome拡張追加方法
Chromeを開いてURLに下記を入力して「Enter」キーを打鍵します。
chrome://extensions/
「パッケージ化されていない拡張機能を読み込む」ボタンを押下します。
先ほどのソースコードが格納されているフォルダを選択します。
すると、下記のように実装したChrome拡張が表示されます。
初期設定1: GeminiのAPIキー
「詳細」ボタンを押下します。
「拡張機能のオプション」をクリックします。
GeminiのAPIキーを入力して、「保存」ボタンを押下します。
初期設定2: カメラの許可
「詳細」ボタンを押下します。
「サイトの設定」をクリックします。
「カメラ」の「許可する」を選択します。
起動
Chrome拡張の「Gemini OCR Extension」をクリックします。
こんな感じに起動します。
使ってみる
領収書を映しながら「撮影」ボタンを押下します。
撮影できたら「OCR解析」ボタンを押下します。
数秒後にOCR結果(CSV形式)が出力されます。
OCR結果(CSV形式)や画像は保存できます。
さいごに
いかがでしたか。この記事では、Gemini 2.0 FlashとChrome拡張を使って領収書OCRの仕組みを実装してみました。生成AIを使ってこのような仕組みを意外と簡単に実装できるので、みなさんもぜひ参考にしてみてください!
Discussion
この記事コピペするだけで10分かからず動かせました。笑 ありがとうございます。

さらにちょっと機能追加して、個人利用用の給与明細OCRにしていただきましたっ
おぉ!さすがです!