☰
自建知识工作插件集:采集清洗输出三合一
2026/10/14 4:17:44 网站建设 项目流程

做知识工作的人,用到最后大概率都会走到同一条路:给自己造一套顺手的工具。我最近把零散的脚本、书签、浏览器扩展统一整理成了一个名为 knowledge-work-plugins 的插件集,专门用来处理知识工作里最常出现的三件事——往资料库里存东西、把杂乱信息弄干净、以及把整理好的内容快速变成输出。这篇文章我会把需求拆解、核心实现、踩过的坑和最终效果都讲清楚,适合那些每天要和大量网页、文档、会议纪要打交道的人参考。

知识工作现在有个很矛盾的现象:工具越来越多,时间越来越少。各种协同软件、资料库、编辑器都在强调“效率”,但真正让人烦躁的往往是那些琐碎操作:复制标题、保存链接、去掉网页里的广告、把一段话从 PDF 转成普通文本、把今天的记录整理成明天的周报。这些动作单次只要几十秒,但每天重复几十次之后,一整块时间就被切碎了。我做的这套插件集不打算取代任何生产工具,它只做一件事:把知识工作者重复消耗在“搬运和整理”上的时间压缩到最低。

1. 先搞清楚知识工作到底卡在哪

1.1 三个高频场景:采集、清洗、输出

我先把自己一个月内的操作习惯拉了一遍,发现真正高频的动作集中在三个场景上。

第一个是素材采集。读到一篇观点明确、案例完整的文章时,我需要保留的其实不只是链接,还有标题、作者、核心段落、发表时间,这些信息如果手动复制,大概率会丢三落四。更麻烦的是,有些文章会在几天后被删除或改版,链接就变成了死链,所以本地留下正文快照非常必要。

第二个是信息清洗。网页保存下来之后,里面通常混着导航栏、侧边栏、广告位、推荐链接、页脚申明,这部分噪声占了内容的30%到50%。如果把整页存下来,后续检索的时候会把“热门推荐”也当成有效信息,导致搜索结果一团糟。清洗的功夫多数人懒得做,于是资料库越堆越乱,最终失去价值。

第三个是整理输出。知识工作者的产出往往不是长篇大论,而是周报、摘要、方案点子、会议记录。这些内容又高度依赖平时积累的素材片段。如果每次输出都要重新翻原文、重新组织语言,效率会非常低。我希望做到的是:从素材库里选出几篇相关文章,一键生成一份包含关键句的初稿,我再花少量时间修改即可。

1.2 为什么选择自建而不是采购现成工具

市面上不是没有能干的工具,笔记软件、在线收藏夹、知识管理库我几乎都试过。它们的共同问题有三个。

一是信息封闭。在线服务收藏的内容存在对方服务器上,一旦服务调整策略或下线,多年积累的资料说没就没。二是清洗能力弱。多数工具只保存网页快照,不负责去广告、去导航、去重复,检索质量自然上不去。三是个性化不足。我需要的摘要格式、模板结构、检索权重和默认设置都跟通用产品不一样,要每次花几十分钟去调整一个别人的产品,不如花几天写一套自己的方案。

自建有自建的好处。插件集只围绕自己工作流设计,代码量其实很小,核心模块加起来几百行就能跑起来;数据完全掌握在自己手里,所有处理都在本地完成;最重要的是,每加一个功能,我都清楚它为什么存在,而不是被产品的复杂设置牵着走。

1.3 整体架构:一个底座,三类插件

这套 knowledge-work-plugins 的形态是浏览器扩展。选择浏览器扩展而不是独立桌面程序,是因为知识工作者大部分的信息来源发生在浏览器里,在本页面上直接完成操作,不需要切换窗口,阻力最小。

整体分成三层:底层是本地存储和数据索引,中层是三个功能插件,上层是触发入口。我用一张表来梳理:

