從 CoolPC Plus 學會編寫 Chrome Extension:打造一個 AI 配單助手

Chrome Extension 最迷人的地方,是它可以在不改動網站原始碼的情況下,替既有網頁補上一層新的操作體驗。這篇文章會以 coolpc-plus 這個專案為範例,拆解一個 Chrome Extension 如何在原價屋估價頁注入側邊欄、讀取頁面零件清單、呼叫 AI Provider,最後把推薦零件反映回原本的頁面。

這個專案不需要打包流程,也沒有複雜的前端框架。整體使用 Chrome Extension Manifest V3、原生 JavaScript、HTML 與 CSS 完成,因此很適合拿來理解 Chrome 插件的基本架構。


這個插件要解決什麼問題

CoolPC Plus 的目標很明確:使用者打開 原價屋線上估價 頁面後,插件會在右側加入「CoolPC AI 配單助手」側邊欄。使用者輸入預算、主要用途與補充需求後,插件會讀取頁面上已公開顯示的零件選項,整理成 prompt 交給 AI 分析,再把推薦結果顯示在側邊欄中。

更重要的是,它不是只輸出文字建議而已。AI 回覆中會帶有一段 JSON,內容包含 CoolPC 頁面上的 selectNameoptionValue。插件解析後,會自動選取推薦零件,並在原本的估價表格上高亮標示。

整個流程可以拆成五個步驟:

  1. 使用 manifest.json 宣告插件權限、作用頁面與注入腳本
  2. 使用 content script 在 CoolPC 頁面建立側邊欄 UI
  3. 從頁面 <select> 元素擷取零件清單與價格
  4. 透過 background service worker 中繼呼叫外部 LLM API
  5. 解析 AI 回覆,將推薦零件套回頁面並高亮顯示

專案結構

專案的 README 已經整理出主要目錄。對 Chrome Extension 來說,最重要的是 manifest.jsoncontent/background/popup/ 這幾個部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
coolpc_explorer/
├── manifest.json
├── background/
│ └── service-worker.js
├── content/
│ ├── content.js
│ ├── main-world-interceptor.js
│ └── sidebar.css
├── popup/
│ ├── index.html
│ └── popup.js
├── shared/
│ └── llm-client.js
└── icons/

如果你是第一次寫 Chrome Extension,可以先把它想成三個執行環境:

  • content script:跑在指定網頁上,負責讀取與操作頁面 DOM
  • background service worker:跑在插件背景環境中,適合處理 API 請求、事件中繼與跨來源限制
  • popup page:點擊瀏覽器工具列上的插件圖示時出現的設定頁

CoolPC Plus 剛好把這三個角色都用到了,因此很適合當作完整範例。

第一步:用 manifest.json 定義插件入口

Chrome Extension 的入口是 manifest.json。這個檔案會告訴 Chrome:插件名稱是什麼、要在哪些頁面啟用、需要哪些權限、背景腳本在哪裡,以及點擊插件圖示時要開啟哪個 popup。

CoolPC Plus 使用的是 Manifest V3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"manifest_version": 3,
"name": "Coolpc Explorer",
"version": "0.1.0",
"description": "AI 電腦組裝顧問 — 在 CoolPC 頁面右側顯示 AI 推薦零件",
"permissions": ["storage", "scripting"],
"host_permissions": [
"*://www.coolpc.com.tw/*",
"https://api.openai.com/*",
"https://generativelanguage.googleapis.com/*",
"https://api.anthropic.com/*"
],
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": ["*://www.coolpc.com.tw/evaluate.php*"],
"js": ["content/content.js"],
"css": ["content/sidebar.css"],
"run_at": "document_idle"
},
{
"matches": ["*://www.coolpc.com.tw/evaluate.php*"],
"js": ["content/main-world-interceptor.js"],
"run_at": "document_idle",
"world": "MAIN"
}
],
"action": {
"default_popup": "popup/index.html",
"default_title": "Coolpc AI 顧問"
}
}

這裡有幾個關鍵設定。

permissions 中的 storage 讓插件可以使用 chrome.storage.sync 儲存設定,例如主題、AI Provider、模型、預算與 API Key。scripting 則是 Chrome Extension 常見的腳本權限。

host_permissions 決定插件可以存取哪些網域。CoolPC Plus 需要讀取 CoolPC 頁面,也需要呼叫 OpenAI、Gemini 與 Claude API,所以這些網域都必須列進來。

content_scripts 宣告了兩段腳本。第一段是主要功能,會在 CoolPC 估價頁載入 content/content.jscontent/sidebar.css。第二段比較特別,它把 content/main-world-interceptor.js 放在 world: "MAIN" 執行,用來攔截頁面原本的 Clear()FReset() 函式。

