一个浏览器扩展:跨文件夹任意勾选书签,聚合成一个文件导出。
支持 HTML(浏览器可直接导入)、JSON、Markdown、CSV 四种格式。纯本地运行,不联网、不收集任何数据。

它能做什么
- ✅ 跨层级任意勾选:书签、文件夹、跨文件夹混着选,三态复选框(全选 / 部分选中 / 未选)一目了然
- ✅ Shift 区间连选:点第一项,按住 Shift 点另一项,中间整段一起选中,跟文件管理器一样;对已选中区间 Shift 点击则整段取消
- ✅ 四种导出格式:
| 格式 |
用途 |
| HTML(Netscape 格式) |
浏览器「导入书签」直接还原,层级完整保留 |
| JSON |
备份 / 程序处理 |
| Markdown |
发帖、笔记、分享 |
| CSV(带 BOM) |
Excel 直接打开,含 folder_path 列 |
- ✅ 复制按钮:不下载文件,直接把「标题 + 链接」的 Markdown 列表复制进剪贴板,粘贴到聊天 / 文档即用
- ✅ 搜索过滤:按标题 / URL 实时搜索,在结果里直接勾选
- ✅ 网址去重:跨文件夹勾选时同一网址只保留一条(忽略末尾斜杠与大小写)
- ✅ 自动清理:空文件夹不导出、无标题书签用网址兜底、选项自动记忆
- ✅ 直接整理书签:勾选书签后(可跨文件夹、跨层级各自勾选),鼠标移到目标文件夹行上的
⊕ 移到此 即可整批移入;文件夹行 +新建 / 顶部 +新建 可就地建子文件夹或顶层文件夹。把"剪切到临时文件夹再导出"也省了
- ✅ 删除:勾选后点工具栏红色「删除」(带确认条,不可恢复)。删文件夹会连同子项一起删;书签栏 / 其他书签等根容器受保护不会被删
浏览器兼容性
- Chrome / Edge / QQ 浏览器 / Firefox 等标准内核:读、导出、移动、新建、删除全部正常。
- 360 极速浏览器 X:读 / 导出正常,但新建 / 移动 / 删除不可用——这是 360 自身的书签写入接口缺陷(
chrome.bookmarks 的 create / move / remove / update 全部返回 Bookmark id is invalid,360 社区多人反馈、暂无官方修复,非本工具 bug)。本工具通过运行时实测探测(建一个临时文件夹再删,若 create 报错即判定写入接口不可用,不靠浏览器 UA 嗅探)识别此类缺陷,命中后页面显示提示条、并直接置灰「新建 / 删除」按钮、隐藏文件夹行的「移到此 / 新建」,写入报错也会给出明确原因。整理书签请改用 Chrome / Edge。
两种导出结构,按需选择

- 保留层级(默认):勾了文件夹就带文件夹名,导入后分组完整。适合备份和迁移。
- 扁平导出:只输出链接,不带任何文件夹。适合临时凑一批链接分享、导入到别处。
另有「去掉根容器外壳」选项(默认开):导入后不会多出「书签栏 / 其他书签」这一层无意义的嵌套。每个选项都有悬浮说明,鼠标放上去就能看到解释。

Shift 区间连选
几百条书签里挑连续的一段?点第一个,按住 Shift 点最后一个,中间整段选中。折叠的分支不参与(只按当前可见顺序),搜索结果列表里同样可用。