层模块核心职责
触发层右键菜单/快捷键/侧边面板在不同场景下调用对应插件
功能层素材采集插件提取标题、链接、正文、时间并落库
功能层清洗去重插件去除网页噪声、识别重复内容
功能层写作辅助插件生成摘要、按模板组装周报/卡片
数据层本地索引与存储全文索引、元数据管理、导出备份

设计原则有三条:单个插件只干一件事;模块之间通过标准的 JSON 数据交换,彼此不直接调用内部函数;所有数据默认留在本地,不回传任何服务器。这三点在后面每一步实现里都在起作用。

2. 核心插件设计与实现思路

2.1 素材采集插件:从“看到”到“入库”不超过三秒

采集插件的核心目标是降低操作阻力。我要求自己从一个念头到完成保存,最多三次操作:选中文字按快捷键,或者直接右键“保存到资料库”。如果还需要打开一个弹窗、确认一次表单,这个流程就失败了。

所以在采集时,插件会自动完成四件事:抓取当前页面的标题和 URL;提取正文清洗后的文本;读取作者和发布时间;把以上内容连同采集时间一起写入本地数据库。整个过程对用户透明,只有右上角闪过一条保存成功的提示。

存储层面我选择 IndexedDB 而不是扩展自带的 storage.local。原因很实际:storage.local 默认有配额限制,几兆数据存点元数据还行,存正文很快就触顶;IndexedDB 的容量要宽松得多,还支持游标遍历和批量写入,对后续做全文索引非常友好。

采集时还有两个细节值得说。第一,对正文的提取必须先走轻量级算法,而不是把整页塞进库。第二,提取失败需要回退到 meta 描述,保证至少能留下链接和标题。这样即使算法出错,也不会丢基本元数据。

2.2 清洗去重插件:把乱信息变成干净数据

清洗的核心是正文提取。网页正文不像 Word 文档有明确的结构,它是一堆嵌套的 div 和 span。我的提取方案沿用阅读模式的基本思路:遍历 DOM 节点,根据文本密度、标签类型和位置给每个节点打分,得分最高的连续区域就是正文。

打分时要注意惩罚项:某些标签几乎不可能是正文内容,例如导航、广告、页脚、推荐列表,这些标签会直接乘以一个极小的权重。经典的新闻网站、个人博客通常都能提取得很干净,而复杂门户网站可能需要调整阈值,但整体可用性超过90%。

去重则用了一个和平时不太一样的算法:simhash。为什么不用 MD5 这种严格哈希?因为网页保存的正文经常有细微差异,某个网站会在文末自动追加“阅读原文”或者修改个别字符。MD5 遇到一个字符不同结果就完全不同,而 simhash 会把相似文本映射成相近的指纹,再通过汉明距离判断相似度。实践中汉明距离小于等于3就认为是重复内容,这个阈值既能抓重复,又不容易误杀不同文章。

2.3 写作辅助插件:摘要和模板组合出初稿

这个插件解决的是“从素材到草稿”的最后一公里。它提供两个核心能力:语义摘要和模板组装。

语义摘要并不是用大模型,而是基于关键词覆盖度的句子抽取。原理可以这样理解:统计全文的词频,给每个句子打一个分数,句子的得分等于句子中包含的高频词权重之和,再乘上位置系数(标题、开头、结尾的权重更高)。取分数最高的三到五句,按原文顺序拼接,就是一篇可选用的摘要。这个办法在说明文、资讯类内容上效果不错,足够用于周报和资料摘编。

模板组装的实现更简单。我定义了几个模板文件,比如“每周总结”“素材卡片”“会议摘要”,模板里留好占位符。写作辅助插件会从检索结果里取出对应的素材片段,自动填充到占位符中,生成一个带结构的 Markdown 初稿。用户只需要改改措辞,而不需要重新组织框架。

3. 实操过程与核心代码拆解

3.1 脚手架搭建与存储层封装

