Chrome 作为我的常用浏览器,经年累月的上网冲浪,收藏夹里积累了不少优质文章和工具链接,也按照自己的习惯做了不少分类。然而,时间一长,书签管理变得混乱不堪:分类繁琐,需要定期调整;有些内容忘记是否收藏过,导致重复添加;想找某个曾经看过的资源时,翻找起来也很费劲。最近接触到一个热门概念——知识库,尤其在 AI 时代,私域数据变得尤为珍贵。对于程序员来说,知识沉淀不仅能提升效率,还能为未来的工作提供宝贵参考。

最近我频繁使用 AI 工具,发现它们特别擅长将模糊的想法梳理清晰,并协助实现具体的方案。本文后续的实现思路和代码,很大程度上得益于 AI 的辅助。顺便提一句,前段时间看到一个观点:AI 的“幻觉”(生成错误内容)难以完全消除,因此程序员不会被完全取代,但未来更需要“高级程序员”——那些能分辨 AI 输出正确性并加以纠正的人才。不过这是题外话,下面进入正题。

总体实现思路

目标是将 Chrome 书签导出并同步到 Notion 数据库中,并保留原始书签的文件夹分类结构。实现分为以下几个步骤:

  1. Chrome 书签导出:开发一个 Chrome 扩展,利用 chrome.bookmarks API 获取用户的书签数据。
  2. Notion 数据库设计:在 Notion 中创建一个数据库,设计字段存储书签的标题、URL 和文件夹信息。
  3. 数据同步:通过 Notion API 将导出的书签数据上传到 Notion 数据库。
  4. 细节优化
    • 避免重复添加:同步前检查 Notion 数据库,避免重复插入已有 URL 的书签。
    • 灵活配置:允许用户在扩展中输入 Notion API Token 和数据库 ID,提升使用灵活性。

详细实现步骤和 Demo

1. 创建 Chrome 扩展

Chrome 扩展是实现书签导出的核心工具,包含以下几个关键文件:

manifest.json

定义扩展的元数据和所需权限。

{
"manifest_version": 3,
"name": "Bookmark to Notion Sync",
"version": "1.0",
"description": "Sync Chrome bookmarks to Notion Database",
"permissions": ["bookmarks"],
"host_permissions": ["<https://api.notion.com/*>"],
"action": {
"default_popup": "popup.html"
}
}
  • permissions: 需要 bookmarks 权限来访问 Chrome 书签。
  • host_permissions: 允许访问 Notion API。

提供用户界面,用于配置 Notion 参数和触发同步操作。

<!DOCTYPE html>
<html>
<head>
<title>Bookmark to Notion Sync</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 10px;
}
.hidden {
display: none;
}
input {
display: block;
margin: 10px 0;
padding: 5px;
width: 200px;
}
button {
padding: 5px 10px;
cursor: pointer;
}
</style>
</head>
<body>
<div id="configView">
<h2>Notion 配置</h2>
<input type="text" id="notionToken" placeholder="Notion API Token" />
<input type="text" id="databaseId" placeholder="Notion Database ID" />
<button id="saveConfig">保存配置</button>
</div>
<div id="mainView" class="hidden">
<h2>Bookmark to Notion Sync</h2>
<button id="syncButton">同步书签到 Notion</button>
<div id="status"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
  • 初始界面要求输入 Notion Token 和 Database ID,保存后显示同步按钮。

处理用户交互、书签提取和同步逻辑。

// 保存 Notion 配置
document.getElementById('saveConfig').addEventListener('click', () => {
const token = document.getElementById('notionToken').value.trim();
const dbId = document.getElementById('databaseId').value.trim();
if (token && dbId) {
chrome.storage.sync.set({ notionToken: token, databaseId: dbId }, () => {
document.getElementById('configView').classList.add('hidden');
document.getElementById('mainView').classList.remove('hidden');
});
} else {
alert('请填写所有字段!');
}
});

