做知识工作的人用到最后大概率都会走到同一条路给自己造一套顺手的工具。我最近把零散的脚本、书签、浏览器扩展统一整理成了一个名为 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: AltShiftS }, 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 存储跑通觉得有价值再加去重和检索。每一步都要确保能独立使用避免做出一个到处半成品的大项目。插件不使用任何外部服务数据全在本地这一点让我用得很安心。后续如果要扩展新能力我也会优先考虑本地可运行、可迁移的方案而不是把数据交给别人保管。这样做出来的工具才是真正属于自己的。