大家好,我是 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(广度优先搜索)策略:
- 先调用
get_all_documents_page分页遍历,获取首页可见的文档和文件夹 - 调用
get_folder获取完整的文件夹列表 - 从根文件夹(ID = "0")开始 BFS,对每个文件夹调用
list/get获取子内容 - 用
visitedFolderIdsSet 防止重复访问(处理循环引用) - 所有文件夹和文档合并去重(用 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": [ /* 子节点递归 */ ]
}
]
}
注意:text 和 note 字段存储的是 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;
}
几个关键设计:
- 用
visitingSet 检测循环引用(理论上不应该出现,但要防御性编程) - 结果缓存到
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 表格语法
转换管线大致为:
- 表格预处理(HTML table → Markdown table 语法)
- span 标记转换(class → Markdown 格式符号)
- 简单标签转换(strong、em、code、u、s)
- 链接和图片转换
- HTML 实体解码
- 空行压缩和尾部清理
公式处理是个有趣的点:幕布用 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 格式的目标是最大程度还原幕布的原始渲染效果。做法是直接把 text 和 note 字段的 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 随时欢迎讨论。
相关链接:
- 项目源码:GitHub
- Chrome 应用商店:幕布导出工具
- Manifest V3 文档:Chrome Developers