第二步:用 content script 注入側邊欄

content/content.js 是整個插件的核心。它被載入到 CoolPC 估價頁後,會建立右側 sidebar、綁定按鈕事件、讀取使用者設定,並負責後續的零件擷取與 AI 回覆處理。

在初始化階段,程式會呼叫 createSidebar()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function createSidebar() {
if (document.getElementById(SIDEBAR_ID)) return;

const sidebar = document.createElement('div');
sidebar.id = SIDEBAR_ID;
sidebar.dataset.theme = DEFAULT_THEME;
sidebar.innerHTML = getSidebarHTML();
document.body.appendChild(sidebar);

const toggle = document.createElement('button');
toggle.id = TOGGLE_ID;
toggle.title = '開啟 / 關閉 CoolPC AI 配單助手';
const toggleSpan = document.createElement('span');
toggleSpan.textContent = '關閉';
toggle.appendChild(toggleSpan);
document.body.appendChild(toggle);

document.body.style.marginRight = '320px';
document.body.style.transition = 'margin-right 0.3s';

bindEvents();
setSettingsControlsEnabled(false);
loadSettings();
}

這段程式做了三件事。

第一,它用 document.createElement() 建立 sidebar,並將 getSidebarHTML() 產生的表單介面塞進去。第二,它額外建立一顆開關按鈕,讓使用者可以收合側邊欄。第三,它調整 document.body.style.marginRight,避免右側 sidebar 直接蓋住原本頁面內容。

這是一個很典型的 content script 寫法:不需要控制原網站的原始碼,只要在頁面載入後注入自己的 DOM 與 CSS,就能疊加新的功能。

第三步:從頁面擷取可用零件

AI 要推薦零件,前提是它必須知道頁面上有哪些選項。CoolPC Plus 沒有去抓非公開資料,而是直接讀取使用者眼前頁面中的 <select> 選單。

核心函式是 extractPartsData()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function extractPartsData() {
const parts = [];

const selects = document.querySelectorAll('select[name^="n"]');
selects.forEach(select => {
const name = select.getAttribute('name');
if (!name) return;

const row = select.closest('tr');
if (!row) return;
const categoryName = getSelectCategoryName(select, name);

const options = [];
select.querySelectorAll('option').forEach(opt => {
const val = parseInt(opt.value, 10);
if (!val || opt.disabled) return;
const text = opt.textContent.trim();

const priceMatch = text.match(/\$\s*([\d,]+)/);
const price = priceMatch ? parseInt(priceMatch[1].replace(/,/g, ''), 10) : 0;

options.push({ value: val, text, price });
});

if (options.length > 0) {
parts.push({ selectName: name, categoryName, options });
}
});

return parts;
}

這裡的設計很值得學。程式不依賴後端 API,而是以目前頁面 DOM 作為資料來源。它尋找所有 namen 開頭的 <select>,例如 CPU、主機板、記憶體、SSD、顯示卡等分類,再逐一讀取其中的 <option>

每個 option 會被整理成三個欄位:

  • value:CoolPC 頁面中該選項的值
  • text:使用者看到的零件名稱與描述
  • price:從文字中解析出來的價格

後續 AI 回覆時,只要提供同一組 selectNameoptionValue,插件就可以把推薦結果對應回頁面上的實際選項。

第四步:建立穩定的 AI Prompt

很多 AI 功能失敗的原因,不是模型不會回答,而是程式沒有給它足夠明確的輸出格式。CoolPC Plus 在 content script 裡定義了固定的 system prompt,要求 AI 必須用繁體中文回答,並且最後只能輸出一段 JSON code block。

專案要求 AI 回覆包含這些區塊:

  • 配置摘要
  • 推薦配置
  • 選擇理由
  • 注意事項
  • 最後一段 JSON 結構,用於自動高亮頁面上的推薦零件

JSON 格式大致如下:

1
2
3
4
5
6
7
{
"recommendations": [
{ "selectName": "n4", "optionValue": 25, "reason": "符合多工與預算需求" }
],
"totalEstimate": 25000,
"summary": "整體配置說明"
}

這個設計讓 AI 回覆同時具備「給人閱讀」與「給程式解析」兩種用途。Markdown 文字可以顯示在 sidebar,而 JSON 則交給程式做後續自動化操作。

在建立 user prompt 時,程式會把使用者需求與可選零件清單一起組合起來:

1
2
3
4
5
6
7
8
9
10
const userPrompt = `[使用者需求]
- 預算上限:NT$${budget || '未指定'}
- 主要用途:${usageText}
- 補充說明:${notes || '無'}

[配單任務]
請依照 system prompt 的固定格式輸出一套最適合的配置。優先控制在預算內;若為了需求必須超出預算,請在「注意事項」明確說明原因。

[可選零件清單]
${partsText}`;

