开源一个Chrome插件的技术复盘:如何实现幕布全量导出 | 幕布导出工具

开源一个Chrome插件的技术复盘:如何实现幕布全量导出

大家好,我是 Mubu Exporter 的作者。

这个项目从第一行代码到上架 Chrome 应用商店大约花了两周时间。期间踩了不少 Manifest V3 的坑,也在幕布 API 的逆向上投入了相当的精力。这篇文章是一次完整的技术复盘——把架构设计、关键实现和踩过的坑都记录下来,供同样在做浏览器扩展开发的朋友参考。(如果你只是想了解如何使用这个工具导出笔记,建议先看Markdown 导出操作指南备份策略文章。)

TL;DR:Mubu Exporter 是基于 Chrome Manifest V3 的开源浏览器扩展,通过逆向幕布 API 实现全量文档导出。核心挑战:Service Worker 30秒超时的断点续传、BFS 文件夹遍历、HTML 到 7 种格式的转换引擎、1.2-2.0秒随机间隔防限流。

整体架构是怎样的?

Mubu Exporter 是一个基于 Chrome Manifest V3 的浏览器扩展,核心架构分为三层:

层级职责关键文件
UI 层(Popup)用户交互、状态展示src/popup.js + src/ui/*
核心层(Background Service Worker)API 调用、格式转换、任务调度src/background.js + src/core/*
存储层状态持久化、断点续传chrome.storage.local

通信方式是标准的 Chrome Extension Message Passing:Popup 通过 chrome.runtime.sendMessage 发指令给 Background,Background 通过同一通道回传进度和日志。

Manifest V3 带来了哪些挑战?

根据 Chrome 开发者文档,Manifest V3 把 Background Page(永驻进程)替换成了 Service Worker(按需唤醒),这带来了几个必须解决的问题:

问题一:Service Worker 会被杀死

根据 Chrome Service Worker 生命周期文档,Service Worker 在空闲 30 秒后会被自动终止。对于我们这种需要处理几百个文件、整个导出流程可能持续 10 分钟的场景,这是致命的。

解决方案:状态持久化 + 断点续传。

  • 每处理完一个文件就把完整状态写入 chrome.storage.local
  • Service Worker 重新唤醒时检查是否有中断的任务,有则从上次的 currentFileIndex 继续
  • 每个文件独立标记状态(pending / in_progress / success / failed),不依赖全局游标

关键代码逻辑:

// background.js 启动时检查未完成任务
export async function maybeResumeExport() {
  if (exportState.isExporting && !exportState.isPaused) {
    sendLog('检测到中断的导出任务,正在尝试恢复...');
    exportFiles();  // 从 currentFileIndex 继续
  }
}

问题二:没有 DOM 环境

Service Worker 里没有 document、没有 URL.createObjectURL。但我们需要生成文件并触发下载。

解决方案:用 Data URL(MDN 文档)替代 Object URL。把文件内容编码为 data:text/markdown;charset=utf-8,... 格式,再传给 chrome.downloads.download() API。对于二进制文件(PDF),用 FileReader 将 Blob 转为 Base64 Data URL。

问题三:AbortController 的生命周期

用户可能在导出过程中点击「暂停」或「重置」。我们需要一种机制来取消进行中的网络请求。

解决方案:维护一个全局 AbortController,所有 fetch 请求都挂载它的 signal。重置时调用 abort() 并创建新实例:

let abortController = new AbortController();

export function refreshAbortController() {
  abortController.abort();  // 取消所有进行中的请求
  abortController = new AbortController();
}

export function getAbortSignal() {
  return abortController.signal;
}

如何逆向幕布 API 获取全量文档?

幕布没有公开的开发者 API,所以需要逆向分析它的网页版通信协议。整个文档获取过程分三步:

Step 1:认证

幕布使用 JWT Token 进行认证,存储在名为 Jwt-Token 的 Cookie 中。插件通过 chrome.cookies.get() API 读取这个 Cookie——这就是为什么需要 "permissions": ["cookies"]

用户不需要输入任何密码或 Token,只需要在浏览器里保持幕布的登录状态即可。

Step 2:递归获取文件列表

这是最复杂的一步。幕布的文件列表不是一次性返回的,而是分散在多个 API 中:

API 端点作用
/v3/api/list/get_all_documents_page分页获取首页文档列表
/v3/api/list/get_folder获取所有文件夹元信息
/v3/api/list/get获取指定文件夹下的子文件夹和文档

为了确保不遗漏任何文档,我采用了 BFS(广度优先搜索)策略:

  1. 先调用 get_all_documents_page 分页遍历,获取首页可见的文档和文件夹
  2. 调用 get_folder 获取完整的文件夹列表
  3. 从根文件夹(ID = "0")开始 BFS,对每个文件夹调用 list/get 获取子内容
  4. visitedFolderIds Set 防止重复访问(处理循环引用)
  5. 所有文件夹和文档合并去重(用 Map 以 ID 为 key)

Step 3:获取文档详情

文件列表只包含元信息(ID、标题、所属文件夹)。真正的内容需要逐篇调用 /v3/api/document/edit/get 获取。返回的核心数据是 definition 字段——一个 JSON 字符串,包含了文档的完整树形结构。

这个 definition 的结构大致是:

{
  "nodes": [
    {
      "id": "xxx",
      "text": "<span class=\"bold\">节点标题</span>",
      "note": "备注内容(HTML格式)",
      "heading": 1,        // 标题级别
      "collapsed": false,  // 是否折叠
      "completed": false,  // 是否完成
      "image": { "uri": "/path/to/image" },
      "children": [ /* 子节点递归 */ ]
    }
  ]
}

