做 SEO 的朋友提过一个需求:写文章时在页面上看到个词,想立刻知道它的搜索结果长什么样------现在的流程是复制 → 打开新标签 → 粘贴 → 回车,一天重复几十次。于是我用一个周末写了个浏览器插件:选中文字,右键「查看搜索结果」,弹窗里直接看前 10。这篇把这个插件(Chrome MV3)的实现完整讲一遍,包括最关键的 Key 存储问题。
插件长什么样
三个组件:
- 右键菜单:选中文字后出现「查看『xx』的搜索结果」
- 后台脚本(service worker):调搜索接口,把结果暂存
- 结果弹窗:显示前 10 名、耗时和扣费
第一步:manifest.json
json
{
"manifest_version": 3,
"name": "SERP Peek",
"version": "1.0.0",
"description": "选中关键词,右键查看搜索结果前 10",
"permissions": ["contextMenus", "storage"],
"host_permissions": ["https://api.serpbase.dev/*"],
"background": { "service_worker": "background.js" },
"options_page": "options.html"
}
两个容易漏的配置:contextMenus 权限 (右键菜单)和 host_permissions(不声明 API 域名,service worker 里的 fetch 会被拦)。
第二步:右键菜单 + 调接口(background.js)
js
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "serp-peek",
title: "查看「%s」的搜索结果",
contexts: ["selection"],
});
});
let busy = false; // 防连点:正在查询时忽略新请求
chrome.contextMenus.onClicked.addListener(async (info) => {
if (busy || !info.selectionText) return;
busy = true;
try {
const { serpbaseKey } = await chrome.storage.local.get("serpbaseKey");
if (!serpbaseKey) {
chrome.runtime.openOptionsPage(); // 没配 Key:打开设置页
return;
}
const resp = await fetch("https://api.serpbase.dev/google/search", {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": serpbaseKey },
body: JSON.stringify({ q: info.selectionText, hl: "zh-cn", gl: "cn" }),
});
const data = await resp.json();
// service worker 随时会被回收:结果必须存 chrome.storage
await chrome.storage.session.set({ lastResult: data, lastQuery: info.selectionText });
await chrome.windows.create({ url: "result.html", type: "popup", width: 480, height: 640 });
} finally {
busy = false;
}
});
注意到 busy 锁和 chrome.storage.session 没有------这是 MV3 和普通网页开发最大的不同:service worker 会休眠,别用内存变量存任何要用的状态。
第三步:结果弹窗(result.html 的脚本)
js
const { lastResult, lastQuery } = await chrome.storage.session.get(["lastResult", "lastQuery"]);
document.querySelector("#query").textContent = lastQuery;
const list = document.querySelector("#list");
for (const r of (lastResult?.organic ?? []).slice(0, 10)) {
const li = document.createElement("li");
li.innerHTML = `<span class="rank">${r.rank}</span>
<a href="${r.link}" target="_blank">${r.title}</a>`;
list.appendChild(li);
}
document.querySelector("#meta").textContent =
`耗时 ${lastResult?.elapsed_ms ?? "-"} ms · 扣费 ${lastResult?.credits_charged ?? "-"} credits`;
把 elapsed_ms 和 credits_charged 显示出来,是给自己看的:每次右键花了多少,心里有数。
第四步:设置页与 Key 存储(最重要的设计决策)
设置页就是一个输入框,把 Key 存进 chrome.storage.local:
js
document.querySelector("#save").onclick = async () => {
const key = document.querySelector("#key").value.trim();
await chrome.storage.local.set({ serpbaseKey: key });
document.querySelector("#hint").textContent = "已保存到本机浏览器";
};
这里有个必须想清楚的模式选择:
| 模式 | 做法 | 适合 |
|---|---|---|
| 个人工具 | 用户自己填 Key,存本地 | 自用/给同事用;Key 存本机浏览器 |
| 对外发布 | 插件调你自己的后端代理 | 发布给陌生用户;Key 只在你的服务器 |
发布版绝对不能把 Key 打进插件包里------任何人解压 crx 都能看到,等于把余额送人。个人工具模式也要提醒:Key 是明文存在浏览器配置里,公用电脑别用。
踩坑记录
坑 1:用内存变量存状态。 service worker 几分钟不活动就被回收,变量全没,弹窗打开是空的。状态一律 chrome.storage。
坑 2:忘声明 host_permissions。 fetch 直接失败,控制台报权限相关的错。API 域名要写进 manifest。
坑 3:Key 打进包里发布。 这是最严重的一条------个人自用可以存本地,发布必须走后端代理。
坑 4:右键菜单 title 没写 %s。 菜单显示的是死文案,带不出选中的词。
坑 5:没有防连点。 右键狂点会连发请求,每次都扣 credits。加个 busy 锁。
坑 6:在 service worker 里用 XMLHttpRequest。 MV3 里用 fetch,XHR 已经不合适了。
工程清单
- manifest 声明
contextMenus+host_permissions - 状态全部进
chrome.storage(session/local 按用途分) - 个人工具:Key 存本地 + 风险提示;对外发布:一律走代理
- 右键菜单 title 用
%s带选中文字 - 防连点锁,避免误扣 credits
- 结果页展示
elapsed_ms/credits_charged,成本心中有数
浏览器插件算是「搜索数据的最近触点」------不用切窗口、不用复制粘贴,选中即查。代码量一个周末足够,但有两条线要先想清楚:Key 存哪(个人 vs 发布) 、状态放哪(storage,不是内存)。跨过这两条,剩下的就是普通的 Web 开发。
接口的鉴权头和各字段说明在 SerpBase 官方文档。你平时最想把哪些搜索数据操作「浓缩」到浏览器里?评论区聊聊。