This commit is contained in:
Julian Freeman
2026-03-11 12:11:38 -04:00
commit 5bae19f80a
8 changed files with 442 additions and 0 deletions

3
background.js Normal file
View File

@@ -0,0 +1,3 @@
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error) => console.error(error));

15
content.js Normal file
View File

@@ -0,0 +1,15 @@
(() => {
// Simple extraction of visible text
const text = document.body.innerText;
const url = window.location.href;
const title = document.title;
// Basic cleanup: remove excessive whitespace
const cleanText = text.replace(/\s\s+/g, ' ').substring(0, 10000); // Limit to 10k chars for token savings
return {
text: cleanText,
url,
title
};
})();

44
google-script.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Google Apps Script to handle POST requests from the Chrome Extension
* 1. Create a new Google Sheet.
* 2. Extensions > Apps Script.
* 3. Paste this code.
* 4. Deploy > New Deployment > Web App.
* 5. Set 'Execute as: Me' and 'Who has access: Anyone'.
* 6. Copy the Web App URL and paste it into the Extension.
*/
function doPost(e) {
try {
var data = JSON.parse(e.postData.contents);
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
// Get headers
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
if (headers[0] === "" && sheet.getLastColumn() === 1) {
// New sheet, set headers from JSON keys
headers = Object.keys(data);
sheet.appendRow(headers);
}
// Map data to headers
var row = headers.map(function(header) {
return data[header] || "N/A";
});
sheet.appendRow(row);
return ContentService.createTextOutput(JSON.stringify({ status: "success" }))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
return ContentService.createTextOutput(JSON.stringify({ status: "error", message: error.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// Handle preflight (for some environments, though 'no-cors' usually avoids this)
function doOptions(e) {
return ContentService.createTextOutput("")
.setMimeType(ContentService.MimeType.TEXT);
}

25
manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "AI Hardware Sourcing Assistant",
"version": "1.0",
"description": "Extract structured product data using Gemini AI.",
"permissions": [
"sidePanel",
"scripting",
"storage",
"activeTab"
],
"host_permissions": [
"https://generativelanguage.googleapis.com/*",
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"action": {
"default_title": "Open Side Panel"
}
}

61
prd.md Normal file
View File

@@ -0,0 +1,61 @@
# PRD: AI-Powered Hardware Sourcing Assistant (Chrome Extension)
## 1. Role & Goal
You are an expert Chrome Extension Developer and AI Integration Specialist. The goal is to build a browser extension that allows a user to extract structured electronic product data (Laptops, Mouse, Keyboards, etc.) from any e-commerce website (especially non-standard local sites in Central America) into a Google Sheet using the Gemini API for semantic analysis.
## 2. Target Workflow
1. User browses a product page (e.g., a laptop on a Honduran retail site).
2. User opens the **Chrome Side Panel**.
3. User clicks a category button (e.g., "Extract Laptop") or types a custom command.
4. The extension captures the page text -> Sends it to **Gemini API**.
5. Gemini parses the unstructured Spanish/English text into a clean JSON.
6. The JSON is sent to a **Google Apps Script Web App URL** which appends it as a new row in a Google Sheet.
## 3. Technical Stack
* **Frontend**: Manifest V3, HTML5, Tailwind CSS (via CDN), JavaScript.
* **APIs**:
* `chrome.sidePanel` (for the persistent UI).
* `chrome.scripting` (to read page content).
* **Gemini API (Google AI Studio)**: For data extraction and translation.
* **Backend**: Google Apps Script (as a lightweight proxy to Google Sheets).
## 4. Functional Requirements
### 4.1 UI Components (Side Panel)
* **API Key Input**: A secure way to save the Gemini API Key to `chrome.storage.local`.
* **Action Buttons**: Quick-action buttons for different categories:
* `[Extract Laptop]`: Target fields: Brand, CPU, RAM, Storage, Price, URL.
* `[Extract Peripheral]`: Target fields: Brand, Model, Connectivity, Battery Life, Price, URL.
* **Chat Interface**: A simple chat box to give custom instructions to the AI about the current page.
* **Status Indicator**: Loading states, Success/Error messages.
### 4.2 Data Extraction Logic (The Content Script)
* The script must extract `document.body.innerText` but filtered to remove excessive whitespace or script tags to save token costs.
* It should also capture the current `window.location.href`.
### 4.3 AI Prompt Engineering (The "Brain")
The extension must send a system prompt to Gemini:
> "Context: You are a professional shopping assistant.
> Input: Unstructured text from a retail website (might be in Spanish).
> Task: Extract [Fields] and translate technical terms to English/Chinese as requested.
> Constraint: Output ONLY valid JSON. Convert local currency (e.g., Lempira L.) to USD if possible, or keep original with symbol."
### 4.4 Data Storage (Google Sheets Integration)
* Instead of complex OAuth2, the extension will send a `POST` request to a **Google Apps Script Web App URL**.
* The Apps Script will handle `appendRow()` logic.
## 5. File Structure
* `manifest.json`: Configuration for permissions (`sidePanel`, `scripting`, `storage`, `activeTab`).
* `sidepanel.html`: The UI layout.
* `sidepanel.js`: Handling UI events, calling Gemini API, and communicating with Apps Script.
* `content.js`: Script to read the webpage text.
* `google-script.js`: The backend code to be pasted into Google Apps Script.
## 6. Security & Constraints
* **Location Context**: The user is in the US; use US-based Gemini API endpoints.
* **Privacy**: Do not send sensitive user data; only product-related text.
* **Error Handling**: Handle cases where Gemini cannot find product info (return "N/A").
## 7. Future Scalability
* Ability to capture product images (extracting `<img>` tags).
* Automatic currency conversion using a secondary FX API.

66
sidepanel.html Normal file
View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hardware Sourcing Assistant</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="bg-gray-50 p-4">
<div class="max-w-md mx-auto">
<!-- Header -->
<header class="mb-6">
<h1 class="text-xl font-bold text-indigo-700">AI 硬件采购助手</h1>
<p class="text-gray-500 text-sm">从任何网站提取产品数据。</p>
</header>
<!-- API Config -->
<section class="bg-white p-4 rounded-lg shadow-sm mb-4 border border-gray-200">
<h2 class="text-sm font-semibold mb-2">配置选项</h2>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500">Gemini API 密钥</label>
<input type="password" id="apiKey" class="w-full mt-1 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-1 focus:ring-indigo-500 outline-none" placeholder="输入 API Key">
</div>
<div>
<label class="block text-xs text-gray-500">Apps Script 链接</label>
<input type="text" id="scriptUrl" class="w-full mt-1 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-1 focus:ring-indigo-500 outline-none" placeholder="输入 Google Apps Script 链接">
</div>
<button id="saveConfig" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white text-sm py-2 rounded transition-colors">保存设置</button>
</div>
</section>
<!-- Action Buttons -->
<section class="grid grid-cols-2 gap-3 mb-4">
<button id="extractLaptop" class="flex flex-col items-center justify-center p-4 bg-white border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 group">
<span class="text-2xl mb-1 group-hover:scale-110 transition-transform">💻</span>
<span class="text-xs font-semibold">提取笔记本</span>
</button>
<button id="extractPeripheral" class="flex flex-col items-center justify-center p-4 bg-white border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 group">
<span class="text-2xl mb-1 group-hover:scale-110 transition-transform">⌨️</span>
<span class="text-xs font-semibold">提取外设</span>
</button>
</section>
<!-- Chat / Custom Instructions -->
<section class="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-4">
<h2 class="text-sm font-semibold mb-2">自定义指令</h2>
<div class="flex gap-2">
<input type="text" id="customInput" class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-1 focus:ring-indigo-500 outline-none" placeholder="例如:查找电池容量...">
<button id="sendCustom" class="bg-indigo-100 text-indigo-700 px-3 py-1 rounded text-sm font-medium hover:bg-indigo-200 transition-colors">执行</button>
</div>
</section>
<!-- Logs / Status -->
<section class="bg-gray-100 rounded-lg p-3 text-xs">
<div class="flex items-center justify-between mb-2">
<span class="font-bold text-gray-600 uppercase">状态与结果</span>
<span id="statusBadge" class="status-badge bg-gray-200 text-gray-600">待机</span>
</div>
<div id="results" class="max-h-64 overflow-y-auto font-mono text-gray-700 bg-white p-2 rounded border border-gray-200 whitespace-pre-wrap">暂无操作。</div>
</section>
</div>
<script src="sidepanel.js"></script>
</body>
</html>

137
sidepanel.js Normal file
View File

@@ -0,0 +1,137 @@
// DOM Elements
const apiKeyInput = document.getElementById('apiKey');
const scriptUrlInput = document.getElementById('scriptUrl');
const saveConfigBtn = document.getElementById('saveConfig');
const extractLaptopBtn = document.getElementById('extractLaptop');
const extractPeripheralBtn = document.getElementById('extractPeripheral');
const customInput = document.getElementById('customInput');
const sendCustomBtn = document.getElementById('sendCustom');
const statusBadge = document.getElementById('statusBadge');
const resultsArea = document.getElementById('results');
// Load settings on startup
chrome.storage.local.get(['geminiApiKey', 'googleScriptUrl'], (data) => {
if (data.geminiApiKey) apiKeyInput.value = data.geminiApiKey;
if (data.googleScriptUrl) scriptUrlInput.value = data.googleScriptUrl;
});
// Save settings
saveConfigBtn.addEventListener('click', () => {
const apiKey = apiKeyInput.value.trim();
const scriptUrl = scriptUrlInput.value.trim();
chrome.storage.local.set({ geminiApiKey: apiKey, googleScriptUrl: scriptUrl }, () => {
updateStatus('已保存', 'bg-green-500 text-white');
setTimeout(() => updateStatus('待机', 'bg-gray-200 text-gray-600'), 2000);
});
});
// Action Handlers
extractLaptopBtn.addEventListener('click', () => handleExtraction('laptop'));
extractPeripheralBtn.addEventListener('click', () => handleExtraction('peripheral'));
sendCustomBtn.addEventListener('click', () => handleExtraction('custom', customInput.value));
async function handleExtraction(type, customText = '') {
const { geminiApiKey, googleScriptUrl } = await chrome.storage.local.get(['geminiApiKey', 'googleScriptUrl']);
if (!geminiApiKey) {
alert('请先输入 Gemini API 密钥。');
return;
}
updateStatus('提取中...', 'bg-blue-500 text-white');
resultsArea.textContent = '正在读取页面内容...';
try {
// 1. Get current tab content
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const [{ result: pageData }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js']
});
resultsArea.textContent = '正在发送至 Gemini...';
// 2. Prepare Prompt
const systemPrompt = `你是一个专业的采购助手。
请从以下文本中提取产品数据。
将技术术语翻译成中文。
仅返回有效的 JSON 格式。
如果可能,将本地货币转换为美元,或者保留原始符号。
URL: ${pageData.url}
Title: ${pageData.title}`;
let userPrompt = "";
if (type === 'laptop') {
userPrompt = "提取笔记本详情品牌、CPU、内存、存储、价格、URL。仅返回 JSON。";
} else if (type === 'peripheral') {
userPrompt = "提取外设详情品牌、型号、连接方式、电池寿命、价格、URL。仅返回 JSON。";
} else {
userPrompt = `提取以下信息:${customText}。仅返回 JSON。`;
}
// 3. Call Gemini API
const geminiResult = await callGemini(geminiApiKey, systemPrompt, pageData.text, userPrompt);
const cleanedJson = parseGeminiJson(geminiResult);
resultsArea.textContent = JSON.stringify(cleanedJson, null, 2);
// 4. Send to Google Apps Script
if (googleScriptUrl) {
updateStatus('保存至表格...', 'bg-purple-500 text-white');
await sendToGoogleScript(googleScriptUrl, { ...cleanedJson, source_url: pageData.url, timestamp: new Date().toISOString() });
updateStatus('成功', 'bg-green-500 text-white');
} else {
updateStatus('完成 (未配置脚本链接)', 'bg-yellow-500 text-white');
}
} catch (error) {
console.error(error);
updateStatus('错误', 'bg-red-500 text-white');
resultsArea.textContent = `错误: ${error.message}`;
}
}
async function callGemini(apiKey, systemPrompt, contextText, userPrompt) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [{
text: `${systemPrompt}\n\n上下文:\n${contextText}\n\n任务: ${userPrompt}`
}]
}]
})
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
return data.candidates[0].content.parts[0].text;
}
function parseGeminiJson(text) {
try {
// Remove markdown code blocks if any
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
return JSON.parse(jsonStr);
} catch (e) {
throw new Error('AI 返回了无效的 JSON: ' + text);
}
}
async function sendToGoogleScript(url, payload) {
const response = await fetch(url, {
method: 'POST',
mode: 'no-cors', // Apps Script often requires no-cors for simple POST
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
return response;
}
function updateStatus(text, classes) {
statusBadge.textContent = text;
statusBadge.className = `status-badge ${classes}`;
}

91
style.css Normal file
View File

@@ -0,0 +1,91 @@
/* Base Styles */
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f9fafb; color: #111827; margin: 0; padding: 1rem; }
* { box-sizing: border-box; }
/* Layout & Spacing */
.max-w-md { max-width: 28rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
/* Flex & Grid */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1 1 0%; }
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.space-y-3 > * + * { margin-top: 0.75rem; }
/* Typography */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.font-medium { font-weight: 500; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.uppercase { text-transform: uppercase; }
/* Colors & Backgrounds */
.bg-white { background-color: #ffffff; }
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-gray-200 { background-color: #e5e7eb; }
.bg-indigo-50 { background-color: #eef2ff; }
.bg-indigo-100 { background-color: #e0e7ff; }
.bg-indigo-600 { background-color: #4f46e5; }
.text-white { color: #ffffff; }
.text-gray-500 { color: #6b7280; }
.text-gray-600 { color: #4b5563; }
.text-gray-700 { color: #374151; }
.text-indigo-700 { color: #4338ca; }
/* Interactive Colors (States) */
.bg-indigo-600:hover { background-color: #4338ca; }
.bg-indigo-100:hover { background-color: #c7d2fe; }
.hover\:bg-indigo-50:hover { background-color: #eef2ff; }
.hover\:border-indigo-400:hover { border-color: #818cf8; }
/* Borders & Shadows */
.border { border-width: 1px; border-style: solid; }
.border-gray-200 { border-color: #e5e7eb; }
.border-gray-300 { border-color: #d1d5db; }
.rounded { border-radius: 0.25rem; }
.rounded-lg { border-radius: 0.5rem; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.outline-none { outline: 2px solid transparent; outline-offset: 2px; }
/* Custom Utilities */
.status-badge { padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500; }
.w-full { width: 100%; }
.max-h-64 { max-height: 16rem; }
.overflow-y-auto { overflow-y: auto; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.transition-colors { transition-property: background-color, border-color, color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.group:hover .group-hover\:scale-110 { transform: scale(1.1); }
/* Input focus */
input:focus { border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; }
/* Status specific colors used in sidepanel.js */
.bg-green-500 { background-color: #10b981; }
.bg-blue-500 { background-color: #3b82f6; }
.bg-purple-500 { background-color: #8b5cf6; }
.bg-yellow-500 { background-color: #f59e0b; }
.bg-red-500 { background-color: #ef4444; }