隐私说明(重点)
- 仅申请
bookmarks 一个权限,用于读取你勾选的内容
- 不申请网络、下载、存储等任何其他权限——导出动作就是浏览器原生的文件保存
- 所有逻辑跑在你本地,代码全部开源可审查,总共几百行,无构建、无混淆
相关代码
1) 三态勾选的状态模型(取消某个子项时,把"整选的父文件夹"展开成"兄弟节点逐个勾选",这样导出时不会有空文件夹、也不会父子重复):
// 取消某节点时:把它的"已被整选的祖先"拆成兄弟节点的显式勾选,使该节点不再被祖先覆盖
function materializeAncestors(id) {
let cur = id;
const exclude = new Set([id]);
const guard = new Set();
while (cur != null && !guard.has(cur)) {
guard.add(cur);
const p = parentOf.get(cur);
if (p == null) break;
if (selected.has(p)) {
selected.delete(p);
const pn = index.get(p);
if (pn && pn.children) {
for (const c of pn.children) if (!exclude.has(c.id)) selected.add(c.id);
}
}
exclude.add(p);
cur = p;
}
}
function setChecked(id, checked) {
const n = index.get(id);
if (!n) return;
clearCache();
if (checked) {
if (ancestorSelected(id)) return; // 祖先已整选时跳过,避免父子重复入列
selected.add(id);
walk(n, (d) => { if (d.id !== id) selected.delete(d.id); });
} else {
materializeAncestors(id);
selected.delete(id);
}
clearCache();
}
2) 生成可被任意浏览器导入的 Netscape HTML(这套格式 Chrome / Edge / Firefox / Safari 都能直接「导入书签」还原层级):
function toNetscapeHtml(nodes) {
const out = ['<!DOCTYPE NETSCAPE-Bookmark-file-1>',
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
'<TITLE>Bookmarks</TITLE>', '<H1>Bookmarks</H1>', '<DL><p>'];
const walk = (list, depth) => {
const pad = ' '.repeat(depth + 1);
for (const n of list) {
if (isFolder(n)) { // 文件夹 → <H3> 包一层 <DL>
out.push(`${pad}<DT><H3 ADD_DATE="${toSec(n.dateAdded)}">${escapeHtml(n.title)}</H3>`);
out.push(`${pad}<DL><p>`);
if (n.children) walk(n.children, depth + 1);
out.push(`${pad}</DL><p>`);
} else { // 书签 → <A HREF>
out.push(`${pad}<DT><A HREF="${escapeHtml(n.url)}" ADD_DATE="${toSec(n.dateAdded)}">${escapeHtml(n.title)}</A>`);
}
}
};
walk(nodes, 0);
out.push('</DL><p>');
return out.join('\n') + '\n';
}
3) 移动书签 + 拦截"移进自己子文件夹"成环(新功能:跨文件夹勾选书签后整批移入目标文件夹;hasDescendant 防止把文件夹丢进自己的子树里导致书签丢失;移动后用 parentOf 核对是否真的落位,避免"API 报成功却没动"的假成功):
// 判断 maybeChild 是否为 ancestor 的子孙(拦截成环移动)
function hasDescendant(ancestorId, maybeChildId) {
const a = index.get(ancestorId);
if (!a || !a.children) return false;
let found = false;
walk(a, (n) => { if (n.id === maybeChildId) found = true; });
return found;
}
// 把当前勾选的全部移入 targetId 下;移进自身或自身子树则跳过
async function moveSelectedTo(targetId) {
if (targetId == null) return { moved: 0, skipped: 0 };
const ids = Array.from(selected);
let moved = 0, skipped = 0;
for (const id of ids) {
if (id === targetId) { skipped++; continue; }
if (hasDescendant(id, targetId)) { skipped++; continue; }
await call(api.move, id, { parentId: targetId });
moved++;
}
selected.clear();
clearCache();
return { moved, skipped };
}
4) 删除书签 / 文件夹(勾选后工具栏「删除」:书签用 remove,文件夹用 removeTree 连子项一起删;顶层根容器受保护不删,且沿用上面的 call 会在出错时抛真实错误而不是假成功):
async function removeSelected() {
const ids = Array.from(selected);
if (!ids.length) return { removed: 0, skipped: 0 };
let removed = 0, skipped = 0, lastErr = null;
for (const id of ids) {
const n = index.get(id);
if (!n) { skipped++; continue; }
if (parentOf.get(id) == null) { skipped++; continue; } // 根容器(书签栏/其他书签)不可删
try {
if (isFolder(n)) await call(api.removeTree, id);
else await call(api.remove, id);
removed++;
} catch (e) { skipped++; lastErr = e; }
}
selected.clear();
clearCache();
if (removed === 0 && lastErr) throw lastErr; // 一条都没删成 → 抛出真实错误
return { removed, skipped };
}
完整代码