用Claude Code开发Chrome扩展:Manifest V3、消息通信与最小权限
用Claude Code构建Chrome MV3扩展,覆盖manifest、service worker、content script、权限、存储与测试。
先确定边界,再让Claude Code写代码
Chrome扩展看起来只是几个小文件,但实际包含Manifest V3、后台service worker、content script、消息通信、权限申请、存储、安全审查、打包和商店说明。如果只对Claude Code说“帮我做一个Chrome扩展”,它很容易生成一个过大的脚手架:<all_urls>、tabs、popup、React、Vite、外部脚本和难以解释的权限都会被混在一起。
这篇文章用一个很小的例子说明正确做法:在https://example.com/*页面中,用户选中文字后,通过右键菜单把选中的词高亮。我们只使用manifest.json、service-worker.js、content-script.js、chrome.storage.local和runtime message。这样做的好处是范围清楚、代码可复制、权限容易解释,也更适合交给Claude Code反复审查。
先解释几个术语。Manifest V3是Chrome扩展当前主要的清单格式,用来声明扩展名称、版本、权限和入口脚本。service worker是事件驱动的后台处理器,不会像旧background page那样一直常驻。content script是注入到网页里的脚本,可以读取和修改DOM。message passing是service worker和content script之间发送JSON消息的方式。若在代理开发里看到harness,可以理解为“让代理安全工作的脚手架”。
开发时要始终对照官方文档。Manifest格式见Chrome Extensions Manifest,service worker见About extension service workers,content script见Chrome Content scripts和MDN Content scripts,权限设计见Chrome Declare permissions和MDN permissions。如果想改进提示词,可以同时阅读站内的Claude Code生产力技巧。
三个实际使用场景
第一个场景是团队文档审阅。比如扩展只允许在https://docs.example.com/*运行,审阅者选中一个产品名后,右键高亮页面里的相关词。由于匹配域名很窄,安全说明也很简单:扩展只会处理内部文档站点。
第二个场景是客服或运营后台辅助。客服人员在订单页面选中订单号,右键后复制、标记或高亮相关信息。这个场景会碰到个人信息,因此要让Claude Code列出“读取什么、保存什么、发送什么、不读取什么”。不要把便利性放在数据边界之前。
第三个场景是内容和SEO检查。content script可以在草稿页面检查description长度、h2数量、内部链接和官方外部链接。它可以配合Claude Code CLI工具开发里的命令行检查,也可以参考Claude Code安全最佳实践建立发布前审查流程。
给Claude Code的提示词
MV3扩展的提示词要同时包含功能、禁止事项和验证方式。尤其要写明service worker不是常驻进程,content script不能随意访问所有扩展API,权限必须最小化。
请制作一个Manifest V3 Chrome扩展示例。
要求:
- 目标URL限制为 https://example.com/*
- 通过右键菜单高亮选中的文字
- 使用纯JavaScript文件: manifest.json、service-worker.js、content-script.js
- 使用 chrome.storage.local 保存 enabled 和 color
- service worker与content script通过runtime message通信
- 禁止使用 <all_urls>、tabs、eval、外部CDN脚本或远程代码
- 添加一个Playwright smoke脚本,确认扩展已被加载
- 根据Chrome官方文档写出权限审查表
这个提示词的重点是“不要扩展范围”。Claude Code很可能主动添加popup、options page、图标、打包器和更多权限。第一次实现时,应先把权限、消息通信、DOM操作和测试做小,再决定是否增加UI。
文件结构与Manifest
目录保持极简,方便在chrome://extensions中直接使用“Load unpacked”加载。
mv3-highlighter/
manifest.json
service-worker.js
content-script.js
package.json
playwright-extension-smoke.mjs
manifest.json声明扩展的入口、权限和目标页面。这里不使用host_permissions,而是在content_scripts.matches中把页面限制到https://example.com/*。
{
"manifest_version": 3,
"name": "Claude Code MV3 Highlighter",
"version": "0.1.0",
"description": "Highlights selected text on example.com from a context menu.",
"action": {
"default_title": "MV3 Highlighter"
},
"permissions": ["contextMenus", "storage"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content-script.js"],
"run_at": "document_idle"
}
]
}
权限审查可以写成表格,先说明为什么需要,再说明刻意不使用什么。
| 项目 | 本例设置 | 理由 | 不使用 |
|---|---|---|---|
| API权限 | contextMenus, storage | 右键菜单和设置保存 | tabs, scripting, downloads |
| 页面范围 | https://example.com/* | 只处理一个演示域名 | <all_urls> |
| host permissions | 无 | 静态content script匹配已足够 | 宽泛主机权限 |
| 远程代码 | 无 | 降低MV3和商店审查风险 | CDN脚本、eval |
service worker:接收事件并发送消息
MV3的service worker会在事件到来时启动,处理结束后可能停止。因此不要把重要状态只放在内存变量中。设置值应该保存在chrome.storage.local,需要时再读取。
const MENU_ID = "highlight-selection";
const DEFAULT_SETTINGS = {
enabled: true,
color: "#fff176"
};
async function readSettings() {
return chrome.storage.local.get(DEFAULT_SETTINGS);
}
chrome.runtime.onInstalled.addListener(async () => {
const settings = await readSettings();
await chrome.storage.local.set(settings);
chrome.contextMenus.create({
id: MENU_ID,
title: "Highlight selected text",
contexts: ["selection"],
documentUrlPatterns: ["https://example.com/*"]
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== MENU_ID || !tab?.id || !info.selectionText) {
return;
}
const settings = await readSettings();
await chrome.tabs.sendMessage(tab.id, {
type: "HIGHLIGHT_SELECTION",
text: info.selectionText.slice(0, 120),
enabled: settings.enabled,
color: settings.color
});
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "GET_SETTINGS") {
readSettings().then(sendResponse);
return true;
}
if (message?.type === "SAVE_SETTINGS") {
const nextSettings = {
enabled: Boolean(message.enabled),
color: String(message.color || DEFAULT_SETTINGS.color)
};
chrome.storage.local.set(nextSettings).then(() => {
sendResponse({ ok: true, settings: nextSettings });
});
return true;
}
return false;
});
这里最容易出错的是异步sendResponse。如果在Promise结束后才调用sendResponse,监听器必须return true,否则消息通道可能提前关闭。审查时要对照Chrome Message passing。
content script:安全地修改DOM
content script可以操作页面DOM,但不应该承担所有业务逻辑,也不要放API密钥或Claude令牌。它接收service worker发来的消息,然后只做必要的页面修改。下面的代码还写入一个data-*标记,方便Playwright确认脚本已注入。
const HIGHLIGHT_CLASS = "cc-mv3-highlight";
document.documentElement.dataset.ccMv3Highlighter = "ready";
function shouldSkipNode(node) {
const parent = node.parentElement;
if (!parent) return true;
return Boolean(
parent.closest(
`script, style, textarea, input, [contenteditable="true"], .${HIGHLIGHT_CLASS}`
)
);
}
function clearHighlights() {
for (const mark of document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)) {
const text = document.createTextNode(mark.textContent || "");
mark.replaceWith(text);
text.parentElement?.normalize();
}
}
function createMark(text, color) {
const mark = document.createElement("mark");
mark.className = HIGHLIGHT_CLASS;
mark.textContent = text;
mark.style.backgroundColor = color;
mark.style.color = "#111";
mark.style.padding = "0 2px";
return mark;
}
function highlightText(term, color) {
const query = term.trim();
if (!query) return 0;
clearHighlights();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.toLowerCase().includes(query.toLowerCase())
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
}
});
const matches = [];
while (walker.nextNode()) {
const node = walker.currentNode;
const index = node.nodeValue.toLowerCase().indexOf(query.toLowerCase());
if (index >= 0) matches.push({ node, index });
}
for (const { node, index } of matches.reverse()) {
const selected = node.splitText(index);
selected.splitText(query.length);
selected.replaceWith(createMark(selected.nodeValue, color));
}
return matches.length;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type !== "HIGHLIGHT_SELECTION") {
return false;
}
if (!message.enabled) {
clearHighlights();
sendResponse({ ok: true, count: 0 });
return false;
}
const count = highlightText(String(message.text || ""), message.color || "#fff176");
sendResponse({ ok: true, count });
return false;
});
这个高亮器不是完整的富文本引擎。它只处理每个text node中的第一个匹配项,并跳过表单、脚本、样式和可编辑区域。上线前还要测试多次匹配、Shadow DOM、动态加载内容、已有mark元素以及SPA页面。
Playwright和手动测试
扩展的完整自动化测试不容易,但smoke test至少能确认Chrome加载了扩展,并且content script进入目标页面。Playwright可使用launchPersistentContext加载未打包扩展。
{
"type": "module",
"scripts": {
"smoke": "node playwright-extension-smoke.mjs"
},
"devDependencies": {
"playwright": "^1.52.0"
}
}
import { chromium } from "playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const extensionPath = path.resolve(__dirname);
const userDataDir = path.resolve(__dirname, ".pw-extension-profile");
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
const page = await context.newPage();
await page.goto("https://example.com/");
await page.waitForFunction(() => {
return document.documentElement.dataset.ccMv3Highlighter === "ready";
});
console.log("Extension content script is loaded on https://example.com/");
await page.waitForTimeout(3000);
await context.close();
npm install
npm run smoke
手动测试也要保留:打开chrome://extensions,启用Developer mode,选择Load unpacked,加载目录。然后打开https://example.com/,选择标题文字,右键点击“Highlight selected text”。如果出现高亮,service worker控制台没有错误,第一轮就通过。
常见失败与发布前检查
第一个失败是过早加入tabs权限。本例只需要从context menu事件拿到tab.id并发送消息,不需要读取所有标签页信息。第二个失败是把秘密信息放在content script中。它靠近页面内容,不适合保存API密钥、Claude令牌或客户数据。第三个失败是误以为service worker会一直活着,导致内存缓存丢失。第四个失败是用innerHTML粗暴替换DOM,破坏事件和可访问性。第五个失败是在调试阶段用了<all_urls>,打包前忘记收窄。
发布前按Prepare your Extension准备图标、截图、隐私说明、权限说明和简短描述。打包时不要把node_modules和Playwright profile放进去。
zip -r mv3-highlighter.zip manifest.json service-worker.js content-script.js
Compress-Archive -Path manifest.json,service-worker.js,content-script.js -DestinationPath mv3-highlighter.zip -Force
变现CTA与实际验证记录
Chrome扩展开发的价值不只是生成代码,而是把权限、安全边界、测试和商店说明整理成可复用流程。ClaudeCodeLab可以通过Claude Code培训与导入咨询帮助团队审查MV3设计、权限说明、测试清单和Claude Code提示词。想自学模板和清单的读者,也可以查看教材列表。
Masa按这个模式试做时,最先暴露的问题不是JavaScript语法,而是权限解释。使用<all_urls>当然能更快演示,但很难向用户说明为什么需要访问所有网站。改成https://example.com/*并只保留contextMenus和storage后,Chrome扩展管理页面的警告更少,Claude Code给出的安全审查意见也更具体。MV3扩展的目标不只是“能运行”,而是“用可解释的权限运行”。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。