// 触发书签同步
document.getElementById('syncButton').addEventListener('click', () => {
document.getElementById('status').textContent = '正在同步...';
chrome.storage.sync.get(['notionToken', 'databaseId'], (config) => {
chrome.bookmarks.getTree((bookmarkTree) => {
const bookmarks = extractBookmarks(bookmarkTree[0].children);
syncToNotion(bookmarks, config.notionToken, config.databaseId);
});
});
});

// 提取书签数据
function extractBookmarks(nodes, folder = 'Root') {
let bookmarks = [];
nodes.forEach((node) => {
if (node.url) {
bookmarks.push({ title: node.title, url: node.url, folder });
}
if (node.children) {
bookmarks = bookmarks.concat(
extractBookmarks(node.children, node.title || folder)
);
}
});
return bookmarks;
}

// 同步到 Notion
async function syncToNotion(bookmarks, token, databaseId) {
const existingUrls = await queryExistingBookmarks(token, databaseId);
let addedCount = 0;
for (const bookmark of bookmarks) {
if (!existingUrls.has(bookmark.url)) {
await addBookmarkToNotion(bookmark, token, databaseId);
addedCount++;
}
}
document.getElementById(
'status'
).textContent = `同步完成!新增 ${addedCount} 个书签。`;
}

// 查询 Notion 中已有书签 URL
async function queryExistingBookmarks(token, databaseId) {
const response = await fetch(
`https://api.notion.com/v1/databases/${databaseId}/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28'
},
body: JSON.stringify({ page_size: 100 })
}
);
const data = await response.json();
return new Set(data.results.map((page) => page.properties.URL.url));
}

// 添加书签到 Notion
async function addBookmarkToNotion(bookmark, token, databaseId) {
await fetch('<https://api.notion.com/v1/pages>', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28'
},
body: JSON.stringify({
parent: { database_id: databaseId },
properties: {
Title: { title: [{ text: { content: bookmark.title || '未命名' } }] },
URL: { url: bookmark.url },
Folder: { rich_text: [{ text: { content: bookmark.folder } }] }
}
})
});
}
  • 逻辑说明
    • extractBookmarks 递归提取书签树中的所有书签及其文件夹信息。
    • syncToNotion 检查现有书签 URL,避免重复添加。
    • 使用 chrome.storage.sync 存储用户配置,确保跨设备同步。

2. Notion 数据库设计

在 Notion 中创建一个数据库,字段设计如下:

字段名 类型 说明
Title 标题 书签名称
URL URL 书签链接
Folder 文本 书签所在文件夹
DateAdded 日期(可选) 书签添加时间

获取配置信息:

  1. Notion API Token
  2. Database ID
    • 在 Notion 数据库页面 URL 中,找到 32 位字符串(如 https://www.notion.so/{workspace}/{databaseId}?v=... 中的 databaseId 部分)。

3. 细节处理

避免重复添加

  • 在同步前通过 queryExistingBookmarks 查询 Notion 数据库中的所有 URL,存入 Set 集合。
  • 仅添加不存在的书签,避免重复。

灵活配置

  • 用户在扩展界面输入 Token 和 Database ID,保存到 chrome.storage.sync,无需硬编码。
  • 配置保存后,界面切换到同步操作模式。

错误处理(建议改进)

  • 当前代码未详细处理 API 请求失败的情况。建议添加 try-catch 和用户提示:
    try {
    await addBookmarkToNotion(bookmark, token, databaseId);
    } catch (error) {
    document.getElementById(
    'status'
    ).textContent = `同步失败:${error.message}`;
    }

使用方法

  1. 安装扩展
    • 在 Chrome 中打开“扩展”页面,启用“开发者模式”。
    • 点击“加载已解压的扩展”,选择包含上述文件的文件夹。
  2. 配置 Notion
    • 打开扩展,输入 Notion Token 和 Database ID,点击“保存配置”。
  3. 同步书签
    • 点击“同步书签到 Notion”,等待完成提示。