注意:textnote 字段存储的是 HTML 片段而非纯文本。这意味着格式转换引擎需要完整的 HTML → 目标格式转换能力。

文件夹路径还原怎么实现?

幕布的文件夹是扁平存储的——每个文件夹记录自己的 folderId(父文件夹 ID)。要还原出完整的目录树路径(如 工作/项目A/阶段1),需要从叶子节点向上递归查找:

function buildFolderPathMap(folderMap) {
  const pathMap = new Map();

  const buildPath = (folderId, visiting = new Set()) => {
    if (!folderId || folderId === '0') return '';
    if (pathMap.has(folderId)) return pathMap.get(folderId);
    if (visiting.has(folderId)) return '';  // 防循环

    const folder = folderMap.get(folderId);
    visiting.add(folderId);

    const parentPath = buildPath(folder.folderId, visiting);
    const folderPath = parentPath
      ? `${parentPath}/${folder.name}`
      : folder.name;

    visiting.delete(folderId);
    pathMap.set(folderId, folderPath);
    return folderPath;
  };

  for (const folderId of folderMap.keys()) {
    buildPath(folderId);
  }
  return pathMap;
}

几个关键设计:

  • visiting Set 检测循环引用(理论上不应该出现,但要防御性编程)
  • 结果缓存到 pathMap 中,避免重复计算
  • 路径中的非法字符(如 /\)会被 sanitizePathComponent() 清洗

7 种格式转换引擎各有什么难点?

插件目前支持 7 种导出格式,其中 6 种在本地完成转换(纯 JavaScript 实现),1 种调用幕布远程 API:

格式转换方式核心难点
Markdown (.md)本地HTML → Markdown 语法映射、表格转换
OPML (.opml)本地XML 构建、备注处理
Freemind (.mm)本地XML 节点 ID 生成、richcontent 格式
HTML (.html)本地样式还原、KaTeX 公式渲染
Word (.doc)本地Office XML 兼容、emoji 处理、表格
JSON (.json)本地无(原始 definition 格式化输出)
PDF (.pdf)远程 API调用幕布 /convert/export 接口

Markdown 转换:最复杂的引擎

Markdown 转换是最复杂的一个,因为幕布节点的 text 字段包含丰富的 HTML 标记:

  • <span class="bold">**加粗**
  • <span class="italic">*斜体*
  • <span class="codespan">`代码`
  • <span class="formula" data-raw="...">$LaTeX$
  • <span class="highlight-yellow"><mark>高亮</mark>
  • <span class="text-color-red"> → 带 style 的 span 保留
  • <table>...</table> → Markdown 表格语法

转换管线大致为:

  1. 表格预处理(HTML table → Markdown table 语法)
  2. span 标记转换(class → Markdown 格式符号)
  3. 简单标签转换(strong、em、code、u、s)
  4. 链接和图片转换
  5. HTML 实体解码
  6. 空行压缩和尾部清理

公式处理是个有趣的点:幕布用 data-raw 属性存储 URL 编码的 LaTeX 源码。转换时需要先 decodeURIComponent,再包裹 $...$

Word 导出:意想不到的坑

Word 格式(.doc)的导出最初看起来简单——生成符合 Office 规范的 HTML 就行。但实际遇到了几个棘手问题:

  • Emoji 乱码:Word 对 Unicode emoji(U+1F000 以上)支持很差,需要在导出前 strip 掉所有 emoji 字符
  • 公式 span:Word 不认识 formula 类的 span,需要提前将 LaTeX 转为纯文本
  • 表格结构:幕布的表格 HTML 带有大量编辑器 UI 元素(column-select-btn 等),需要清洗后重建干净的 table 结构
  • 字体回退:Windows 和 macOS 的默认中文字体不同,需要在 CSS 里写完整的 fallback 链

HTML 导出:最大保真度