這裡還有一個實用細節:程式會先依預算過濾過高價格的單品,並且每個分類最多取前 100 筆。這樣可以避免 prompt 過長,也能降低 AI 推薦完全不符合預算的機率。

第五步:用 background service worker 呼叫外部 API

content script 雖然可以操作頁面 DOM,但直接呼叫外部 API 時容易遇到 CORS 與權限問題。因此 CoolPC Plus 把 OpenAI、Gemini 與 Claude 的 API 呼叫集中放在 background/service-worker.js

content script 會用 chrome.runtime.sendMessage() 傳送請求:

1
2
3
4
5
6
7
8
9
10
11
12
13
const response = await chrome.runtime.sendMessage({
type: 'LLM_REQUEST',
payload: {
provider,
apiKey,
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
maxTokens: 16384,
},
});

background service worker 收到後,依照 provider 分派到不同函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'LLM_REQUEST') {
handleLLMRequest(message.payload)
.then(data => sendResponse({ success: true, data }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true;
}
});

async function handleLLMRequest({ provider, apiKey, model, messages, maxTokens }) {
switch (provider) {
case 'openai':
return callOpenAI(apiKey, model, messages, maxTokens);
case 'gemini':
return callGemini(apiKey, model, messages, maxTokens);
case 'claude':
return callClaude(apiKey, model, messages, maxTokens);
default:
throw new Error(`不支援的 Provider: ${provider}`);
}
}

return true 是這段程式的關鍵。Chrome 的 message listener 預設是同步回應,如果要在非同步 API 請求完成後才呼叫 sendResponse(),就必須回傳 true,讓 message channel 保持開啟。

這也是 Manifest V3 很常見的架構:content script 專心處理頁面,background service worker 專心處理外部服務與插件層級的工作。

第六步:支援 Chrome 內建 AI

除了外部 API,CoolPC Plus 也支援 Chrome 內建 AI,也就是 Gemini Nano。這種模式不需要 API Key,但需要使用者的瀏覽器版本與實驗功能支援。

專案在 content script 中直接檢查可用 API:

1
const api = self.LanguageModel ?? window.LanguageModel ?? self.ai?.languageModel ?? window.ai?.languageModel ?? null;

這段寫法同時相容 Chrome 136+ 的 LanguageModel,以及 Chrome 127 到 135 的 window.ai.languageModel。如果 API 不存在,程式會提示使用者確認 Chrome 版本與 flags 設定。

若 API 可用,插件會建立 session,送出 prompt,最後記得銷毀 session:

1
2
3
4
5
6
const session = await api.create({ systemPrompt });
try {
return await session.prompt(userPrompt);
} finally {
session.destroy();
}

這裡的好處是可以保留同一套 UI 與 prompt 流程,只是在 provider 選擇 chrome-ai 時改走本機模型。

第七步:解析 AI 回覆並高亮頁面

AI 回覆完成後,processLLMResponse() 會先找出最後的 JSON code block:

1
2
3
4
5
6
7
8
const jsonMatch = rawContent.match(/```json\s*([\s\S]*?)```/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[1]);
recommendations = parsed.recommendations || [];
summary = parsed.summary || '';
} catch (_) { /* JSON 解析失敗則只顯示文字 */ }
}

接著程式會移除 JSON,只把 Markdown 內容顯示在 sidebar:

1
2
const textContent = rawContent.replace(/```json[\s\S]*?```/g, '').trim();
resultContent.innerHTML = formatMarkdown(textContent);

如果 JSON 中有推薦項目,插件會呼叫 applyRecommendedParts()showRecommendationHighlights()。前者負責真的選取 <select>,後者負責視覺標示。

