🥻
領収書OCR管理を機能強化 OCR精度を気軽の確認可能
はじめに
先日投稿した下記記事に記載している領収書OCR管理を機能強化しました。
機能強化した結果のシステム全体概要
このChrome拡張機能「AI OCR Extension」は、PCのカメラを使って領収書を撮影し、AIを活用して領収書の情報をテキストデータとして抽出するツールです。
経理処理の効率化する可能性を秘めており、領収書の手動入力の手間を削減しつつ、AIの抽出結果を人間がチェック・編集できる設計になっています。精度に改善の余地があるものの、OCR結果に画像上の座標情報を含めることで、抽出データと画像の対応関係を視覚的に確認できます。
実際に使っている様子。抽出データと画像の対応関係は改善の余地あり。
主な特徴:
マルチAIモデル対応:
- 3つの最新AIモデル(Gemini、Claude、ChatGPT)に対応
- ユーザーは性能や利用状況に応じて選択可能
カメラ撮影とOCR処理:
- PCのウェブカメラを使って領収書を撮影
AIモデルによる画像認識で以下の情報を抽出:
- 支払先会社名、発行日、支払金額(税込)
- 通貨、登録番号(適格請求書発行事業者の登録番号)
- その他注記事項
結果の確認と編集:
- 抽出結果と原画像の対応箇所を視覚的に確認可能(座標付きJSONデータを活用)
- フォームフィールドを選択すると画像上の該当部分がハイライト表示
- 不正確な情報を手動で編集可能
データ保存:
- 編集済みデータをJSON形式で保存可能
- 撮影画像もPNG形式で保存可能
APIキー管理:
- 各AIサービスのAPIキーを安全に管理
- セキュリティのためのマスキング機能
処理フロー:
- 拡張機能起動 → カメラ画面表示 → AIモデル選択 → 撮影 → OCR処理
- 結果確認・編集画面 → データ編集 → JSON/画像保存 または 撮り直し
ソースコード
manifest.json
- Chrome拡張機能の設定ファイル
- 名前「AI OCR Extension」:PCカメラで撮影し、Gemini/Claude/ChatGPTのAPIでOCRを行うChrome拡張
- 権限:storage(データ保存)、tabs(タブ操作)
- バックグラウンド処理:service-worker.js
- 設定画面:options.html
manifest.json
manifest.json
{
"name": "AI OCR Extension",
"description": "PCカメラで撮影し、Gemini/Claude/ChatGPT APIでOCRを行うChrome拡張",
"version": "1.0.0",
"manifest_version": 3,
"permissions": [
"storage",
"tabs"
],
"action": {
"default_title": "AI OCR Extension"
},
"background": {
"service_worker": "service-worker.js"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
service-worker.js
- 拡張機能のアイコンがクリックされたときに新しいタブでpopup.htmlを開く簡単なスクリプト
service-worker.js
service-worker.js
// service-worker.js
chrome.action.onClicked.addListener(() => {
chrome.tabs.create({
url: 'popup.html'
});
});
popup.html / popup.js
- メインの機能画面:カメラ起動・撮影・OCR処理
- 3つのAIモデル(Gemini 2.0 Flash、Claude 3.7 Sonnet、ChatGPT-4o)から選択可能
- カメラ操作(起動、撮影、撮り直し)機能
- 選択したAIモデルに対応するAPI呼び出しでOCR処理を実行
- AIに領収書情報を特定のJSON形式で抽出させるための詳細なプロンプト定義
- OCR処理結果を保存してcomparison.htmlに遷移
popup.htmlを表示している様子
popup.html
popup.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>領収書OCR</title>
<style>
body {
font-family: sans-serif;
margin: 20px;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1, h2 {
margin: 0.5em 0;
}
button {
margin: 8px 8px 8px 0;
padding: 8px 16px;
cursor: pointer;
}
/* 動画/画像の枠のスタイル */
#video, #capturedImage {
display: block;
width: 100%;
max-height: 480px;
object-fit: cover;
border: 1px solid #ccc;
margin-bottom: 15px;
background: #eee;
}
.section {
margin-bottom: 20px;
}
/* ボタングループのスタイル */
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* 撮り直しボタンの強調スタイル */
#resetBtn {
background-color: #f5f5f5;
border: 1px solid #ddd;
}
/* コンテナーレイアウト */
.container {
max-width: 800px;
margin: 0 auto;
}
/* AIモデル選択 */
.model-selection {
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.model-selection label {
font-weight: bold;
}
.model-selection select {
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
}
/* レスポンシブ対応 */
@media (max-width: 600px) {
body {
padding: 10px;
}
#video, #capturedImage {
max-height: 360px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>領収書 OCR</h1>
<!-- AIモデル選択 -->
<div class="model-selection">
<label for="aiModel">AIモデル:</label>
<select id="aiModel">
<option value="gemini">Gemini 2.0 Flash</option>
<option value="claude">Claude 3.7 Sonnet</option>
<option value="chatgpt">ChatGPT-4o</option>
</select>
</div>
<!-- カメラ映像/撮影画像エリア -->
<div class="section">
<video id="video" autoplay></video>
<img id="capturedImage" alt="撮影画像プレビュー" style="display:none;" />
<!-- Canvas要素 -->
<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>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
popup.js
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 capturedImage = document.getElementById('capturedImage');
// 撮り直しボタン取得
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;
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;
// 追加: 撮り直しボタン表示
resetBtn.style.display = 'block';
});
// AIモデル選択の参照を取得
const aiModelSelect = document.getElementById('aiModel');
// 選択されたAIモデルを保存する
aiModelSelect.addEventListener('change', () => {
chrome.storage.local.set({ selectedAiModel: aiModelSelect.value });
});
// 保存されたAIモデルの選択を復元する
chrome.storage.local.get('selectedAiModel', (data) => {
if (data.selectedAiModel) {
aiModelSelect.value = data.selectedAiModel;
}
});
// プロンプトテキスト定義(どのモデルでも共通)
const getPromptText = () => {
return `
あなたは優秀な経理担当者です。受け取った領収書を画像解析して文字や金額を起こしてください。
## 重要事項
- わからない項目がある場合は、正直に「N/A」と記入してください。
- 1枚の画像に複数の領収書が含まれている場合は、それぞれの領収書ごとに別々のJSONを作成してください。
- 回答はJSONのみで出力してください。
- 標準的でない形式や追加情報がある場合は、各行の注記として記載してください。
- テキスト出力した根拠となる画像の場所について、クロップできるように、それぞれ座標(x,y)と幅、高さも教えてください。単位はpxでお願いします。
## 項目の説明
- 支払先会社名
- 発行日
- 支払金額税込
- 通貨
- 登録番号
## 出力形式
以下の項目をJSON形式で出力してください。
## 出力項目(優先順位順)
1. 支払先会社名
2. 発行日
3. 支払金額税込
4. 通貨
5. 登録番号
6. 注記
## JSONの定義
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "InvoiceFields",
"type": "object",
"properties": {
"imageWidthPx": {
"type": "integer",
"description": "撮影した画像の幅(px)"
},
"imageHeightPx": {
"type": "integer",
"description": "撮影した画像の高さ(px)"
},
"payeeName": {
"type": "object",
"title": "支払先会社名",
"description": "支払先の会社名。宛名や請求先ではなく、実際に支払う先の会社名を示します。",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(支払先会社名)"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
},
"issueDate": {
"type": "object",
"title": "発行日",
"description": "領収書を発行した日付(YYYY-MM-DD形式)",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(発行日)",
"pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|1\\d|2\\d|3[01])$",
"example": "2025-03-15"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
},
"amountIncludingTax": {
"type": "object",
"title": "支払金額税込",
"description": "税込み合計金額(カンマ区切り、小数点以下2桁まで)。税抜き金額しかない場合は、税額を加算して税込みにしてください。",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(支払金額税込)",
"pattern": "^\\d{1,3}(,\\d{3})*(\\.\\d{2})?$",
"example": "12,345.67"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
},
"currency": {
"type": "object",
"title": "通貨",
"description": "支払金額の通貨。例:JPY、USD、EUR",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(通貨)",
"pattern": "^[A-Z]{3}$",
"example": "JPY"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
},
"registrationNumber": {
"type": "object",
"title": "登録番号",
"description": "適格請求書発行事業者の登録番号。法人番号がある場合は「T+法人番号」、ない場合は「T+13桁の固有番号」(例:T0000000000000)。",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(登録番号)",
"pattern": "^T\\d{13}$",
"example": "T1234567890123"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
},
"notes": {
"type": "object",
"title": "注記",
"description": "領収書や支払に関して補足や特記事項があれば記入します。",
"properties": {
"value": {
"type": "string",
"description": "実際の文字列値(注記)"
},
"x": {
"type": "number",
"description": "座標X"
},
"y": {
"type": "number",
"description": "座標Y"
},
"width": {
"type": "number",
"description": "幅"
},
"height": {
"type": "number",
"description": "高さ"
}
},
"required": ["value", "x", "y", "width", "height"]
}
},
"required": [
"imageWidthPx",
"imageHeightPx",
"payeeName",
"issueDate",
"amountIncludingTax",
"currency",
"registrationNumber",
"notes"
]
}
`.trim();
};
// Gemini APIを使用してOCRを実行する関数
async function processWithGemini(base64Data, apiKey) {
const promptText = getPromptText();
// Gemini 2.0 Flash APIのエンドポイント
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
// リクエストボディ
const requestBody = {
contents: [
{
parts: [
{
text: promptText
},
{
inline_data: {
mime_type: "image/png",
data: base64Data
}
}
]
}
]
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Gemini OCR request failed:', errorData);
throw new Error(`Gemini OCR request failed: ${response.status}`);
}
const ocrData = await response.json();
let ocrText = '';
if (ocrData.candidates && ocrData.candidates.length > 0) {
const firstCandidate = ocrData.candidates[0];
if (firstCandidate.content?.parts) {
ocrText = firstCandidate.content.parts.map(part => part.text).join('\n');
}
}
return ocrText;
}
// Claude APIを使用してOCRを実行する関数
async function processWithClaude(base64Data, apiKey) {
const promptText = getPromptText();
// Claude APIのエンドポイント
const apiUrl = 'https://api.anthropic.com/v1/messages';
// リクエストボディ
const requestBody = {
// model: "claude-3-5-sonnet-20241022",
model: "claude-3-7-sonnet-20250219",
max_tokens: 1024,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png", // PNGとして送信する
data: base64Data
}
},
{
type: "text",
text: promptText
}
]
}
]
};
// ヘッダー準備(順序とフォーマットが重要)
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
'anthropic-dangerous-direct-browser-access': true
};
console.log('Claude API Request Headers:', JSON.stringify(headers));
console.log('Claude API Request Body (structure):', JSON.stringify({
model: requestBody.model,
max_tokens: requestBody.max_tokens,
messages: [{
role: "user",
content: "[image and text content]"
}]
}));
const response = await fetch(apiUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
let errorDetails;
try {
errorDetails = JSON.parse(errorText);
console.error('Claude API Error Response:', errorDetails);
} catch (e) {
console.error('Claude API Error (non-JSON):', errorText);
}
throw new Error(`Claude OCR request failed: ${response.status} - ${errorText}`);
}
const ocrData = await response.json();
// Claude APIからの応答に対応する処理
let ocrText = '';
if (ocrData.content && ocrData.content.length > 0) {
ocrText = ocrData.content
.filter(part => part.type === 'text')
.map(part => part.text)
.join('\n');
}
return ocrText;
}
// ChatGPT APIを使用してOCRを実行する関数
async function processWithChatGPT(base64Data, apiKey) {
const promptText = getPromptText();
// ChatGPT APIのエンドポイント
const apiUrl = 'https://api.openai.com/v1/chat/completions';
// リクエストボディ
const requestBody = {
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{
type: "text",
text: promptText
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64Data}`
}
}
]
}
],
max_tokens: 1024
};
console.log('ChatGPT API Request (structure):', JSON.stringify({
model: requestBody.model,
messages: [{
role: "user",
content: "[text and image content]"
}]
}));
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
let errorDetails;
try {
errorDetails = JSON.parse(errorText);
console.error('ChatGPT API Error Response:', errorDetails);
} catch (e) {
console.error('ChatGPT API Error (non-JSON):', errorText);
}
throw new Error(`ChatGPT OCR request failed: ${response.status} - ${errorText}`);
}
const ocrData = await response.json();
// ChatGPT APIからの応答に対応する処理
let ocrText = '';
if (ocrData.choices && ocrData.choices.length > 0) {
ocrText = ocrData.choices[0].message.content;
}
return ocrText;
}
// 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," を取り除く
// 選択されたAIモデルを取得
const selectedModel = aiModelSelect.value;
// モデルに応じたAPIキーをストレージから取得
const apiKeys = await chrome.storage.local.get(['geminiApiKey', 'claudeApiKey', 'openaiApiKey']);
let apiKey;
let modelName;
// APIキーの確認
switch (selectedModel) {
case 'gemini':
apiKey = apiKeys.geminiApiKey;
modelName = 'Gemini';
break;
case 'claude':
apiKey = apiKeys.claudeApiKey;
modelName = 'Claude';
break;
case 'chatgpt':
apiKey = apiKeys.openaiApiKey;
modelName = 'ChatGPT';
break;
}
// APIキーがない場合はオプションページに誘導
if (!apiKey) {
alert(`先にオプションページで${modelName}のAPIキーを設定してください。`);
chrome.runtime.openOptionsPage();
ocrBtn.textContent = 'OCR解析';
ocrBtn.disabled = false;
return;
}
// 選択されたAIサービスで処理
let ocrText;
try {
switch (selectedModel) {
case 'gemini':
ocrText = await processWithGemini(base64Data, apiKey);
break;
case 'claude':
ocrText = await processWithClaude(base64Data, apiKey);
break;
case 'chatgpt':
ocrText = await processWithChatGPT(base64Data, apiKey);
break;
}
} catch (error) {
console.error(`${modelName} OCR処理エラー:`, error);
alert(`${modelName} OCR処理中にエラーが発生しました: ${error.message}`);
ocrBtn.textContent = 'OCR解析';
ocrBtn.disabled = false;
return;
}
// 取得した画像データとOCR結果をストレージに保存
await chrome.storage.local.set({
'ocrResults': ocrText,
'capturedImageData': dataUrl,
'usedAiModel': selectedModel // 使用したAIモデルを保存
});
// 比較画面に遷移する (タブ内で遷移)
window.location.href = 'comparison.html';
} catch (error) {
console.error('OCR処理エラー:', error);
alert('OCR処理中にエラーが発生しました: ' + error.message);
ocrBtn.textContent = 'OCR解析';
ocrBtn.disabled = false;
}
});
// 4. 撮り直しボタンの機能
resetBtn.addEventListener('click', () => {
// 状態をリセット
capturedImage.style.display = 'none';
ocrBtn.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());
}
});
comparison.html / comparison.js
- OCR処理結果の表示・編集画面
- 撮影画像と抽出データ(支払先会社名、発行日、金額など)の表示
- フォームフィールドを選択すると画像上の対応箇所をハイライト表示
- 抽出データの編集機能
- 編集済みデータのJSON保存機能と画像の保存機能
- popup.htmlに戻る機能
comparison.htmlを表示している様子
comparison.html
comparison.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>OCR結果確認</title>
<style>
body {
font-family: sans-serif;
margin: 20px;
max-width: 1200px;
}
h1, h2 {
margin: 0.5em 0;
}
button {
margin: 8px 8px 8px 0;
padding: 8px 16px;
cursor: pointer;
}
.container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.image-section, .results-section {
flex: 1;
min-width: 300px;
}
.image-section {
display: flex;
flex-direction: column;
}
#originalImage {
max-width: 100%;
border: 1px solid #ccc;
margin-bottom: 10px;
}
#cropOverlay {
position: absolute;
border: 3px solid rgba(255, 0, 0, 0.8);
background-color: rgba(255, 0, 0, 0.2);
pointer-events: none;
display: none;
z-index: 10;
box-shadow: 0 0 5px rgba(255, 0, 0, 0.8);
}
.image-container {
position: relative;
margin-bottom: 20px;
overflow: visible;
}
#originalImage {
display: block;
max-width: 100%;
height: auto;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input[type="text"] {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-group textarea {
width: 100%;
height: 80px;
padding: 8px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
}
.button-group {
margin-top: 20px;
display: flex;
gap: 10px;
}
#backBtn {
background-color: #f5f5f5;
}
</style>
</head>
<body>
<h1>OCR結果確認</h1>
<div class="container">
<!-- 左側: 画像表示エリア -->
<div class="image-section">
<h2>撮影された画像</h2>
<div class="image-container">
<img id="originalImage" alt="撮影画像" />
<div id="cropOverlay"></div>
</div>
</div>
<!-- 右側: OCR結果表示エリア -->
<div class="results-section">
<h2>OCR結果</h2>
<div class="form-group">
<label for="payeeName">支払先会社名</label>
<input type="text" id="payeeName" />
</div>
<div class="form-group">
<label for="issueDate">発行日</label>
<input type="text" id="issueDate" />
</div>
<div class="form-group">
<label for="amountIncludingTax">支払金額税込</label>
<input type="text" id="amountIncludingTax" />
</div>
<div class="form-group">
<label for="currency">通貨</label>
<input type="text" id="currency" />
</div>
<div class="form-group">
<label for="registrationNumber">登録番号</label>
<input type="text" id="registrationNumber" />
</div>
<div class="form-group">
<label for="notes">注記</label>
<textarea id="notes"></textarea>
</div>
</div>
</div>
<!-- アクションボタン -->
<div class="button-group">
<button id="backBtn">戻る</button>
<button id="saveImageBtn">画像を保存</button>
<button id="saveJsonBtn">JSONを保存</button>
</div>
<!-- Debug JSON Display -->
<div class="form-group" style="margin-top: 20px;">
<h3>デバッグ用JSON</h3>
<textarea id="debugJson" style="width: 100%; height: 150px; font-family: monospace;"></textarea>
</div>
<!-- Canvas for image data processing (hidden) -->
<canvas id="hiddenCanvas" style="display:none;"></canvas>
<script src="comparison.js"></script>
</body>
</html>
comparison.js
comparison.js
// comparison.js
// DOM Elements
const originalImage = document.getElementById('originalImage');
const cropOverlay = document.getElementById('cropOverlay');
const hiddenCanvas = document.getElementById('hiddenCanvas');
// Form fields
const payeeNameInput = document.getElementById('payeeName');
const issueDateInput = document.getElementById('issueDate');
const amountInput = document.getElementById('amountIncludingTax');
const currencyInput = document.getElementById('currency');
const registrationInput = document.getElementById('registrationNumber');
const notesInput = document.getElementById('notes');
// Debug display
const debugJson = document.getElementById('debugJson');
// Buttons
const backBtn = document.getElementById('backBtn');
const saveImageBtn = document.getElementById('saveImageBtn');
const saveJsonBtn = document.getElementById('saveJsonBtn');
// Store the OCR data
let ocrData = null;
let imageScale = 1;
let originalOcrText = ''; // Store the original OCR text
let originalImageWidth = 0; // Original image width from OCR data
let originalImageHeight = 0; // Original image height from OCR data
// Function to recalculate image scaling factors
function recalculateImageScaling() {
if (!originalImage) return;
// Get current dimensions of the displayed image
const displayedWidth = originalImage.clientWidth;
const displayedHeight = originalImage.clientHeight;
const naturalWidth = originalImage.naturalWidth;
const naturalHeight = originalImage.naturalHeight;
console.log(`Image dimensions updated - Natural: ${naturalWidth}x${naturalHeight}, Displayed: ${displayedWidth}x${displayedHeight}`);
// If any overlay is currently visible, update its position by calling showCropOverlay again
if (cropOverlay.style.display === 'block') {
// Try to determine which field is currently active
const activeFields = ['payeeName', 'issueDate', 'amountIncludingTax',
'currency', 'registrationNumber', 'notes'];
// First, try to find which form field has focus
const focusedElement = document.activeElement;
let activeFieldName = null;
if (focusedElement) {
for (const fieldName of activeFields) {
if (focusedElement.id === fieldName) {
activeFieldName = fieldName;
break;
}
}
}
// If no field has focus, try to determine by checking the overlay position
if (!activeFieldName) {
for (const fieldName of activeFields) {
if (ocrData && ocrData[fieldName] &&
ocrData[fieldName].width > 0 && ocrData[fieldName].height > 0) {
showCropOverlay(fieldName);
return; // Only update one field
}
}
} else {
// Update the active field's overlay
showCropOverlay(activeFieldName);
}
}
}
// Initialize the page
document.addEventListener('DOMContentLoaded', () => {
// Get data passed from popup
chrome.storage.local.get(['ocrResults', 'capturedImageData', 'usedAiModel'], (data) => {
if (data.capturedImageData) {
originalImage.src = data.capturedImageData;
// Wait for image to load to set up scaling
originalImage.onload = () => {
// Calculate image scaling factors (actual displayed size vs original size)
recalculateImageScaling();
// Now that we have the scaling factors, we can display any highlights
setupFormHighlighting();
// Add resize event listener to handle window resizing
window.addEventListener('resize', () => {
recalculateImageScaling();
});
};
} else {
console.error('No image data found');
}
// 使用したAIモデルの情報を表示に追加
if (data.usedAiModel) {
let modelName;
switch (data.usedAiModel) {
case 'gemini':
modelName = 'Gemini 2.0 Flash';
break;
case 'claude':
modelName = 'Claude 3.7 Sonnet';
break;
case 'chatgpt':
modelName = 'ChatGPT-4o';
break;
default:
modelName = data.usedAiModel;
}
// タイトルにモデル名を追加
const titleElement = document.querySelector('h1');
if (titleElement) {
titleElement.textContent = `OCR結果確認 (${modelName})`;
}
}
if (data.ocrResults) {
try {
// Store original OCR text for debugging
originalOcrText = data.ocrResults;
// Display raw JSON in debug textarea
debugJson.value = originalOcrText;
// Try to parse as JSON
try {
ocrData = JSON.parse(data.ocrResults);
} catch (e) {
// If it's not JSON directly, look for JSON in the string
// This handles the case where API might return extra text around the JSON
const jsonMatch = data.ocrResults.match(/\{[\s\S]*\}/);
if (jsonMatch) {
ocrData = JSON.parse(jsonMatch[0]);
} else {
throw new Error('No valid JSON found in the response');
}
}
// Extract image dimensions from OCR data if available
if (ocrData.imageWidthPx && ocrData.imageHeightPx) {
originalImageWidth = ocrData.imageWidthPx;
originalImageHeight = ocrData.imageHeightPx;
console.log(`Original image dimensions from OCR: ${originalImageWidth}x${originalImageHeight}`);
}
populateFormFields(ocrData);
} catch (error) {
console.error('Failed to parse OCR results:', error);
// Display error message to user
alert('OCR結果の解析に失敗しました。正しいJSON形式でない可能性があります。');
}
} else {
console.error('No OCR results found');
}
});
});
// Populate form fields with OCR data
function populateFormFields(data) {
if (!data) return;
if (data.payeeName) {
payeeNameInput.value = data.payeeName.value || '';
}
if (data.issueDate) {
issueDateInput.value = data.issueDate.value || '';
}
if (data.amountIncludingTax) {
amountInput.value = data.amountIncludingTax.value || '';
}
if (data.currency) {
currencyInput.value = data.currency.value || '';
}
if (data.registrationNumber) {
registrationInput.value = data.registrationNumber.value || '';
}
if (data.notes) {
notesInput.value = data.notes.value || '';
}
}
// Set up form field events to show crop highlights
function setupFormHighlighting() {
const formFieldMap = {
'payeeName': payeeNameInput,
'issueDate': issueDateInput,
'amountIncludingTax': amountInput,
'currency': currencyInput,
'registrationNumber': registrationInput,
'notes': notesInput
};
// Add event listeners to all form fields
for (const [key, element] of Object.entries(formFieldMap)) {
element.addEventListener('focus', () => {
showCropOverlay(key);
});
element.addEventListener('blur', () => {
hideCropOverlay();
});
}
}
// Show crop overlay for a specific field
function showCropOverlay(fieldName) {
if (!ocrData || !ocrData[fieldName]) return;
// Skip fields with zero dimensions (like N/A fields)
const field = ocrData[fieldName];
if (field.width === 0 || field.height === 0) {
console.log(`Skipping overlay for ${fieldName} - has zero dimensions`);
return;
}
// Get the image dimensions
const displayedWidth = originalImage.clientWidth;
const displayedHeight = originalImage.clientHeight;
console.log(`Displayed image dimensions: ${displayedWidth}x${displayedHeight}`);
console.log(`OCR dimensions: ${originalImageWidth}x${originalImageHeight}`);
// ===== SIMPLIFIED RELATIVE POSITIONING APPROACH =====
// Calculate the scaling ratio between OCR dimensions and displayed dimensions
let scaleX, scaleY;
if (originalImageWidth > 0 && originalImageHeight > 0) {
// Calculate the aspect ratios
const ocrAspectRatio = originalImageWidth / originalImageHeight;
const displayedAspectRatio = displayedWidth / displayedHeight;
// Determine how scaling should be applied based on which dimension constrains the image
if (Math.abs(ocrAspectRatio - displayedAspectRatio) < 0.01) {
// Aspect ratios are virtually the same - simple scaling
scaleX = displayedWidth / originalImageWidth;
scaleY = displayedHeight / originalImageHeight;
} else if (ocrAspectRatio > displayedAspectRatio) {
// Width is the constraining dimension
scaleX = displayedWidth / originalImageWidth;
scaleY = scaleX; // Preserve aspect ratio
} else {
// Height is the constraining dimension
scaleY = displayedHeight / originalImageHeight;
scaleX = scaleY; // Preserve aspect ratio
}
} else {
// Fallback if OCR dimensions aren't available
const naturalWidth = originalImage.naturalWidth;
const naturalHeight = originalImage.naturalHeight;
scaleX = displayedWidth / naturalWidth;
scaleY = displayedHeight / naturalHeight;
}
// Calculate the positions - scaled directly from OCR coordinates
let x = Math.round(field.x * scaleX);
let y = Math.round(field.y * scaleY);
let width = Math.round(field.width * scaleX);
let height = Math.round(field.height * scaleY);
// Ensure minimum size for visibility
if (width < 10) width = 10;
if (height < 10) height = 10;
// Position overlay directly using container-relative coordinates
// No need for getBoundingClientRect or offsets - the container is the reference
cropOverlay.style.left = `${x}px`;
cropOverlay.style.top = `${y}px`;
cropOverlay.style.width = `${width}px`;
cropOverlay.style.height = `${height}px`;
cropOverlay.style.display = 'block';
// Debug logging
console.log(`Field: ${fieldName}`);
console.log(`OCR field data: x:${field.x}, y:${field.y}, w:${field.width}, h:${field.height}`);
console.log(`Scaling factors: scaleX:${scaleX.toFixed(4)}, scaleY:${scaleY.toFixed(4)}`);
console.log(`Final overlay position: left:${x}px, top:${y}px, width:${width}px, height:${height}px`);
}
// Hide crop overlay
function hideCropOverlay() {
cropOverlay.style.display = 'none';
}
// Update OCR data with form values
function updateOcrData() {
if (!ocrData) {
ocrData = {};
}
// Preserve original image dimensions if they exist
if (originalImageWidth > 0 && originalImageHeight > 0) {
ocrData.imageWidthPx = originalImageWidth;
ocrData.imageHeightPx = originalImageHeight;
}
// Only update values, not coordinates
if (ocrData.payeeName) {
ocrData.payeeName.value = payeeNameInput.value;
} else if (payeeNameInput.value) {
ocrData.payeeName = { value: payeeNameInput.value };
}
if (ocrData.issueDate) {
ocrData.issueDate.value = issueDateInput.value;
} else if (issueDateInput.value) {
ocrData.issueDate = { value: issueDateInput.value };
}
if (ocrData.amountIncludingTax) {
ocrData.amountIncludingTax.value = amountInput.value;
} else if (amountInput.value) {
ocrData.amountIncludingTax = { value: amountInput.value };
}
if (ocrData.currency) {
ocrData.currency.value = currencyInput.value;
} else if (currencyInput.value) {
ocrData.currency = { value: currencyInput.value };
}
if (ocrData.registrationNumber) {
ocrData.registrationNumber.value = registrationInput.value;
} else if (registrationInput.value) {
ocrData.registrationNumber = { value: registrationInput.value };
}
if (ocrData.notes) {
ocrData.notes.value = notesInput.value;
} else if (notesInput.value) {
ocrData.notes = { value: notesInput.value };
}
return ocrData;
}
// Create formatted JSON output
function createFormattedJSON() {
const updatedData = updateOcrData();
// Create a simplified JSON structure with just the values
const simplifiedData = {
imageWidthPx: updatedData.imageWidthPx,
imageHeightPx: updatedData.imageHeightPx,
payeeName: updatedData.payeeName?.value || '',
issueDate: updatedData.issueDate?.value || '',
amountIncludingTax: updatedData.amountIncludingTax?.value || '',
currency: updatedData.currency?.value || '',
registrationNumber: updatedData.registrationNumber?.value || '',
notes: updatedData.notes?.value || ''
};
// Return formatted JSON string
return JSON.stringify(simplifiedData, null, 2);
}
// Save image button handler
saveImageBtn.addEventListener('click', () => {
if (!originalImage.src) {
alert('画像が読み込まれていません。');
return;
}
try {
// Create a temporary canvas to get the image data
const ctx = hiddenCanvas.getContext('2d');
hiddenCanvas.width = originalImage.naturalWidth;
hiddenCanvas.height = originalImage.naturalHeight;
ctx.drawImage(originalImage, 0, 0);
const dataUrl = hiddenCanvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
// Create filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-');
a.download = `receipt_${timestamp}.png`;
a.click();
} catch (error) {
console.error('画像保存エラー:', error);
alert('画像の保存中にエラーが発生しました');
}
});
// Save JSON button handler
saveJsonBtn.addEventListener('click', () => {
try {
const jsonContent = createFormattedJSON();
const blob = new Blob([jsonContent], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Create filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-');
a.download = `ocr_result_${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('JSON保存エラー:', error);
alert('JSONの保存中にエラーが発生しました');
}
});
// Back button handler
backBtn.addEventListener('click', () => {
// Save any edited data before going back
const updatedData = updateOcrData();
chrome.storage.local.set({ 'ocrResults': JSON.stringify(updatedData) });
// Navigate back to the camera screen (popup.html)
window.location.href = 'popup.html';
});
options.html / options.js
- APIキー設定画面
- 3つのAIサービス(Google Gemini、Anthropic Claude、OpenAI ChatGPT)のAPIキー設定
- APIキーの表示/非表示切り替え機能とマスキング機能
- キー設定の保存機能
options.htmlを表示している様子
options.html
options.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI OCR Extension - Options</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin: 0 0 20px 0;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
button {
background-color: #4285f4;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #3367d6;
}
.info {
margin-top: 20px;
padding: 15px;
background-color: #e8f0fe;
border-left: 4px solid #4285f4;
border-radius: 4px;
}
.api-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ddd;
}
.api-section:last-child {
border-bottom: none;
}
@media (max-width: 600px) {
.container {
padding: 15px;
}
}
/* 追加スタイル: APIキー表示切替ボタン関連 */
.input-with-toggle {
display: flex;
align-items: center;
width: 100%;
}
.input-with-toggle input {
flex: 1;
margin-right: 8px;
}
.toggle-visibility {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
}
.toggle-visibility:hover {
background-color: #e3e3e3;
}
</style>
</head>
<body>
<div class="container">
<h1>AI OCR APIキー設定</h1>
<div class="api-section">
<h2>Google Gemini</h2>
<div class="form-group">
<label for="geminiApiKey">API Key:</label>
<div class="input-with-toggle">
<input type="password" id="geminiApiKey" placeholder="Google Gemini API Keyを入力してください" />
<button type="button" class="toggle-visibility" data-for="geminiApiKey">表示</button>
</div>
</div>
<div class="info">
<p>Gemini 2.0 Flash API Keyは<a href="https://aistudio.google.com/" target="_blank">Google AI Studio</a>から取得できます。</p>
</div>
</div>
<div class="api-section">
<h2>Anthropic Claude</h2>
<div class="form-group">
<label for="claudeApiKey">API Key:</label>
<div class="input-with-toggle">
<input type="password" id="claudeApiKey" placeholder="Anthropic Claude API Keyを入力してください" />
<button type="button" class="toggle-visibility" data-for="claudeApiKey">表示</button>
</div>
</div>
<div class="info">
<p>Claude API Keyは<a href="https://console.anthropic.com/" target="_blank">Anthropic Console</a>から取得できます。</p>
</div>
</div>
<div class="api-section">
<h2>OpenAI ChatGPT</h2>
<div class="form-group">
<label for="openaiApiKey">API Key:</label>
<div class="input-with-toggle">
<input type="password" id="openaiApiKey" placeholder="OpenAI ChatGPT API Keyを入力してください" />
<button type="button" class="toggle-visibility" data-for="openaiApiKey">表示</button>
</div>
</div>
<div class="info">
<p>OpenAI API Keyは<a href="https://platform.openai.com/api-keys" target="_blank">OpenAI Platform</a>から取得できます。</p>
</div>
</div>
<button id="saveKey">保存</button>
<div class="info">
<p>入力したAPIキーはこの拡張機能内でのみ使用され、OCR処理のために必要です。</p>
</div>
</div>
<script src="options.js"></script>
</body>
</html>
options.js
options.js
// options.js
document.addEventListener('DOMContentLoaded', () => {
// 要素への参照
const saveButton = document.getElementById('saveKey');
const geminiApiKeyInput = document.getElementById('geminiApiKey');
const claudeApiKeyInput = document.getElementById('claudeApiKey');
const openaiApiKeyInput = document.getElementById('openaiApiKey');
const toggleButtons = document.querySelectorAll('.toggle-visibility');
// マスク表示用のAPIキー隠蔽関数
function maskApiKey(key) {
if (!key) return '';
// 最初と最後の4文字を表示し、間は*で隠す(キーが8文字以下の場合はすべて*)
if (key.length <= 8) {
return '*'.repeat(key.length);
}
return key.substring(0, 4) + '*'.repeat(key.length - 8) + key.substring(key.length - 4);
}
// 実際のAPIキー値を保持するオブジェクト
const actualKeys = {
geminiApiKey: '',
claudeApiKey: '',
openaiApiKey: ''
};
// 既存のAPIキーがあれば読み込む
chrome.storage.local.get(['geminiApiKey', 'claudeApiKey', 'openaiApiKey'], (data) => {
// 実際のキー値を保存
if (data.geminiApiKey) {
actualKeys.geminiApiKey = data.geminiApiKey;
geminiApiKeyInput.value = maskApiKey(data.geminiApiKey);
geminiApiKeyInput.dataset.masked = 'true';
}
if (data.claudeApiKey) {
actualKeys.claudeApiKey = data.claudeApiKey;
claudeApiKeyInput.value = maskApiKey(data.claudeApiKey);
claudeApiKeyInput.dataset.masked = 'true';
}
if (data.openaiApiKey) {
actualKeys.openaiApiKey = data.openaiApiKey;
openaiApiKeyInput.value = maskApiKey(data.openaiApiKey);
openaiApiKeyInput.dataset.masked = 'true';
}
});
// 表示/非表示切替ボタンの処理
toggleButtons.forEach(button => {
const targetId = button.dataset.for;
const targetInput = document.getElementById(targetId);
button.addEventListener('click', () => {
const isMasked = targetInput.type === 'password';
if (isMasked) {
// マスクを解除して表示
targetInput.type = 'text';
button.textContent = '隠す';
// 表示するとき、マスク状態だった場合は実際の値を表示
if (targetInput.dataset.masked === 'true') {
targetInput.value = actualKeys[targetId];
targetInput.dataset.masked = 'false';
}
} else {
// マスクをかけて非表示
targetInput.type = 'password';
button.textContent = '表示';
// 値が変更されている場合は、マスクをかけない(ユーザーが編集中の状態)
if (targetInput.value !== actualKeys[targetId]) {
targetInput.dataset.masked = 'false';
} else {
// 値が元のままなら、マスク表示に戻す
targetInput.value = maskApiKey(actualKeys[targetId]);
targetInput.dataset.masked = 'true';
}
}
});
});
// 入力フィールドのフォーカス時の処理
const handleInputFocus = (input, keyName) => {
// マスク状態でフォーカスを得たら、実際の値を表示
if (input.dataset.masked === 'true') {
input.type = 'text';
input.value = actualKeys[keyName];
input.dataset.masked = 'false';
// 対応するボタンのテキストを更新
const button = document.querySelector(`.toggle-visibility[data-for="${keyName}"]`);
if (button) {
button.textContent = '隠す';
}
}
};
// 入力フィールドのフォーカスアウト時の処理
const handleInputBlur = (input, keyName) => {
// 値が変更されていなければ、マスク表示に戻す
if (input.value === actualKeys[keyName]) {
input.type = 'password';
input.value = maskApiKey(actualKeys[keyName]);
input.dataset.masked = 'true';
// 対応するボタンのテキストを更新
const button = document.querySelector(`.toggle-visibility[data-for="${keyName}"]`);
if (button) {
button.textContent = '表示';
}
}
};
// 各入力フィールドにフォーカスイベントを設定
geminiApiKeyInput.addEventListener('focus', () => handleInputFocus(geminiApiKeyInput, 'geminiApiKey'));
claudeApiKeyInput.addEventListener('focus', () => handleInputFocus(claudeApiKeyInput, 'claudeApiKey'));
openaiApiKeyInput.addEventListener('focus', () => handleInputFocus(openaiApiKeyInput, 'openaiApiKey'));
// 各入力フィールドにブラーイベントを設定
geminiApiKeyInput.addEventListener('blur', () => handleInputBlur(geminiApiKeyInput, 'geminiApiKey'));
claudeApiKeyInput.addEventListener('blur', () => handleInputBlur(claudeApiKeyInput, 'claudeApiKey'));
openaiApiKeyInput.addEventListener('blur', () => handleInputBlur(openaiApiKeyInput, 'openaiApiKey'));
// 保存ボタンのイベントリスナー
saveButton.addEventListener('click', () => {
// 実際の値を取得(マスク表示になっている場合は実際の値を使用)
const geminiKey = geminiApiKeyInput.dataset.masked === 'true' ?
actualKeys.geminiApiKey :
geminiApiKeyInput.value.trim();
const claudeKey = claudeApiKeyInput.dataset.masked === 'true' ?
actualKeys.claudeApiKey :
claudeApiKeyInput.value.trim();
const openaiKey = openaiApiKeyInput.dataset.masked === 'true' ?
actualKeys.openaiApiKey :
openaiApiKeyInput.value.trim();
if (!geminiKey && !claudeKey && !openaiKey) {
alert('少なくとも1つのAPIキーを入力してください');
return;
}
// 一時的に保存中の状態を表示
const originalText = saveButton.textContent;
saveButton.textContent = '保存中...';
saveButton.disabled = true;
// 全APIキーをストレージに保存
chrome.storage.local.set({
geminiApiKey: geminiKey,
claudeApiKey: claudeKey,
openaiApiKey: openaiKey
}, () => {
// 実際のキー値を更新
actualKeys.geminiApiKey = geminiKey;
actualKeys.claudeApiKey = claudeKey;
actualKeys.openaiApiKey = openaiKey;
// 入力フィールドをマスク表示に戻す
geminiApiKeyInput.type = 'password';
geminiApiKeyInput.value = maskApiKey(geminiKey);
geminiApiKeyInput.dataset.masked = 'true';
claudeApiKeyInput.type = 'password';
claudeApiKeyInput.value = maskApiKey(claudeKey);
claudeApiKeyInput.dataset.masked = 'true';
openaiApiKeyInput.type = 'password';
openaiApiKeyInput.value = maskApiKey(openaiKey);
openaiApiKeyInput.dataset.masked = 'true';
// ボタンテキストを元に戻す
toggleButtons.forEach(button => {
button.textContent = '表示';
});
// 保存成功のフィードバック
saveButton.textContent = '✓ 保存しました';
// 元のテキストに戻す
setTimeout(() => {
saveButton.textContent = originalText;
saveButton.disabled = false;
}, 2000);
});
});
// 各入力フィールドでEnterキーを押したときに保存を実行
const inputFields = [geminiApiKeyInput, claudeApiKeyInput, openaiApiKeyInput];
inputFields.forEach(input => {
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
saveButton.click();
}
});
});
});
おわりに
いかがでしたか。このツールを応用すると今後新しい生成AIモデルが公開されたときも比較的素早くOCRの検証ができます。また、抽出データと画像の対応関係は、生成AIモデルの精度や恐らくChrome拡張の実装で改善の余地がありそうで、今後に期待かなと考えています。
Discussion