HTML 格式的目标是最大程度还原幕布的原始渲染效果。做法是直接把 textnote 字段的 HTML 原样保留,不做格式转换,只添加外层样式结构。同时引入 KaTeX CDN 来渲染数学公式:

document.querySelectorAll(".formula[data-raw]").forEach(el => {
  const tex = decodeURIComponent(el.getAttribute("data-raw"));
  katex.render(tex, el, { throwOnError: false });
});

下载文件名路由怎么做?

Chrome 的 downloads.download() API 有个限制:filename 参数只指定一个建议文件名,实际保存路径还受到浏览器下载设置的影响。

为了确保导出的文件按照幕布的文件夹结构保存(如 幕布备份/工作/项目A/文档.md),我使用了 chrome.downloads.onDeterminingFilename 事件来拦截并覆盖文件名:

// 下载前注册目标路径
pendingDownloadUrlMap.set(dataUrl, relativePath);

// 下载创建时记录 downloadId → 目标路径
chrome.downloads.onCreated.addListener(downloadItem => {
  const targetPath = pendingDownloadUrlMap.get(downloadItem.url);
  if (targetPath) {
    downloadFilenameOverrides.set(downloadItem.id, targetPath);
  }
});

// 文件名决定阶段介入
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
  const targetPath = downloadFilenameOverrides.get(item.id);
  if (targetPath) {
    suggest({ filename: targetPath, conflictAction: 'uniquify' });
  } else {
    suggest();
  }
});

这个机制保证了每个文件都能被保存到正确的子目录下,而且同名文件会自动加序号避免覆盖。

如何控制导出节奏避免限流?

批量调用幕布 API 时不能太快,否则会触发限流或者被临时封禁。我在每个文件的导出之间加了随机延迟:

await delay(1200 + Math.random() * 800);
// 每个文件间隔 1.2~2.0 秒

这个间隔经过实测验证:1 秒以下偶尔触发 429,2 秒以上体验太慢。1.2~2.0 秒的随机区间在可靠性和速度之间取得了不错的平衡——500 篇文档大约 10~15 分钟完成。

权限设计遵循什么原则?

安全性是这个插件的核心卖点之一——「数据不经过第三方服务器」。在权限申请上,我尽量做到最小化:

权限用途为什么必须
cookies读取幕布 Jwt-Token认证的唯一方式,不需要用户手动输入
storage持久化导出状态断点续传、设置保存
downloads保存导出文件到磁盘核心功能
declarativeNetRequest请求头处理确保 API 请求携带正确的 Origin/Referer

host_permissions 限定为 https://*.mubu.com/*——只能访问幕布域名下的资源,无法访问任何其他网站的数据。

整个数据流路径是:幕布 API → Service Worker(内存中转换)→ 本地磁盘。没有任何中间服务器参与。

构建与发布

项目使用 Webpack 5 打包,生产构建加了 webpack-obfuscator 做代码混淆(保护核心逻辑,但仍然开源了源码)。构建流程很简单:

# 开发模式(watch)
npm run build:watch

# 生产构建 + 打包 zip
npm run pack
# → build/chrome-extension-mubu-export.zip

Chrome Web Store 的审核对 Manifest V3 扩展相对友好,只要权限声明合理、代码没有恶意行为,通常 1-3 天就能过审。

国际化(i18n)

插件支持中文和英文两种界面语言,通过 Chrome 标准的 _locales 机制实现。Manifest 中的 __MSG_extName__ 等占位符会自动根据用户系统语言替换为对应文案。

未来方向

项目还有几个想做但尚未实现的功能:

  • 增量导出——对比上次导出记录,只处理新增/修改的文档
  • 图片本地化——将文档中引用的幕布 CDN 图片下载到本地并替换链接
  • 自定义模板——让用户定义 Markdown 的输出格式(比如标题用 # 而不是列表)
  • 定时自动备份——利用 Chrome Alarms API 实现周期性导出

如果你对这些功能感兴趣或者想贡献代码,欢迎到 GitHub 提 Issue 或 PR。想了解这个插件的实际使用体验,可以看看这位用户的 500 篇笔记导出实录。对于想要将导出结果迁移到其他工具的用户,我们也有详细的幕布迁移 Obsidian 教程幕布迁移 Notion 指南XMind 思维导图转换方案

写在最后

做这个项目最大的收获是对 Chrome Manifest V3 有了深入理解。Service Worker 的限制确实逼着开发者写出更健壮的代码——当你不能假设进程一直存活时,你就必须认真对待状态管理和容错设计。

另一个感悟是:逆向一个产品的 API 时,最重要的不是技术手段,而是理解它的数据模型。幕布的「文件夹 → 文档 → 节点树」三层结构一旦搞清楚,后面的实现就是体力活了。

希望这篇技术复盘对正在做类似项目的你有所帮助。如果有任何技术问题,GitHub Issues 随时欢迎讨论。


相关链接: