从一行代码到 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");

加载插件:

  1. 打开 chrome://extensions
  2. 点左上角"加载已解压的扩展程序"
  3. 选你的 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 和动态注入的脚本之间的通信

最干净的方式是:

  1. content script 注入 Turndown 脚本
  2. content script 通过 window.postMessage 与页面脚本通信
  3. 或者更直接:让 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,先在控制台手动测试:

  1. 重新加载插件(chrome://extensions → 刷新按钮)
  2. 刷新当前网页
  3. 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 worker
  • chrome.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 下载文件"。同意后就能用。

验证:

  1. 重新加载插件
  2. 刷新当前网页
  3. 点工具栏图标
  4. 点"保存为 Markdown"
  5. 应该弹出文件保存对话框,默认文件名是 页面标题_日期.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. 被打回怎么改

审核员会在邮件里写明原因。常见流程:

  1. 看邮件里的原因
  2. 修改对应问题
  3. 重新上传 zip(在 dashboard 里编辑现有项目)
  4. 重新提交

如果是权限问题被卡,可以考虑:

  • 缩小 host_permissions 范围(从 <all_urls> 改成具体网站)
  • 在隐私声明里详细说明

7. 怎么更新版本

发布后想改代码:

  1. 改完代码
  2. 修改 manifest.json 里的 version(必须递增,比如 0.1.0 → 0.2.0)
  3. 重新打包 zip
  4. 在 dashboard 里编辑现有项目,上传新 zip
  5. 重新提交审核

更新版本审核通常 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 官方文档 确认。