From 5bae19f80a114c6202490d87d98fe242ad9e1b2e Mon Sep 17 00:00:00 2001 From: Julian Freeman Date: Wed, 11 Mar 2026 12:11:38 -0400 Subject: [PATCH] init --- background.js | 3 ++ content.js | 15 ++++++ google-script.js | 44 +++++++++++++++ manifest.json | 25 +++++++++ prd.md | 61 +++++++++++++++++++++ sidepanel.html | 66 +++++++++++++++++++++++ sidepanel.js | 137 +++++++++++++++++++++++++++++++++++++++++++++++ style.css | 91 +++++++++++++++++++++++++++++++ 8 files changed, 442 insertions(+) create mode 100644 background.js create mode 100644 content.js create mode 100644 google-script.js create mode 100644 manifest.json create mode 100644 prd.md create mode 100644 sidepanel.html create mode 100644 sidepanel.js create mode 100644 style.css diff --git a/background.js b/background.js new file mode 100644 index 0000000..d7262b8 --- /dev/null +++ b/background.js @@ -0,0 +1,3 @@ +chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch((error) => console.error(error)); diff --git a/content.js b/content.js new file mode 100644 index 0000000..1881afa --- /dev/null +++ b/content.js @@ -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 + }; +})(); diff --git a/google-script.js b/google-script.js new file mode 100644 index 0000000..0c8a797 --- /dev/null +++ b/google-script.js @@ -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); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..71f10e9 --- /dev/null +++ b/manifest.json @@ -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/*", + "" + ], + "background": { + "service_worker": "background.js" + }, + "side_panel": { + "default_path": "sidepanel.html" + }, + "action": { + "default_title": "Open Side Panel" + } +} diff --git a/prd.md b/prd.md new file mode 100644 index 0000000..4a06c15 --- /dev/null +++ b/prd.md @@ -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 `` tags). +* Automatic currency conversion using a secondary FX API. \ No newline at end of file diff --git a/sidepanel.html b/sidepanel.html new file mode 100644 index 0000000..9380feb --- /dev/null +++ b/sidepanel.html @@ -0,0 +1,66 @@ + + + + + + Hardware Sourcing Assistant + + + +
+ +
+

AI 硬件采购助手

+

从任何网站提取产品数据。

+
+ + +
+

配置选项

+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+ + +
+

自定义指令

+
+ + +
+
+ + +
+
+ 状态与结果 + 待机 +
+
暂无操作。
+
+
+ + + + diff --git a/sidepanel.js b/sidepanel.js new file mode 100644 index 0000000..8aecd5c --- /dev/null +++ b/sidepanel.js @@ -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}`; +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..3c1afbb --- /dev/null +++ b/style.css @@ -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; }