从一行代码到 Chrome Web Store 上架。本文用一个具体例子走通 Chrome 插件开发完整流程:manifest.json、content script、popup 通信、文件下载、用户偏好存储、上架审核。
为什么要做这个插件
我经常在浏览器里看技术文章。看到好的想存到 Obsidian,复制粘贴格式全乱。打开"另存为"保存下来是带广告、导航栏、侧边栏的 HTML。想找一个干净的 Markdown 版本,要么没有,要么是闭源插件不敢装。
装个现成插件不放心。写个 userscript 分发麻烦。干脆自己写一个。
这是个"改造已有网站"的小工具,Chrome 插件是最合适的形态。如果你还不确定插件是不是解决你问题的最佳方案,可以先看 《Chrome 插件开发》。
我们要做的版本
最终交付一个能本地转、能下载、能存用户偏好的插件:
- 点击工具栏图标,弹出一个 popup
- popup 上一个按钮"保存为 Markdown"
- 点击后,把当前页面转成干净的 Markdown,触发下载
- 几个 checkbox 让用户自定义:是否包含图片、是否包含代码块、文件名格式
这个版本足够简单,又覆盖了插件开发的核心链路:content script、popup 通信、文件下载、用户偏好存储。
准备工作
需要:
- Chrome 浏览器(任何最近版本都行)
- 一个空文件夹
- 一个文本编辑器
不需要:
- Node.js
- npm
- 任何前端框架
- 任何构建工具
整个项目就是几个静态文件,加一个第三方库。装一个依赖就要搭构建,搭构建就要讲 webpack / vite / TypeScript,整个文章会跑偏。
第三方库:Turndown.js
用来把 HTML 转成 Markdown。同类插件 80% 在用,成熟稳定。
下载地址:https://unpkg.com/turndown@7.2.0/dist/turndown.js
下载下来保存到 vendor/turndown.js(自己创建 vendor 目录)。
项目结构:
markdown-saver/
├── manifest.json
├── content.js
├── popup.html
├── popup.js
├── icons/
│ ├── icon16.png
│ ├── icon32.png
│ ├── icon48.png
│ └── icon128.png
└── vendor/
└── turndown.js
图标先随便找 4 张占位图(每个尺寸一张),后面会讲商店要求。
打开 chrome://extensions,打开右上角的"开发者模式"。
第一个能跑的插件
先做一个最小可运行的版本。目标:让 Chrome 认识我们的插件,content script 能往控制台打日志。
manifest.json:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"action": {
"default_title": "保存为 Markdown"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
content.js:
console.log("[Markdown Saver] content script loaded");
加载插件:
- 打开
chrome://extensions - 点左上角"加载已解压的扩展程序"
- 选你的
markdown-saver文件夹
验证:
打开任意网页,按 F12 打开 DevTools,控制台应该看到 [Markdown Saver] content script loaded。
如果没看到:
- 确认
chrome://extensions里插件状态是"已启用" - 确认 manifest.json 没有语法错误(JSON 必须合法,文件名不能错)
- 改完文件后必须回到
chrome://extensions点插件详情页的刷新按钮
到这里,插件已经能跑。剩下的是加功能。
提取内容并转成 Markdown
核心功能:从 content script 里拿到页面的 HTML,用 Turndown 转成 Markdown。
先让 content script 自己做这件事。后面再加 popup 通信。
content.js:
// 等待 Turndown 加载
window.addEventListener("load", () => {
// 动态注入 Turndown
const script = document.createElement("script");
script.src = chrome.runtime.getURL("vendor/turndown.js");
document.documentElement.appendChild(script);
});
function initTurndown() {
if (typeof TurndownService === "undefined") {
setTimeout(initTurndown, 50);
return;
}
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*"
});
// 排除导航、页脚、广告等干扰元素
turndown.remove([
"nav",
"aside",
"footer",
"header",
"[role='navigation']",
"[role='banner']",
".advertisement",
".ad",
".sidebar",
"#sidebar"
]);
window.__markdownSaver = turndown;
}
但这段代码有几个问题需要解决。
问题一:Turndown 文件需要被 content script 访问
content script 默认不能直接访问插件目录里的文件。要让 content script 能通过 chrome.runtime.getURL("vendor/turndown.js") 拿到这个文件,必须在 manifest.json 里声明 web_accessible_resources。
更新 manifest.json:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"action": {
"default_title": "保存为 Markdown"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
"web_accessible_resources": [
{
"resources": ["vendor/turndown.js"],
"matches": ["<all_urls>"]
}
]
}
问题二:动态注入的 script 默认运行在"页面世界"
content script 默认运行在"隔离世界",但动态注入的 <script> 标签默认跑在页面的 JavaScript 上下文里。所以我们在 initTurndown 里定义的 window.__markdownSaver 在 content script 自己访问不到。
解决办法:在动态注入时加 document.head 而不是 document.documentElement 没有用,关键是让 content script 通过消息通信让 content script 主动调用。
问题三:content script 和动态注入的脚本之间的通信
最干净的方式是:
- content script 注入 Turndown 脚本
- content script 通过
window.postMessage与页面脚本通信 - 或者更直接:让 content script 直接执行转 HTML 的动作,但 Turndown 服务在页面里初始化
这里有一个更简洁的方案:把 Turndown 注入到页面世界后,content script 通过自定义事件通信。
content.js:
// 注入 Turndown 到页面世界
function injectTurndown() {
return new Promise((resolve) => {
const script = document.createElement("script");
script.src = chrome.runtime.getURL("vendor/turndown.js");
script.onload = () => {
// Turndown 加载完后初始化
const initScript = document.createElement("script");
initScript.textContent = `
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*"
});
turndown.remove([
"nav", "aside", "footer", "header",
"[role='navigation']", "[role='banner']",
".advertisement", ".ad", ".sidebar", "#sidebar"
]);
window.__convertToMarkdown = (html) => turndown.turndown(html);
window.dispatchEvent(new CustomEvent("turndown-ready"));
`;
document.documentElement.appendChild(initScript);
resolve();
};
document.documentElement.appendChild(script);
});
}
// 监听转换完成事件
window.addEventListener("turndown-converted", (e) => {
console.log("Converted:", e.detail.markdown);
});
// 等页面加载完再注入
if (document.readyState === "complete") {
injectTurndown();
} else {
window.addEventListener("load", injectTurndown);
}
这一段代码看起来绕,原因是要在 content script 和页面世界之间传递 Turndown 这个对象。最简单的方案是干脆让 Turndown 在页面里初始化,content script 通过消息通信调用。
但更简单的方案是:把 Turndown 直接加载到 content script 的隔离世界。
更新一下:content script 加载 Turndown 的方式其实可以更直接。Manifest V3 允许在 content_scripts 里声明多个 JS 文件,依次加载:
更新 manifest.json:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"action": {
"default_title": "保存为 Markdown"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["vendor/turndown.js", "content.js"],
"run_at": "document_idle"
}
]
}
这样 Turndown 会被 Chrome 自动注入到 content script 的隔离世界,可以直接用。
content.js(简化版):
// Turndown 已经被自动注入,直接用
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*"
});
turndown.remove([
"nav",
"aside",
"footer",
"header",
"[role='navigation']",
"[role='banner']",
".advertisement",
".ad",
".sidebar",
"#sidebar"
]);
// 监听来自 popup 的消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "extract") {
const html = document.documentElement.outerHTML;
const markdown = turndown.turndown(html);
sendResponse({ markdown });
}
return true; // 保留 sendResponse 通道
});
到这里,content script 已经能做完整的事情:拿 HTML、转 Markdown、返回结果。剩下的只是 popup 怎么调用它。
验证:
现在还没有 popup,先在控制台手动测试:
- 重新加载插件(
chrome://extensions→ 刷新按钮) - 刷新当前网页
- F12 DevTools 控制台输入:
chrome.runtime.sendMessage({ action: "extract" }, (response) => {
console.log(response.markdown.slice(0, 200));
});
应该能看到页面前 200 字符的 Markdown 输出。
让插件"可交互":加 popup
现在加 popup。点击工具栏图标,弹出一个 HTML 页面,里面放一个按钮和几个 checkbox。
popup.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { width: 280px; padding: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button { width: 100%; padding: 8px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #1557b0; }
.option { margin: 8px 0; font-size: 13px; }
.status { margin-top: 8px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<button id="save">保存为 Markdown</button>
<div class="option">
<label><input type="checkbox" id="includeImages" checked> 包含图片</label>
</div>
<div class="option">
<label><input type="checkbox" id="includeCode" checked> 包含代码块</label>
</div>
<div class="status" id="status"></div>
<script src="popup.js"></script>
</body>
</html>
更新 manifest.json,加上 popup:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"action": {
"default_popup": "popup.html",
"default_title": "保存为 Markdown"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["vendor/turndown.js", "content.js"],
"run_at": "document_idle"
}
]
}
popup.js:
const saveBtn = document.getElementById("save");
const statusEl = document.getElementById("status");
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
statusEl.textContent = "提取中...";
try {
// 获取当前活动标签页
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 向 content script 发消息
const response = await chrome.tabs.sendMessage(tab.id, { action: "extract" });
if (!response || !response.markdown) {
statusEl.textContent = "提取失败";
return;
}
// 触发下载
const title = document.title.replace(/[\\/:*?"<>|]/g, "_");
const date = new Date().toISOString().slice(0, 10);
const filename = `${title}_${date}.md`;
await chrome.downloads.download({
url: "data:text/markdown;charset=utf-8," + encodeURIComponent(response.markdown),
filename: filename,
saveAs: true
});
statusEl.textContent = "已保存";
} catch (err) {
statusEl.textContent = "错误:" + err.message;
} finally {
saveBtn.disabled = false;
}
});
更新 manifest.json,加上 downloads 权限:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"permissions": ["downloads", "storage"],
"action": {
"default_popup": "popup.html",
"default_title": "保存为 Markdown"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["vendor/turndown.js", "content.js"],
"run_at": "document_idle"
}
]
}
几个关键点:
1. chrome.tabs.sendMessage vs chrome.runtime.sendMessage
chrome.runtime.sendMessage:发给 background service workerchrome.tabs.sendMessage:发给某个 tab 里的 content script- popup 给 content script 发消息必须用
chrome.tabs.sendMessage
2. 异步响应要 return true
content script 的 onMessage 监听器如果要异步响应(用 await、setTimeout 等),必须 return true,否则 sendResponse 通道会被关闭。
3. 消息体是 JSON
sendMessage 的参数和返回值都必须是 JSON 兼容的数据。函数、DOM 节点、Symbol 传不过去。
4. 第一次用 downloads API 会弹权限申请
用户第一次点"保存为 Markdown"时,Chrome 会弹窗问"是否允许 Markdown Saver 下载文件"。同意后就能用。
验证:
- 重新加载插件
- 刷新当前网页
- 点工具栏图标
- 点"保存为 Markdown"
- 应该弹出文件保存对话框,默认文件名是
页面标题_日期.md
如果出问题:
- popup 看不到 console:右键 popup → 检查
- content script 报错:刷新页面,看 F12 控制台
- 消息没发出去:检查 manifest 里 content_scripts 的 matches 是否覆盖当前 URL
触发下载和保存用户偏好
现在加上用户偏好。用 chrome.storage.local 存,比 localStorage 靠谱。
chrome.storage vs localStorage:
- localStorage:每个 origin 独立。content script 注入的页面有自己的一套 localStorage,跨页面、跨插件读不到。
- chrome.storage:异步 API,跨页面、跨插件、跨设备(如果用 sync)共享。
- 用
chrome.storage.local就行,sync需要登录 Chrome 账户,不必要。
popup.js(加入偏好读取和应用):
const saveBtn = document.getElementById("save");
const statusEl = document.getElementById("status");
const includeImages = document.getElementById("includeImages");
const includeCode = document.getElementById("includeCode");
// 加载保存的偏好
chrome.storage.local.get(["includeImages", "includeCode"], (result) => {
includeImages.checked = result.includeImages !== false; // 默认 true
includeCode.checked = result.includeCode !== false; // 默认 true
});
// 监听变化并保存
includeImages.addEventListener("change", () => {
chrome.storage.local.set({ includeImages: includeImages.checked });
});
includeCode.addEventListener("change", () => {
chrome.storage.local.set({ includeCode: includeCode.checked });
});
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
statusEl.textContent = "提取中...";
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, { action: "extract" });
if (!response || !response.markdown) {
statusEl.textContent = "提取失败";
return;
}
let markdown = response.markdown;
// 根据用户偏好过滤
if (!includeImages.checked) {
markdown = markdown.replace(/!\[.*?\]\(.*?\)/g, "");
}
if (!includeCode.checked) {
markdown = markdown.replace(/```[\s\S]*?```/g, "");
}
const title = document.title.replace(/[\\/:*?"<>|]/g, "_");
const date = new Date().toISOString().slice(0, 10);
const filename = `${title}_${date}.md`;
await chrome.downloads.download({
url: "data:text/markdown;charset=utf-8," + encodeURIComponent(markdown),
filename: filename,
saveAs: true
});
statusEl.textContent = "已保存";
} catch (err) {
statusEl.textContent = "错误:" + err.message;
} finally {
saveBtn.disabled = false;
}
});
到这一步,插件的核心功能完整了。能提取、能转换、能下载、能存用户偏好。
Manifest V3 必须知道的几件事
最终的 manifest.json:
{
"manifest_version": 3,
"name": "Markdown Saver",
"version": "0.1.0",
"description": "一键把当前页面存为 Markdown",
"permissions": ["downloads", "storage"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "popup.html",
"default_title": "保存为 Markdown",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["vendor/turndown.js", "content.js"],
"run_at": "document_idle"
}
]
}
几个关键字段:
action(V3 替换了 browser_action / page_action)
V2 时代有 browser_action(浏览器全局按钮)和 page_action(特定页面按钮)。V3 统一成 action。
service_worker(我们没用)
V3 用 service_worker 取代常驻 background page。我们这个插件用不到,因为 popup 直接跟 content script 通信,不需要经过后台。
如果你的插件需要跨页面协调(比如同时管理多个 tab),才需要 service_worker。
host_permissions(V3 独立字段)
V2 时代权限混在 permissions 里。V3 把"能访问哪些网站"拆出来。
"<all_urls>" 表示所有网站。如果你只想在某些网站用,改成 ["https://*.example.com/*"]。
icons(必填,4 个尺寸)
Chrome Web Store 严格要求 4 个尺寸:16、32、48、128。缺一个都不让过。
可以自己画,可以找设计师,可以去 iconmonstr 这种免费图标站下载。PNG 格式,背景透明。
上架到 Chrome Web Store
如果只想给自己用,刷新插件就够了。要发出去让别人也能装,需要走 Chrome Web Store。
1. 注册开发者账号
打开 Chrome Web Store Developer Dashboard,点"创建开发者账户"。
需要:
- 5 美元一次性注册费
- Visa 卡或其他支持的信用卡
- 科学上网(国内访问 Google 服务需要)
注册一次,永久有效。
2. 打包插件
把整个 markdown-saver 文件夹压缩成 zip。注意:
- 直接压缩文件夹内容,不是压缩文件夹本身
- 不要包含
.DS_Store、Thumbs.db这种系统文件 - 不要包含源代码注释里写的 TODO 之类的敏感信息
# 在项目根目录
zip -r markdown-saver.zip . -x "*.DS_Store" "*/Thumbs.db"
3. 准备商店材料
到 Chrome Web Store Developer Dashboard,点"新增项目"。
需要填:
- 插件包:上传刚才的 zip
- 商店图标:128x128 PNG,必须
- 小宣传图(可选):440x280
- 大宣传图(可选):920x680
- 截图:1280x800 或 640x400,至少 1 张,最多 5 张
- 简短描述:最多 132 字符,简洁说明插件做什么
- 详细描述:完整功能介绍、支持的使用场景
- 类别:工具 / 生产力 等
- 语言:默认英文,可加多语言
- 隐私声明 URL:必填
- 权限说明:每个权限都要解释为什么用
隐私声明
需要一个公开的 URL。可以放在:
- GitHub Pages
- 一个简单的 Notion 公开页
- 自己博客的一个静态页面
内容要说明:
- 插件收集什么数据(不收集最好,写"不收集任何数据")
- 收集的数据做什么用
- 第三方服务(这个插件没用到)
4. 提交审核
填完所有材料,点"提交审核"。
审核时间:
- 首次提交:1-3 天
- 被打回修改后重提:通常 1 天内
- 紧急更新:几小时内
5. 常见打回原因
1)缺少隐私声明 URL
必填。没有就打回。
2)权限超出实际需要
如果你申请了"读取所有网站数据"但实际不需要,审核员会打回。"host_permissions": ["<all_urls>"] 申请了就一定要在描述里解释清楚。
3)single purpose 不明确
一个插件做太多事会被打回。Markdown Saver 目的明确:把页面转成 Markdown。一个插件同时改页面 + 抓 cookie + 拦截广告,肯定不过。
4)icon 用了禁用素材
Google 商标、Chrome 标志、其他产品的 Logo 不能用。
5)截图或描述夸大
“最好用的 Markdown 工具” 这种话容易被打回。要客观描述功能。
6. 被打回怎么改
审核员会在邮件里写明原因。常见流程:
- 看邮件里的原因
- 修改对应问题
- 重新上传 zip(在 dashboard 里编辑现有项目)
- 重新提交
如果是权限问题被卡,可以考虑:
- 缩小
host_permissions范围(从<all_urls>改成具体网站) - 在隐私声明里详细说明
7. 怎么更新版本
发布后想改代码:
- 改完代码
- 修改
manifest.json里的version(必须递增,比如 0.1.0 → 0.2.0) - 重新打包 zip
- 在 dashboard 里编辑现有项目,上传新 zip
- 重新提交审核
更新版本审核通常 1 天内。
调试常见问题
1. service worker 的 console.log 在哪看
如果你的插件有 service worker,它在普通 DevTools 里看不到日志。要去 chrome://extensions,点插件的"service worker"链接,会打开一个独立的 DevTools 窗口。
我们的插件没用 service worker,跳过。
2. popup 的 console 在哪看
右键点 popup 的任意位置,选"检查"。会打开一个 DevTools 窗口,专门给这个 popup 用。
3. content script 报错怎么 debug
content script 报错会显示在目标网页的 DevTools 里。但它有独立的"扩展视图":
- F12 打开 DevTools
- 顶部下拉框,切换到你的插件名(不是当前的网页)
4. 修改了代码没生效
Chrome 会缓存插件代码。改完代码后:
- 必须回
chrome://extensions点刷新按钮 - 改 manifest 里的字段(如
content_scripts)尤其要刷新
5. 消息没发出去
在 popup 控制台或 content script 控制台分别 console.log,看是哪一边没收到。常见原因:
chrome.tabs.sendMessage的 tabId 写错- content script 没注入(检查
matches是否覆盖当前 URL) - manifest 里
content_scripts改完忘了刷新插件
还能做什么
这个版本是基础骨架。能扩展的方向:
1. 快捷键触发
chrome.commands API 让你绑定一个键盘快捷键,触发保存。在 manifest.json 加:
"commands": {
"save-as-markdown": {
"suggested_key": {
"default": "Ctrl+Shift+S"
},
"description": "保存当前页面为 Markdown"
}
}
然后在 background service worker 里监听 chrome.commands.onCommand。
2. 右键菜单触发
chrome.contextMenus API。右键点击页面空白处,菜单里加一项"保存为 Markdown"。
3. 图片下载到本地
当前版本只把图片 URL 转成 Markdown。如果要下载图片到本地或 base64 内嵌,需要:
- 用
fetch下载图片 - 转成 base64
- 替换 Markdown 里的图片 URL
4. 自定义选择器
让用户自己指定要保留或排除的元素。加一个 options 页面(chrome.runtime.openOptionsPage())。
5. 适配 Firefox
Firefox 也支持 WebExtensions API,但有些差异:
host_permissions不需要分出来background.scripts替代service_worker(Firefox 还没完全迁移)- API 行为略有不同
不是简单改个 manifest 就能跑,需要测试和适配。
6. 适配 Edge
Edge 用的就是 Chrome 内核,能直接装 Chrome Web Store 的插件。不用单独适配。
写在最后
这个插件不长,但走完了 Chrome 插件开发的完整流程:
- manifest.json 是什么、怎么配
- content script 怎么注入、怎么拿页面内容
- popup 怎么和 content script 通信
- chrome.storage 怎么存用户偏好
- chrome.downloads 怎么触发下载
- Manifest V3 跟 V2 差在哪
- Chrome Web Store 怎么注册、怎么提交、怎么过审核
实际写一个能解决自己问题的插件,整个流程大概一两天。前 80% 的时间花在踩坑上,后 20% 才是写业务代码。这篇文章能帮你跳过那 80% 的坑。
剩下的就是动手了。打开编辑器,从第一个 manifest.json 开始。
以本文发布时的 Chrome Web Store 政策为准。截图规格、描述字数、隐私政策要求可能会调整。提交前看一眼 Chrome Web Store 官方文档 确认。