搭一个浏览器扩展脚手架,第一件事是写 manifest 文件。我用的扩展清单是第三版(MV3),后台脚本用 service worker 替代了常驻后台页面,这要求所有状态必须在事件触发时重新加载,所以设计上要尽量无状态,或者状态全部落库。

manifest.json 的核心配置:

{ "manifest_version": 3, "name": "Knowledge Work Plugins", "version": "0.1.0", "permissions": ["contextMenus", "storage", "activeTab"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ], "commands": { "quick-save": { "suggested_key": { "default": "Alt+Shift+S" }, "description": "快速保存当前页面" } } }

权限只声明三个:右键菜单、存储、当前标签页。多余的权限一律不加,这样在审核和运行时都更安全。

存储层我封装了一个简单的 IndexedDB 操作类。用 promise 包装,避免回调地狱:

class KwStore { constructor(dbName = 'kw-plugins', storeName = 'documents') { this.dbName = dbName; this.storeName = storeName; this.db = null; } async open() { if (this.db) return this.db; return new Promise((resolve, reject) => { const req = indexedDB.open(this.dbName, 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(this.storeName)) { const store = db.createObjectStore(this.storeName, { keyPath: 'id' }); store.createIndex('url', 'url', { unique: true }); store.createIndex('createdAt', 'createdAt', { unique: false }); } }; req.onsuccess = () => { this.db = req.result; resolve(this.db); }; req.onerror = () => reject(req.error); }); } async add(doc) { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, 'readwrite'); tx.objectStore(this.storeName).add(doc); tx.oncomplete = () => resolve(doc.id); tx.onerror = () => reject(tx.error); }); } async getAll() { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, 'readonly'); const req = tx.objectStore(this.storeName).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } }

我故意用 URL 做了唯一索引,同一条链接重复保存时数据库会直接报错,这个报错会被上层捕获并转成“重复采集”的提示。为什么这样设计而不是覆盖?因为同一链接的两次采集时间不同,后来者可能拿到的是改版后的正文,宁可提示用户确认,也不要静默丢失原版。

3.2 正文提取模块的实现细节

正文提取是整个插件集里最容易出问题的部分。我实现的算法思路是:递归遍历 body 下的所有节点,对每个叶子节点计算一个文本密度分数,再把相邻高分支节点合并成一个候选块。

简化后的核心逻辑:

function scoreNode(el) { const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NAV', 'FOOTER', 'ASIDE', 'FORM']); if (SKIP_TAGS.has(el.tagName)) return -1; const text = el.innerText || ''; const density = text.length / Math.max(el.children.length, 1); let score = density; if (el.closest('article')) score *= 1.2; if (el.closest('main')) score *= 1.1; if (el.closest('a')) score *= 0.6; return score; } function extractMainContent(root) { let bestScore = 0; let bestNode = null; const queue = [root]; while (queue.length) { const node = queue.shift(); const score = scoreNode(node); if (score > bestScore) { bestScore = score; bestNode = node; } for (const child of node.children) queue.push(child); } return bestNode ? bestNode.innerText : ''; }

这个版本是很朴素的写法,现实中还需要加两个限制:一是正文区域不能无限大,当节点文本长度超过全页文本的 80% 时,通常说明它包含了导航文本;二是要把选出的节点向上扩展一级或多级,因为真正容器经常是父级 div。这些边界情况我是在测试了几十个不同类型页面后慢慢补进去的。

提取完毕后的文本还需要做一次基础清洗:去掉多余空行、压缩连续空白符、统一换行符。这一步看似不起眼,但从排版角度看非常关键,否则后续 Markdown 预览和模板填充时会到处出现莫名其妙的空行。

3.3 simhash 去重模块的代码实现

simhash 实现起来并不长,但每一步都很关键。我把它拆成四步:分词、哈希、加权、降维。

用 JavaScript 写一个可用版本:

function hashWord(word) { let hash = 0; for (let i = 0; i < word.length; i++) { hash = (hash << 5) - hash + word.charCodeAt(i); hash |= 0; } return hash; } function simhash(text) { const words = text.match(/[a-zA-Z0-9\u4e00-\u9fa5]+/g) || []; const vector = new Array(64).fill(0); for (const word of words) { const h = hashWord(word); const weight = word.length > 1 ? 1 : 0.5; for (let bit = 0; bit < 64; bit++) { if ((h & (1 << (31 - bit))) !== 0) { vector[bit] += weight; } else { vector[bit] -= weight; } } } let fingerprint = 0; for (let bit = 0; bit < 64; bit++) { if (vector[bit] > 0) { fingerprint |= 1 << (31 - bit); } } return fingerprint >>> 0; } function hammingDistance(a, b) { let xor = a ^ b; let count = 0; while (xor) { count += xor & 1; xor >>>= 1; } return count; }

这段代码用了 64 位向量来得到 32 位的最终指纹。因为 JavaScript 的位运算固定操作 32 位有符号整数,直接处理 64 位会溢出,所以我用 32 位范围内的位移来模拟,最后得到的是一个可比较的 32 位无符号整数。

判重的时候,我不跟库里每条数据两两对比,那样复杂度太高。正确做法是维护一张指纹到文章 ID 的映射表,新文章算完指纹后,与已有指纹做汉明距离比较。当库内文章过万之后,再按指纹的前 8 位做分桶,只比较同桶内的数据,速度会快一个数量级。这是我在数据量增长之后才补上的优化,早做反而浪费精力。

3.4 检索与摘要的轻量实现

本地检索我用倒排索引实现。建立索引时,先把正文按字符 n-gram 切分,英文可以直接按空格分词,中文则按 unigram 和 bigram 混合切。

建立倒排索引的大致结构:

function buildIndex(docs) { const index = new Map(); docs.forEach((doc) => { const tokens = tokenize(doc.text); const freq = {}; tokens.forEach((t) => { freq[t] = (freq[t] || 0) + 1; }); for (const [token, count] of Object.entries(freq)) { if (!index.has(token)) index.set(token, new Map()); index.get(token).set(doc.id, count); } }); return index; } function tokenize(text) { const normalized = text.toLowerCase().replace(/[^\u4e00-\u9fa5a-z0-9]/g, ' '); const tokens = normalized.split(/\s+/).filter(Boolean); const bigrams = []; for (let i = 0; i < tokens.length - 1; i++) { const a = tokens[i]; const b = tokens[i + 1]; if (/[\u4e00-\u9fa5]/.test(a) || /[\u4e00-\u9fa5]/.test(b)) { bigrams.push(a + b); } } return tokens.concat(bigrams); }

查询时,把查询词也走同样的分词流程,统计每个命中文章的累加权重,按权重排序取前十条。权重计算我从简单的词频换成 TF-IDF:词频是文章内出现次数,逆文档频率是库内包含该词的文章数取对数。这个改动让检索结果从“谁出现次数多谁靠前”变成了“谁更能代表这篇文章谁靠前”,效果提升非常明显。

摘要抽取我在第二章提过思路,这里补充一个关键细节:抽取之前必须去掉原始正文里的换行噪声,否则句子会被切得七零八落。我按中文句号、英文句点、感叹号和问号做切分,归一化后再打分。

4. 常见问题与排查技巧实录

4.1 动态加载页面导致采集不全

第一个踩的坑是单页应用。很多内容平台根本不是传统的服务端渲染,页面里的正文是 JS 异步拉接口之后再渲染出来的。我的 content script 在 document_idle 时执行,如果这时候正文还没渲染完,提取出来的就是半截。

后来我改成自己先等一下,再尝试提取。具体做法是使用 MutationObserver 监听 DOM 变化,同时设置一个兜底定时器:连续 500 毫秒没有新的节点变化,就认为页面基本稳定,再跑提取。实测下来成功率提升明显。这里有个细节要注意:观察器不能挂太久,否则会持续占用内存。我在触发提取后立刻断开观察。

4.2 正文提取误伤正常内容

提取正文时经常遇到一个尴尬场景:算法把评论区当成正文,或者把正文的一部分连同相关推荐一起截掉了。排查后发现,问题往往出在打分权重上,评论区和推荐区的文字密度也很高。

我做了两个改进。第一,给特定标签清零而不是弱化,像 ASIDE、NAV、FOOTER 直接判负分。第二,增加区域连续性检查,正文提取不应该只取一个孤零零的节点,得分最高的候选点周围若存在明显低分区域,则把边界收缩回高分区域内部。这两个改动在多数场景下能让误伤率降到很低。

还有一个容易被忽略的问题:黑名单列表不要写太死。某站点改名、改版后,标签类名可能从article-body变成article-detail,硬编码类名会直接失效。我的选择是优先用标签级语义,比如 ARTICLE、MAIN,类名只作为加权因子,不作为硬条件。

4.3 采集慢、内存占用大

插件用久了之后,处理大页面或大批量导入时会出现卡顿。原因一是提取正文时递归遍历了整个 DOM 树,二是写入数据库时反复开启事务。

解决办法有两个。提取逻辑从递归改成迭代,避免深嵌套页面导致调用栈过深;写库时改为批量事务,一次事务提交多条记录,而不是一条一条提交。还有一个很有效的做法:在空闲时段用 requestIdleCallback 处理批量任务,绝不让插件在页面交互高峰期抢占主线程。这个优化做完之后,即使面对几百个节点的复杂页面也不会有明显卡顿。

4.4 多设备同步出现冲突

我的插件最初只放在本机,后来想在多设备间共用同一份素材库,就通过文件导出再导入的方式同步。问题来了:两个设备同时修改同一文档时,后导入的版本会完全覆盖先修改的内容。

最终我采用了很朴素的冲突处理方案:每条文档增加version字段和updatedAt字段。导入时如果发现本地版本号更高或更新时间更新,就保留本地版本,并把冲突的远程版本放到一个单独的“冲突待处理”队列里。这个方案谈不上智能,但足够可靠。我个人的建议是,个人知识库同步,不要追求自动合并,保留一个手动冲突区反而更安全。

5. 扩展方向与实际使用心得

5.1 后续可以继续加的能力

这套插件集跑通之后,延展空间其实很大。我想过加一个定时任务插件,每天扫描库里保存过的链接,自动刷新一次正文快照,防止原文被删除后本地留的是残缺版。还想加一个标签聚类能力,用简单的 TF-IDF 加 KMeans 把相似文档自动归堆,避免手动整理分类的负担。如果要进一步,可以把摘要抽取换成更高质量的本地模型,但考虑到推理速度和硬件成本,我暂时不会这么做。

5.2 个人实测后的体会与调整建议

我大概用了两个月,库里有几百份资料。最直观的感受是,每天在“复制—粘贴—排版—整理”这些动作上省下的时间远超过我写插件的成本。检索命中率我专门做过一次抽样:随意挑 20 个查询词,从库里找相关文档,基本第一屏就能看到想要的结果。这说明对个人知识库来说,数据结构比算法更重要,只要清洗做得干净,简单的索引就够用。

如果你的资料量不大,我建议不要一上来就模仿全量功能。先搭一个最小的采集插件,把正文提取和 IndexedDB 存储跑通,觉得有价值再加去重和检索。每一步都要确保能独立使用,避免做出一个到处半成品的大项目。插件不使用任何外部服务,数据全在本地,这一点让我用得很安心。后续如果要扩展新能力,我也会优先考虑本地可运行、可迁移的方案,而不是把数据交给别人保管。这样做出来的工具,才是真正属于自己的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询