1
2
3
4
5
6
7
8
9
10
11
12
function applyRecommendedParts(recommendations) {
recommendations.forEach(rec => {
const select = document.querySelector(`select[name="${rec.selectName}"]`);
if (!select) return;

const option = select.querySelector(`option[value="${rec.optionValue}"]`);
if (option) {
select.value = rec.optionValue;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}

這裡一定要觸發 change 事件,因為 CoolPC 原頁面可能會在選項改變時重新計價。如果只改 select.value,畫面看起來選到了,但原網站自己的計算邏輯未必會同步執行。

第八步:處理頁面原本的重置行為

CoolPC 頁面本身有清除與重置功能。當使用者執行這些操作時,插件也應該清掉自己的高亮狀態,否則頁面資料已經重置,AI 標記卻還留在畫面上,體驗會很混亂。

這就是 content/main-world-interceptor.js 的用途。它在 main world 執行,包住頁面原本的 Clear()FReset()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function () {
if (window.__aiAdvisorInjected) return;
window.__aiAdvisorInjected = true;

const _origClear = window.Clear;
window.Clear = function (...args) {
document.dispatchEvent(new CustomEvent('ai-advisor-clear'));
return _origClear?.apply(this, args);
};

const _origFReset = window.FReset;
window.FReset = function (...args) {
document.dispatchEvent(new CustomEvent('ai-advisor-clear'));
return _origFReset?.apply(this, args);
};
})();

content script 再監聽這個事件:

1
document.addEventListener('ai-advisor-clear', clearHighlights);

這個做法很漂亮,因為 content script 預設跑在 isolated world,和頁面自己的 JavaScript 執行環境不同。若要攔截頁面上的全域函式,就需要透過 world: "MAIN" 的腳本進入頁面主世界,再用 CustomEvent 通知 isolated world 的 content script。

第九步:用 popup 儲存設定

Chrome Extension 常見的另一個入口是 popup。CoolPC Plus 的 popup/index.htmlpopup/popup.js 提供設定介面,讓使用者選擇主題、AI Provider、模型與 API Key。

設定會被存在 chrome.storage.sync

1
2
3
4
5
chrome.storage.sync.set({ [STORAGE_KEY]: settings }, () => {
statusEl.textContent = '設定已儲存';
statusEl.className = 'status';
setTimeout(() => { statusEl.textContent = ''; }, 2000);
});

content script 載入 sidebar 時,也會從同一個 STORAGE_KEY 讀取設定。因此 popup 和 sidebar 可以共享狀態,使用者在 popup 改了 provider 或主題,回到 CoolPC 頁面時就能同步套用。

這裡也有一個安全與體驗上的取捨。專案提供「記住 API Key」選項;如果使用者勾選,API Key 會寫入瀏覽器同步儲存空間。如果不想保存,就取消勾選,插件只在當下使用輸入值。

第十步:本機載入與測試插件

CoolPC Plus 目前不需要額外 build step,因此開發流程非常直接。

  1. 下載或 clone 專案
  2. 開啟 Chrome 的 chrome://extensions/
  3. 開啟右上角「開發人員模式」
  4. 點選「載入未封裝項目」
  5. 選擇專案資料夾
  6. 前往 https://www.coolpc.com.tw/evaluate.php
  7. 在右側 sidebar 輸入預算、用途與補充說明
  8. 選擇 AI Provider、模型與 API Key
  9. 點選「分析並推薦零件」

修改程式後,只要回到 chrome://extensions/ 重新載入擴充功能,再刷新 CoolPC 頁面即可看到變更。

專案也提供幾個簡單的語法檢查指令:

1
2
3
4
node --check content/content.js
node --check popup/popup.js
node --check background/service-worker.js
git diff --check

因為這個專案沒有打包器與型別檢查,node --check 至少可以先確認 JavaScript 語法沒有錯。git diff --check 則可以檢查是否有多餘空白或格式問題。

從這個專案可以學到什麼

CoolPC Plus 雖然是一個具體情境的工具,但它涵蓋了 Chrome Extension 開發中非常實用的幾個觀念。

第一,manifest.json 是插件的控制中心。要在哪個網頁啟用、要讀取哪些外部網域、要載入哪些 content scripts,都必須在這裡說清楚。

第二,content script 很適合做「頁面增強」。它可以注入 DOM、讀取表單、改變樣式,也能把頁面上的既有資料整理成自己的應用資料。

第三,background service worker 適合當中繼層。當功能需要呼叫外部 API、處理權限或封裝共用邏輯時,把它放在 background 會比全部塞在 content script 更清楚。

第四,AI 功能要能落地,不能只依賴自然語言。CoolPC Plus 用 Markdown 給使用者閱讀,再用 JSON 給程式解析,這種「文字說明 + 結構化資料」的輸出設計,是很多 AI 工具都可以參考的模式。

第五,寫插件時要尊重原網站的狀態。像是選取零件後觸發 change 事件、頁面重置時同步清掉高亮,這些細節都會直接影響使用者是否覺得工具可靠。

結語

如果你想學 Chrome Extension,CoolPC Plus 是一個很好的實戰範例。它沒有過重的技術棧,卻完整示範了 Manifest V3、content script、background service worker、popup、chrome.storage.sync、跨來源 API 呼叫與頁面 DOM 操作。

更重要的是,這個專案不是為了展示插件 API 而寫的玩具範例,而是從真實使用情境出發:使用者在估價頁上挑零件,插件就在同一個頁面補上 AI 分析、推薦與高亮。這也是 Chrome Extension 最有價值的地方:它能在既有網頁流程中,補上一個剛好需要的輔助層。