作为每天在浏览器、编辑器、知识库、待办清单之间来回切换的人,我攒了大半年的书签和摘录,却在真正想写一篇总结时发现连一份干净的素材都拿不出来。这就是我想做 knowledge-work-plugins 的起点:一套由采集、清洗、整理、输出四个可独立替换的插件组成的知识工作流水线,把“看到一篇好文章”到“写成一份可交付的产出”这段路上的手工劳动尽量压缩。这个项目不是要造一个新工具,而是给现有工具缝隙里塞满胶水。如果你也是长期处理信息、笔记散乱、在写作前要花大量时间翻历史记录的人,这篇文章应该适合你。
1. 知识工作的真正瓶颈不是“记不住”,而是“工具断层”
1.1 信息链路断裂的真实代价
先描述一个我猜你也很熟悉的场景。你在通勤路上读到一篇讲时间管理的好文章,顺手复制了关键段落,粘贴进手机备忘录。回到工位后,那篇文章的链接躺在浏览器历史里,备忘录里的摘录没有作者、没有原文链接、没有阅读日期。三天后你想在周报里引用其中一个观点,不得不花十五分钟去翻历史记录,还不一定找得到。
这种损耗不是偶然的,它是结构性的。我把日常的信息处理链路拆开看,发现大多数人的工作流里至少有五个环节:采集、阅读、摘录、整理、输出。市面上每个环节都有做得很好的专用工具,但环节与环节之间完全是断的。你在这一个工具里记下的内容,不会自动变成下一个工具能识别的格式。于是每天积攒的碎片信息就像堆在仓库角落的散货,看着很多,真要出货时一件都挑不出来。
我做了一个粗略统计。一周内我大概主动阅读二十篇长文,其中值得长期留存的可能有六七篇,会动手摘录的可能有两三篇,最终真正出现在我自己的文章、周报或方案里的一般不超过一篇。也就是说,这条信息链路的实际转化率不足 5%。这个数字让我意识到,瓶颈根本不是记忆力,而是工具断层带来的摩擦。每多一次复制粘贴、每多一个需要手动补齐的字段,都是在消耗那一点珍贵的行动意愿。
1.2 为什么不用一个“全家桶”插件解决所有问题
遇到这种问题,很多人的第一反应是找一个“大而全”的插件,一个入口搞定采集、标注、整理、搜索。我也试过这类方案,最后都卸载了。原因很简单:大而全插件的生命周期太短。它试图兼容所有场景,于是权限申请得特别多,运行效率越来越慢,版本迭代越来越滞后;一旦开发者停止维护,你积累的几千条笔记等于被锁在了一个没人维护的黑盒里。
被替换性是工具设计里最被低估的指标。如果一个采集插件不好用,我应该能只把它替换掉,其他部分不受影响。全家桶做不到这一点,因为它把所有功能焊死在一起,牵一发而动全身。所以这套项目从一开始就拆成四个独立插件,它们之间只通过一份标准化的 JSON 数据交换,不共享内存、不互相调用内部函数。任何一个节点出问题,我可以单独修、单独升级、甚至单独重写,其他节点照常工作。
1.3 项目定位:不是替代工具,而是给现有工具“填补缝隙”
这个项目真正的定位,是给已经成熟的知识工具之间补上运输带。浏览器负责采集,笔记应用负责存储,编辑器负责输出,我不打算替代其中任何一个,只负责让数据能在它们之间顺畅流动。这也决定了插件的设计原则:每个插件都是单向的、无状态的处理节点,输入一份数据,输出一份处理后的数据,不保存自己的状态。
举个例子,采集插件把网页正文提取出来之后,会生成一份包含标题、作者、原文链接、发布时间、正文 Markdown、标签列表的 JSON 对象。清洗插件拿到这份 JSON,做去噪和格式化;整理插件再拿到清洗后的 JSON,写入知识库;输出插件则是从知识库里按条件检索,生成草稿。每个环节都是独立运行的,你可以完全不装输出插件,只用手动复制,前面的环节依然成立。这种“管道式”设计,让整个系统像流水线生产一样稳定,每一道工序都可以单独检修。
2. 插件体系的整体架构:四个节点拼成一条流水线
2.1 承载环境怎么选:浏览器、编辑器、知识库,各自负责什么
知识工作插件最核心的问题不是功能怎么写,而是挂在哪个宿主上。我选了三类宿主,每一类负责流水线上的特定区间。
浏览器插件负责采集,这是理所当然的,因为内容是先在浏览器里遇到的。我选了一款支持 Manifest V3 的主流浏览器作为主要承载环境,因为它的扩展生态最成熟、API 最稳定。编辑器插件负责清洗和输出,具体挂在一款我日常使用的代码/文本编辑器上,因为清洗过程涉及批量正则替换和格式转换,编辑器是最顺手的操作台。知识库侧的整理插件则跑在一款本地优先、纯 Markdown 存储的笔记应用里,因为这类应用的插件机制简单,数据文件直接落在本地,我完全掌控。
三个宿主的取舍并不难理解。我把它们的职责边界整理成了下面这张表,方便你看清每个插件到底该放在哪里。
| 插件环节 | 承载宿主 | 核心职责 | 不负责的事 |
|---|---|---|---|
| 采集插件 | 浏览器 | 抓取正文、提取元数据 | 不做内容清洗,不决定存哪 |
| 清洗插件 | 编辑器/命令行 | Markdown 化、去噪、补全引用信息 | 不采集新内容,不写知识库 |
| 整理插件 | 笔记应用 | 写入笔记、自动打标签、生成双向链接 | 不修改原文内容,不负责导出 |
| 输出插件 | 编辑器/命令行 | 检索素材、生成周报/文章草稿 | 不代替用户做判断和决策 |
这张表本身就是设计文档。一旦某个插件开始做表格里“不负责的事”,说明职责越界了,应当立即拆分。
2.2 技术选型与技术栈
技术栈方面,我统一用 TypeScript 开发,构建工具选 Vite。这样做的原因是,四个插件虽然挂在不同的宿主上,但代码逻辑里相当一部分可以复用。比如标准的元数据解析、Markdown 转换、相似度计算这些纯函数,完全可以抽成一个公共的 npm 包,各个插件引用同一份代码。TypeScript 的类型约束在这种跨插件协作场景里尤其重要,因为数据通过 JSON 传递,最容易出的问题就是字段名不一致或者类型对不上。
和浏览器 插件 + 编辑器插件一起使用还有点特殊的地方:浏览器插件用的是 Manifest V3 规范,编辑器插件用的是 CommonJS/ESM 模块体系,笔记应用插件又可能是它自己的脚本引擎。好在公共代码层是纯 TypeScript,不依赖任何宿主 API,编译成 JS 之后各方都能用。这也是这个项目能同时跑在三个宿主上的前提——你不能让某个宿主去适配另一个宿主的 API。
还有一个值得说的决策:本地优先。所有数据在本地生成、本地存储,只在用户主动触发时才考虑同步到云端。这个选择既是为了速度,也是为了安全。知识工作产生的数据往往比代码更敏感,草稿、灵感、个人观点,这些不应该在用户没意识到的时刻被上传到某个服务器。
2.3 代码骨架:用一个最小插件跑通流程
为了说清楚这套架构,我先给你看一个最小的浏览器插件骨架。它只做了两件事:监听页面上的快捷键,把选中文本发送给后台服务。
{ "manifest_version": 3, "name": "knowledge-work-collector", "version": "0.1.0", "permissions": ["activeTab", "scripting", "storage"], "background": { "service_worker": "src/background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["src/content.js"], "run_at": "document_idle" } ], "commands": { "collect-selection": { "suggested_key": { "default": "Alt+Shift+C" }, "description": "Collect current selection as knowledge item" } } }这段配置里有两个细节容易忽略。第一,content_scripts里的run_at设置成document_idle,确保页面 DOM 加载完成后脚本才注入,避免若干元素没渲染出来导致取不到正文。第二,快捷键是放在浏览器插件层的commands字段里的,而不是在页面里绑定keydown事件。这样做的好处是,即使用户当前焦点在一个复杂的富文本编辑器里,快捷键也能被浏览器可靠地截获。
数据交换的格式统一成下面这样,这是整个流水线的“标准物料”:
{ "id": "uuid-v4-generated", "source_url": "https://example.com/article", "title": "原文标题", "author": "原文作者或空", "published_at": "2025-01-15T09:30:00.000Z", "collected_at": "2025-03-20T11:22:33.000Z", "content_markdown": "# 标题\n\n正文内容...", "excerpt": "用户选中的关键段落原文", "tags": ["time-management", "focus"], "via": "user-selection" }一旦确定这个 schema,整个流水线的接口就冻结了。采集插件只需要保证输出符合这个 schema,清洗插件只需要保证输入输出符合这个 schema,两边甚至不需要知道对方的具体实现。这也是我在这类多插件项目里最想强调的一点:先定数据格式,再写功能逻辑,否则协作一定会在某个节点绞成一团。
3. 核心插件的开发细节:采集、清洗、整理、输出
3.1 采集插件:正文提取不是剪切整个网页
采集插件最容易踩的坑,就是把整个网页的 HTML 存下来。这种做法看似简单,实际后患无穷:页面上的导航、广告、相关推荐都会混进正文,几个月后重新打开时,原页面可能已经改版,样式错乱,引用时根本没法用。
正文提取的正确做法是,在 DOM 里按内容密度找正文区域。基本思路是,遍历页面上的区块元素,计算每个区块内有用的文本长度、链接文本占比、标点密度。真正的正文区块往往文本长、链接少、标点多;导航和侧栏则恰恰相反。把得分高的区块拎出来之后,再用遍历方式剔除内部嵌入的script、style、noscript等干扰标签,剩下的文本整体转成 Markdown。
我用了一段很轻量的小脚本做核心打分,思路大致如下:
function scoreElement(el) { const text = el.innerText || ""; const linkText = Array.from(el.querySelectorAll("a")) .map((a) => a.innerText || "") .join(""); const textLength = text.length; const linkRatio = linkText.length / Math.max(textLength, 1); return { score: textLength - linkText.length * 2, textLength, linkRatio, }; }实际运行中会发现两个棘手情况。第一是懒加载文章。部分页面首屏只渲染了开头几百字,正文要等滚动到中段才加载。应对办法是延迟采集或者自动滚动几次后再提取。第二是动态渲染站点,肉眼能看到正文,但 DOM 里根本没有静态内容。这类站点只能靠调用页面自己的渲染接口或者使用内置的“稍后提取”功能,没有万能解法。
3.2 清洗插件:把“网页格式”变成“知识基元”
采集插件输出的内容通常还很粗糙,里面夹杂着多余的空格、应删除的跟踪链接、公式转义错误等。清洗插件的目标是,把“网页格式”变成“知识基元”,即一份干净、自包含、带完整溯源信息的文本。
清洗规则我整理成了清单。第一条是强制 Markdown 化,代码块要保留语言标识,行内代码要正确包上反引号。第二条是补全引用溯源,如果原文没有作者或发布时间,必须在元数据里显式标成空,而不是直接把站点名填进作者字段。第三条是图片处理,外链图片全部下载到本地目录,文件名按内容哈希生成,避免两年后链接失效变成一片空白。第四条是去噪,把文末的“相关阅读”“广告标签”“读者评论”按特征串删掉。
下面这段是清洗插件输出前的一份最小示例,注意source_url、author这些字段永远完整保留,哪怕为空也占位,这是为了让下游整理插件能基于稳定结构做处理。
{ "source_url": "https://example.com/article", "title": "清洗后的标题", "author": "", "content_markdown": "## 开头\n\n正文干净内容...", "images": [ { "original_url": "https://example.com/image.png", "local_path": "./attachments/6a2b9c.png" } ], "cleaned_by": "knowledge-work-cleaner@0.3.1" }清洗阶段还有一个容易忽略的环节:去重。同一篇文章可能在不同渠道读到三次,采集插件会产生三份记录。清洗插件内部用一个基于标题归一化和内容相似度的缓存,遇到重复内容时,保留最早采集的那份,把后两份合并成“关联来源”字段,而不是直接丢弃。这样既保留了一稿多发的关联关系,又不会让知识库被同一篇文章刷屏。
3.3 整理插件:自动标签与双向链接
整理插件是整套系统里让我涨经验最多的部分。它的任务是把清洗好的 JSON 写入知识库,同时生成标签和链接。首先说自动标签。简单的方案是对正文分词之后按 TF-IDF 取高分词,但直接跑会发现经常打出“我们”“一个”这类无用词。实际做法是先维护一份停用词表,再对名词性词加权,同时把原文元数据里的已有标签纳入首选项,综合排序后取前三到五个。
双向链接的实现则比较微妙。我没有用中心化的数据库,而是利用了一个特性:知识库本身是纯 Markdown 文件,只要笔记里有形如[[笔记名称]]的引用链接,应用就会自动识别。整理插件在写入笔记时,会检索已有笔记标题,把当前笔记的标题和正文关键词与现有笔记做比对。相似度超过阈值的条目,自动在文末追加“相关笔记”区块,写上对应的[[链接]]。这样每一次写入,都会像织网一样把新内容挂到旧内容旁边,网越织越密,检索时能沿着链路顺藤摸瓜。
3.4 输出插件:从素材到成稿的最后一公里
输出插件是这个流水线的最后一棒,也是最难写好的一环。它要做的事是,给定一个时间范围和主题关键词,从知识库里检索出相关素材,汇总成一份结构化的草稿。为了不让它变成“自动写稿机”,我刻意把它设计成草稿生成器,而不是发布器。它只负责把素材按时间倒序排列,提取节选,配上原文引用,生成一个有标题层级和引用脚注的 Markdown 文件,然后交给用户自行编辑。
这样做的好处是,观点和判断永远掌握在用户手里。插件生成的东西就像一份帮你折好了桌布、摆好了盘子餐具的餐桌,菜还要你自己炒。实测下来,这份“半成品”能让从找到写到成稿的时间缩短一半以上,因为最难的开场白和素材组织,机器已经替你做完了。
4. 开发过程中啃下来的硬骨头:权限、限流与数据去重
4.1 权限边界:最小权限原则是信任的基础
浏览器插件开发里,权限申请是最容易让用户心生警惕的环节。一个只做正文采集的插件,如果申请了“修改所有网站数据”“读取所有浏览历史”,用户第一反应就是卸载。我在这套项目里坚持最小权限原则:采集插件只申请activeTab、scripting、storage三个权限,连tabs都不申请。
可能有人会问,不申请tabs权限怎么拿到当前页面 URL?这正好是 Manifest V3 下的一个设计巧点:activeTab权限在用户主动触发快捷键时,临时授予当前标签页的访问权,采集插件此刻可以读一次页面信息,操作结束后权限自动收回。这种“按需授权、用完即走”的机制,比常驻权限要安全得多。用户也会更放心地把这个插件留在浏览器里。
另外再提一个安全设计原则:OAuth 令牌绝对不要存进普通页面环境。如果后面需要接入第三方 API,令牌必须放在插件的背景脚本隔离环境里,页面上下文只能通过消息接口间接使用,不能直接触碰令牌本身。这条红线守住,可以减少一大半 token 泄露风险。
4.2 接口限流与失败重试:不能靠用户手点重试
清洗插件在下载外链图片或补全原文元数据时,会调第三方接口。第三方接口最恼人的限制就是限流,一旦并发一上来,返回 429 或 503 是家常便饭。刚开始我写过一版简单的失败就重试,结果把接口打得更惨,限流反而更严重。
后来改成标准的指数退避重试算法:第一次失败后等 1 秒,第二次等 2 秒,第三次等 4 秒,依此类推,最多重试五次,超过次数就把任务标记成“待人工处理”,写进一个队列文件,不影响主流程。下面是这部分的伪代码:
async function fetchWithRetry(request, maxRetries = 5) { let delay = 1000; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await executeRequest(request); } catch (err) { if (err.status !== 429 && err.status !== 503) throw err; await sleep(delay); delay *= 2; } } throw new Error("retry-exhausted"); }限流这块给我的最大教训是,设定降级策略比设定重试策略更重要。有限流就意味着一定会用完配额,永远只靠重试,最终还是死路一条。我的方案是:图片优先下载,元数据补全降级为可选项,如果接口限流严重,宁可存一份没有作者信息的干净文章,也不阻塞整个采集流程。
4.3 数据去重与脏数据清洗:一次线上问题的完整排查复盘
数据去重这个坑,我是被一次真实故障追着走了一遍完整的排查链路才解决的,这里值得完整复盘。
现象是,某天我从一个站点连续采集了三篇文章,整理插件写入知识库后发现,其中一篇标题正常,正文却只有一个空段落,其他两篇正常。我第一反应是采集插件提取正文时出了问题,于是把那条记录原始输入调出来看,发现原始 JSON 里content_markdown字段已经被填了内容,说明采集环节是成功的。接着怀疑清洗插件,又把清洗插件的日志翻出来,发现它根本没报错,只是输出内容变得很短。这时候我才意识到,问题可能出在去重逻辑上。
顺着这条线查下去,最终定位到一篇更早的旧笔记里。清洗插件的去重缓存用的是“标题归一化 + 内容相似度”双重判断,而旧笔记的标题里有一个空格是全角字符,归一化函数没处理,导致它没能识别出“同一篇文章”。新文章被当成完全独立的条目写入,但写入时整理插件执行的相似度检测又认定它与旧笔记正文极度相似,于是触发合并规则,把新正文直接折叠成了旧笔记的摘要,摘要很短,看起来就是一个空段落。
根因清楚了,修复也直接:统一归一化函数,把所有全角标点转半角,同时对“正文为空”的记录加一条硬校验,一旦出现,跳过合并逻辑,保留原始完整内容。那次排错之后我专门写了一段回归测试,把各种带全角空格、大小写差异、URL 参数乱序的样例都套进去。这件事的教训是,去重逻辑不能只看标题,正文相似度判断才是兜底,而且任何异常记录都不能静默处理,要让用户看到发生了合并,否则排查起来无异于大海捞针。
5. 把插件真正用起来:一次从“刷到文章”到“写出周报”实测
5.1 搭建与基础配置
如果你看完前面的架构想自己搭一套,我从零开始的操作路径可以给你参考。第一步,把四个插件代码分别装入各自的宿主环境,浏览器插件安装到浏览器,编辑器插件通过本地加载方式开发模式运行,知识库插件则导入笔记应用的插件目录。第二步,在公共配置文件中指定四个路径变量:采集结果暂存目录、清洗结果输出目录、知识库存放目录、输出草稿目录。第三步,用一条模拟数据完整跑一遍,确认每个节点之间数据格式无错。
配置阶段容易忽略的一点是快捷键。我建议把常用操作绑定到不同修饰键组合上,避免冲突。我自己设置的组合是:采集当前选中内容为Alt+Shift+C,清洗当前缓存目录为Alt+Shift+P,生成周报草稿为Alt+Shift+W。设置完后,可以花两三天正常使用,重点观察哪些高频动作有卡顿,再按实际使用调整。
5.2 五步工作流实际走一遍
我拿一个具体场景实测:上周二我在浏览技术社区时看到一篇讲任务队列设计的文章,里面有一段关于背压机制的论述,正好下周里程碑评审可以用。
按下Alt+Shift+C,采集插件提取正文并生成 JSON 文件,整个过程大约两秒。文章正文约 4000 字,采集结果里标题、作者、时间都对,标签自动生成了task-queue和backpressure两个词。接着按Alt+Shift+P,清洗插件开始处理缓存,把图片下载到本地,补充脚注引用,同时发现这篇文章和我一个月前存下的另一篇有部分重叠,它在 JSON 里自动标记了关联条目。然后打开笔记应用触发一次整理同步,新笔记被写入知识库,文末出现了两个相关笔记链接,其中一个正是上次那篇旧文。
从采集到入库,整个链路大约四分钟,其中真正需要我动手的时间只有三十秒——按两次快捷键,打开应用确认一次。周四要写周报时,我在输出插件输入时间范围和“任务队列”关键词,插件在知识库里检索出三条素材,按时间倒序生成一份带引用脚注的草稿。我把草稿里的核心段落重新组织了一遍,加上自己的结论,删掉两条不太贴切的内容,一篇周报的框架流程就完成了一大半。
5.3 使用前后的直观对比
为了让你对这套系统的价值有一个直观感知,我把同一条“刷到文章再到写入周报”的链路,在使用插件前后各测了一次,结果如下表。
| 对比项 | 手动流程 | 插件流程 |
|---|---|---|
| 发现文章到完成摘录 | 15 分钟 | 约 3 分钟 |
| 素材可检索率(半年后) | 低,部分丢失 | 高,全部带溯源 |
| 周报初稿准备时间 | 约 2 小时 | 约 40 分钟 |
| 人工操作次数 | 约 10 次 | 2 至 3 次 |
| 素材引用完整性 | 经常缺出处 | 自动含原文链接 |
用了一个月后,我最直观的感受不是“快”,而是“敢”。以前读到好东西,心里会咯噔一下——“这个会不会以后有用”,但又懒得完整摘录,于是干脆不存,错过很多值得深入的信息。现在只要按下快捷键就会自动留存,哪怕当时用不上,也能在三个月后的某一天被整理插件翻出来,和新的文章连成一条线。这种“内容在库里自己长链接”的体验,是以前手动整理完全没感受过的。
6. 后续想做的三件事,以及长期维护插件的一点教训
6.1 扩展方向:移动端、RSS 聚合与智能摘要
这套系统目前最明显的短板在移动端。通勤路上刷到的内容,往往来不及回工位处理,只能在手机上粗略收藏,等同步到桌面端再补采集。现在的临时方案是,浏览器自带的稍后读功能先把链接存起来,回工位后批量导入。后一个版本我打算在移动浏览器里做一个更轻量级的采集插件,只提取摘要和链接,完整正文留给桌面端处理。
第二个想做的方向是 RSS 源聚合。目前采集插件是被动的、用户主动触发式的。但很多深度内容来自固定的一批高质量站点,完全可以定时抓取。我打算写一个定时任务,每天凌晨把已订阅的 RSS 源批量拉取一遍,过滤掉已入库条目,剩下的自动走清洗和整理流程。这样知识库的积累就不只依赖用户的偶发点击,而是有了一个稳定的每日补给线。
第三个方向是引入本地 NLP 做摘要抽取和主题聚类。现在相似度判断只停留在词频层面,效果比较粗糙,没法判断两篇文章是不是在讲同一个概念的不同侧面。如果能在本地跑一个小语言模型,把文章摘要向量化,整理插件就能识别出“这一篇是对上一篇的补充”“这两篇观点相反”这类关系,双向链接的质量会明显上一个台阶。不过这个方向成本不低,我还在评估边缘计算能力是否够用,短期内可能先做基于关键词演进的妥协版本。
6.2 长期维护插件的一点教训
插件开发最难的不是写第一版功能,而是让它活过第三年。浏览器升级会破坏 API,编辑器的插件机制会调整,笔记应用会改存储格式。我有一张注意事项清单,列几条对你可能有直接价值。
第一,任何与宿主 API 有关的功能都要做兼容层。我在代码里专门用一个adapter.ts文件封装所有宿主调用,插件本体不直接碰宿主 API。这样升级宿主时,要改的只有这一层,不会牵扯到核心逻辑。
第二,数据文件永远是第一优先级。知识库里的 Markdown 文件和附件是我真正的资产,插件代码哪怕全部重写,数据也要能原样打开。所以我在设计早期就坚持纯文本存储、不加密、不用专属二进制格式。无论哪个插件失效,手动目录永远兜底。
第三,版本迭代要克制。给插件加功能很容易,但每一次改动都可能破坏之前的工作流。我的做法是,新功能默认排在“可选项”里,默认关闭,只有在用户主动开启时才生效。这样既不影响老用户的稳定性,也给新功能留了试错空间。
最后再分享一个小体会。这套系统解决的不只是“信息丢失”问题,更是“信息焦虑”问题。我以前囤积资料,收藏夹越攒越多,内心反而越来越慌,因为知道自己永远不会回头翻。现在有了自动整理和关联机制,囤积本身变成了一件安心的事:每存一条,知识库就更有价值一点,而不是更杂乱一点。如果让我重做一遍整个项目,我仍然会从定数据格式开始,那句项目里最朴素的准则始终不变——先把数据标准定好,再谈任何功能。