ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于浏览器插件的知识管理工作流:划词捕获、AI助手与同步导出实践

基于浏览器插件的知识管理工作流:划词捕获、AI助手与同步导出实践

我很久以前就想聊聊这个项目了——knowledge-work-plugins,一套围绕知识工作者日常流程做的浏览器插件集。做这套东西的起因很简单:我自己每天在浏览器里开几十个标签页,看到好的技术文章、行业报告、灵感碎片,第一反应是"先存着",结果存完就再也没打开过。后来我意识到问题不在"存"这个动作,而在于采集、整理、回顾、输出这条链路是断的。浏览器插件恰好是把这条链路重新接起来的合适载体,跨平台、轻量、随开随用,不需要额外安装一个重型软件。

这套插件集适合谁参考?如果你平时要做大量信息收集、阅读英文资料、写技术笔记,或者正打算给自己的知识管理流程搭一套趁手的工具,这篇博文里的完整方案可以直接抄作业。我尽量把每个模块的设计思路、关键代码、踩坑记录都写清楚,不是那种只贴个README的玩具项目。

1. 需求拆解与总体设计

1.1 先想清楚知识工作流的三个核心节点

动手写代码之前,我花了很长时间把知识工作者的日常拆了一下。拆到最后发现,不管你是写代码、做研究还是写文档,流程都可以归到三个核心节点上:采集、整理、输出。采集是往脑袋外部的存储空间抓内容,整理是给内容打标签、建立关联、去掉冗余,输出是把消化后的内容变成文章、代码、分享或项目方案。

这三个节点对应到工具需求上就不一样了。采集阶段需要的是低摩擦的捕获方式,最好选中一段文字按个快捷键就完事,不要弹窗、不要切换窗口、不要等到"有空再弄"。整理阶段需要的是批处理能力和去重能力,因为采集时你根本来不及判断这段内容是否值得留,很多内容都是重复的。输出阶段则需要把你积累的内容快速变成草稿、摘要或引文,而不是让你重新在几十个笔记文件里翻找。

这套插件集就是围绕这三个节点设计的。它不是一个"大而全"的知识管理应用,而是一组可以独立运行、也可以组合使用的小插件。每个插件只做一件事,但这件事要做到从浏览器里拿到的原始信息不被损耗。比如采集插件会保留原文上下文、来源URL、采集时间,这些元数据在整理阶段会变成很关键的检索维度。

1.2 为什么选浏览器插件而不是独立应用

我有段时间一直在犹豫,是做一个本地桌面软件,还是做一个浏览器插件。后来选浏览器插件有几个实际考虑。第一,不管什么操作系统,浏览器都是知识工作者的主要入口,资料从哪来就留在哪,不需要"导入导出"这种反人类的操作;第二,浏览器插件可以拿到当前页面的DOM、选中文本、请求头里的来源信息,这些是独立应用拿不到的第一手数据;第三,插件开发的迭代成本低,改完代码刷新一下就能看到效果,不需要重新打包安装。

当然,独立应用也有它的好处,比如可以做全局快捷键、可以常驻系统托盘。但对我这个场景来说,浏览器插件的"边界感"反而是优势,它跟着浏览器走,浏览器关了它就不工作了,这天然限制了知识采集的噪音范围,你不会把整个系统的文件都扫进来,只会收集和网页内容相关的东西。

这套插件集我采用的是共享数据层、独立功能层的架构。四个插件模块各自有独立的UI和交互逻辑,但它们都往同一个数据仓库里写数据。这个仓库可以选择浏览器本地的IndexedDB,也可以通过可选配置同步到本地文件或自己搭的服务端。这样做的考量是:插件之间不要互相依赖,某个插件挂了不影响其他功能;但数据必须统一,否则整理阶段会变成信息孤岛。

模块划分我提一下,后面会展开讲:

  • 划词捕获插件:选中网页文字,快速存入知识库,保留来源和上下文。
  • 侧边栏AI助手:在不离开当前页面的情况下,对选中的内容做摘要、翻译、结构化整理。
  • 知识库同步插件:把采集的内容批量导出为Markdown、JSON,或推送到本地目录。
  • 网页阅读辅助插件:针对长文的阅读标注和稍后读补充,和采集插件互补。

每个模块打包成独立的浏览器扩展,这样你可以只装自己需要的部分,也为后续扩展留了余地。

2. 模块一:划词捕获插件

2.1 捕获功能的最小可用实现

划词捕获是整套插件集里最基础也最常用的模块。它的核心场景就是你在读一篇长文时,看到一段很有启发的论述,按一下快捷键或者点一下浮标,整段文字连同出处信息就被存下来了。

Manifest V3下,插件的主体是后台的service worker和注入页面的content script。捕获功能主要在content script里完成,因为只有它可以直接操作页面DOM、监听键盘事件和鼠标事件。Manifest里的关键配置大概是这样的(我简化了,实际项目里还配了一些权限):

{ "manifest_version": 3, "name": "knowledge-work-plugins - capture", "version": "1.0.0", "permissions": ["contextMenus", "storage", "activeTab"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ], "commands": { "capture-selection": { "suggested_key": { "default": "Alt+Shift+C" }, "description": "Capture current selection" } } }

注意我用的是contextMenus权限,这样用户可以在右键菜单里直接选"存入知识库",比快捷键更直观。但content script里不能直接注册右键菜单,需要把消息发给service worker来处理:

// content.js document.addEventListener('mouseup', (event) => { const selection = window.getSelection(); if (selection && !selection.isCollapsed) { const text = selection.toString().trim(); if (text.length > 20) { chrome.runtime.sendMessage({ type: 'SHOW_CAPTURE_UI', text, selectionRect: { x: event.clientX, y: event.clientY } }); } } }); // background.js chrome.contextMenus.create({ id: 'capture-to-kb', title: '存入知识库', contexts: ['selection'] }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === 'capture-to-kb') { chrome.tabs.sendMessage(tab.id, { type: 'GET_SELECTION' }); } });

这里有一个我实际踩过坑的细节:直接用window.getSelection()在大多数页面都能拿到文字,但在一些Shadow DOM封装比较重的页面(比如某些文档类站点)会拿不到完整的选中内容。后来我做了一个降级方案,如果getSelection()返回的内容过短或者为空,就尝试从document.activeElement里读取用户的选区,因为很多富文本编辑器把选区状态存在了可编辑元素内部。

2.2 去重与清洗:别让知识库变成垃圾场

采集功能最容易被忽略的地方是数据清洗。如果原样把网页上的文字存进去,你会发现里面全是导航栏文字、版权声明、页面底部那些"联系我们"之类的东西。虽然用户是选中文字才触发捕获,但很多时候选区会不小心带进一些干扰内容,比如代码块两侧的空格、隐藏的Zero Width Space字符(ZWNBSP,零宽不换行空格)、还会把多段完全不相关的文字拼到一起。

捕获的时候我会在content script里做一次预处理,清洗逻辑分成三步。第一步是把\u200b、\u200c这类不可见字符全部剔除,这些字符在网页里常见于分词或排版标记,存进文本文件后显示为乱码或占位符;第二步是压缩连续空白,把多个空格、换行统一成单个换行,为后续解析做准备;第三步是识别并截断极长的无断行字符串,那通常是Base64编码的图片或代码混淆结果,对知识库没有任何价值。

去重我用的是内容指纹方案,比全文比对效率高得多。清洗完的文本内容取一个SHA-256哈希,在写入前先查一下数据库里是否已经有这个哈希。实际效果是同一段落从不同页面源存进来,只有第一次会被记录,后面的都会被标记为"重复内容"。指纹去重有个盲区:如果网页在引用同一段话时加了不同的标点或改了几个词,哈希就不一样了。对这种情况我后来加了一个模糊匹配——先比哈希,再对比长度相似且正文文本相似度高于95%的记录,用简单的编辑距离算法就能判断,不需要上向量库。

2.3 上下文保留:没有出处的采集等于没采集

我见过很多人的笔记软件里躺着一条条没有来源、没有时间的孤立文字,等到要引用的时候才去搜索引擎里费劲找原文。这个插件从设计上就把上下文当成一等公民。每次捕获都会自动带上四个字段:当前页面的URL、页面标题、选中文字在页面里的标题结构(如果页面对应选区有明确的h1/h2父级,会一并取到)、以及时间戳。

取标题结构的方法是在选区所在节点上向上遍历祖先节点,找到最近的一个标题节点,存下它的文本。这样你回头看到一条笔记时,能立刻知道它是来自某篇文章的"第三章:组件设计与模块划分"还是"结语",而不是一行孤零零的话。这个细节在整理阶段非常有用,尤其当你需要把内容重新组织成长文时,这些上下文线索能帮你快速判断内容的层级关系。

存储方面,数据先写IndexedDB,因为采集频率不高,但单条数据里有纯文本、URL、标题等多个字段,用localStorage存JSON容易撑爆。IndexedDB的读写是异步的,插件在捕获后需要给用户一个即时反馈,我一般会存完再弹一个轻提示,而不是先弹提示后异步存储,因为后者如果索引出问题,用户以为存了实际没存上,非常误导。

3. 模块二:侧边栏AI助手

3.1 Side Panel API:不遮挡主内容的对话区

AI助手这块我一定要单独聊。很多扩展做AI对话功能都是用popup,但popup有几个先天问题:一是它默认悬浮在工具栏下面,面积有限;二是你在阅读一个页面的时候,popup会遮挡内容,用户为了看AI输出反而要缩小或重排当前页面;三是popup的生命周期太短,一旦用户点击页面其他位置,popup就关了,之前对话的记录全部丢失。

后来我换成了Chrome的Side Panel API,也就是chrome.sidePanel。侧边栏固定在浏览器窗口右侧,和主内容区是并列关系,不遮挡正文,而且它的生命周期和页面切换解耦,你在A页面打开侧边栏,切到B页面侧边栏还保持原样,这对"边读边问"的场景极其友好。

Manifest里需要声明sidePanel权限,然后在service worker里注册侧边栏行为:

{ "permissions": ["sidePanel"], "side_panel": { "default_path": "sidebar/index.html" } }

侧边栏本身就是一个独立的HTML页面,可以正常加载JavaScript、使用浏览器API,唯一要注意的是它的网络层面受浏览器安全策略限制。身份认证信息、API密钥这些都不能硬编码在sidebar页面里,否则容易被其他扩展或恶意页面探测到。我的做法是专门在background里调用AI服务,侧边栏只负责收集用户输入和渲染结果,密钥和模型配置都放在background里,两边通过消息传递沟通。

3.2 页面上下文拼装:让AI知道你正在看什么

AI助手的价值不止于通用聊天,它最有用的是能结合当前页面做定向分析。你在读一篇讲"分布式事务"的文章,看不太懂里面Saga模式的实现,选中那几段话,直接让侧边栏"解释这段文字中Saga模式的状态机流转",它需要同时拿到选中的文本和页面的整体上下文,才能给出合理的回答。

我实现了一个上下文集的概念,每次调用AI都会带上三层信息:当前页面的URL和标题,用户当前选中的文本,以及本次对话的历史消息。这三层信息在生态里是分层的,URL和标题进入系统的上下文提示词,选中文本作为本次请求的输入主体,历史消息则单独作为一个消息数组传给模型接口。

需要注意的是token预算问题。有些页面标题很长,带上完整URL一起塞进提示词很容易浪费token,尤其是英文页面里一堆追踪参数,像?utm_source=xxx&utm_campaign=yyy这种。入参前我会做一次URL清洗,把所有query参数去掉,只保留协议、域名和路径。

3.3 流式输出与状态管理:别让人盯着空白等

调用AI服务如果不用流式输出,体验会很难受。一个长回答可能要等十几秒才一次性返回,用户还以为扩展卡死了。用流式输出的话,文字是一段一段出现的,即使生成速度慢,至少用户在视觉上能看到"它正在工作",这心理感受完全不一样。

我当时用的是fetch配合ReadableStream解析SSE(Server-Sent Events)数据流。各家AI服务商返回的流协议大同小异,核心解析逻辑是这样的:

async function* streamAI(messages) { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: MODEL_NAME, stream: true, messages }) }); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const payload = trimmed.slice(5).trim(); if (payload === '[DONE]') return; try { const json = JSON.parse(payload); const delta = json.choices?.[0]?.delta?.content || ''; if (delta) yield delta; } catch (e) { console.warn('parse stream json error', e); } } } }

这里有个容易踩的坑:TextDecoder解码时如果遇到多字节字符被分成两个chunk,直接拼接会出乱码。用decode(value, { stream: true })然后手动管理buffer就可以处理这个问题。另外SSE消息的换行符可能随着服务商不同而不同,有些是\r\n有些是\n,统一用\n切分后trim一下是最稳的。

状态管理方面,侧边栏里我维护了一个简单的对话状态:空闲、生成中、出错。生成中的时候输入框禁用以避免并发请求,同时给回答区域设置一个自动滚动开关,如果用户主动往上滚动查看历史内容,就停止自动滚动,避免内容跳来跳去影响阅读。

3.4 密钥管理:最容易被忽略的安全细节

插件的AI服务调用需要一个API密钥。很多人图省事直接把密钥写死在代码里或者放在chrome.storage.sync里。写死肯定不行,因为代码要提交到仓库,只要仓库一公开,密钥就泄露了。放chrome.storage.sync同样有风险,sync的数据会和浏览器账户同步,一旦账户同步被攻破或者另一个扩展恶意读取,密钥也不安全。

我采用的设计是密钥只存在chrome.storage.local里,配合chrome.storage.session的会话级存储保存运行时变量。从UI层面做一次简单的访问控制:只有打开过侧边栏并输入过密钥的用户,才能触发AI请求;请求时会先检查密钥是否存在,不存在就直接提示让用户配置,而不是在黑盒里报一个看不懂的错误。虽然local的存储也不是绝对安全,但至少不跨设备同步,降低暴露面。

4. 模块三:知识库同步与导出

4.1 为什么要统一存成纯文本和Markdown

采集功能解决了"内容怎么进来"的问题,接下来面对的是"内容怎么出去"。知识管理工具千千万万,今天用这个笔记软件,明天迁移到另一个,如果采集的数据格式被某个特定软件的私有格式绑死,后续迁移成本会极其痛苦。所以我从一开始就要求所有数据都以纯文本和Markdown这两种格式存储和导出。

纯文本是底线,不需要任何额外解析器就能打开、搜索、比较。Markdown则是折中方案:既能保留结构信息,比如标题层级、列表、代码块、引用,又不需要依赖某个特定的数据库或渲染引擎。我在导出模块里把Markdown作为主要的文件格式,每一条采集记录生成一个.md文件,文件名按"日期-时间-随机四字符"生成,避免文件名冲突。文件内部结构是标准化的:

# 页面标题 > 来源:[页面标题](https://example.com/article) ## 采集信息 - 时间:2025-01-15 14:30:22 - 来源URL:https://example.com/article#section-3 - 所属章节:第三章:系统架构 - 标签:#知识管理 #插件开发 ## 正文内容 这里是清洗后的选中文字,保留原有的段落结构和列表结构。

为什么要在导出文件里带上"采集信息"这一整段?因为知识管理最怕的就是内容脱离出处。哪怕是复制到其他工具里,只要这个文件的头部还带着来源URL和采集时间,内容就不会变成无根之萍。这里再补充一个细节:标签字段在导出时一般是空着的,因为标签整理这个动作本身就是一种主动思考,自动打标签我做过验证,准确率不够,反而会导致后续搜索时标签噪声太大。

4.2 导出方式:下载、复制、推送,按场景选路径

导出模块我实现了三条路径,场景不同用不同的路径。单人本地使用的时候,直接下载文件最省事,浏览器自动把Markdown文件存到下载目录,不额外依赖任何服务。复制到剪贴板适合快速把内容塞进当前正在写作的文档里,用navigator.clipboard.writeText()就可以实现,但注意需要页面处于focus状态且拥有Clipboard权限。

推送本地目录是给重度用户准备的。如果你用本地知识库软件,比如Obsidian这类基于本地文件夹的工具,下载文件再手动移过去很烦。我做了个可选配置,插件通过chrome.runtime.connectNative连接一个本地辅助程序,这个辅助程序监听一个本地HTTP端口,插件把Markdown内容POST过去,辅助程序负责写入指定目录。配置界面里有一个目录路径选择框和一个开关,默认关闭,因为不是所有人都需要这种深度联动。

4.3 IndexedDB存储结构:一条记录从采集到导出的完整生命周期

数据的生命周期是这样的。采集插件先把内容写入IndexedDB的一条记录,此时状态是unprocessed。用户打开知识库管理界面,可以看到所有未处理的记录,可以做三件事:标记为已读、打标签、删除。标记为已读的记录会留在一个独立的集合里,方便后续检索。当用户执行导出操作时,选中状态为已读的记录,按时间排序生成Markdown内容,写入导出队列。

IndexedDB里的对象仓库(object store)设计不复杂,核心就一张表:

const store = { id: 'uuid', content: '清洗后的纯文本', source_url: 'https://...', page_title: '页面标题', section_title: '所属章节标题', captured_at: 'ISO时间戳', status: 'unprocessed | processed | deleted', tags: ['标签1', '标签2'], fingerprint: 'sha256指纹' };

索引建在status和captured_at上。实际使用中,status索引用于快速筛选待处理记录,captured_at索引用于按时间范围统计采集量。索引建多了写入会变慢,我之前试过给source_url也加索引,但查了一下发现根本没有按URL查的需求,反而每次写入要多维护一个索引,徒增开销,后来就去掉了。

4.4 批量操作与冲突处理

采集中经常会遇到这种场景:一天攒了二十多条未处理的记录,晚上集中整理时想给其中七八条统一打上同一个标签。批量操作我给每条记录的checkbox加了一个"全选当前筛选结果"的功能,点击后把当前列表页所有记录的ID收集起来,再一次遍历更新状态。这里有一个容易犯的错是复查更新计数——如果你用for循环一个个更新,每次更新都要await一次,七八条还没问题,但记录多了之后,用户很可能以为卡住了。

我后来改成了一次请求、循环写入的设计:前端收集所有ID,调用一次后台的bulkUpdate接口,后台遍历写入。这样前端只展示一个"正在更新N条记录"的进度提示,体验比每一条都弹个状态要好很多。冲突处理主要是针对本地辅助程序写入失败的情况,比如目录不存在、磁盘满了、辅助程序没启动。这种情况我不会直接把记录标成processed,而是留一个export_error字段,在管理界面单独列出导出失败的项目,让用户有机会重试。

5. 模块四:网页阅读辅助插件

5.1 长文阅读时的标注与回想

有了采集和AI,知识工作还有一个高频场景没覆盖到:在阅读页面时做标注。在线阅读和纸质阅读不一样,纸质书你可以用笔划线、折角,网页上大多没有这种机制,除非你打开的是某个自带评论或者高亮功能的平台。但平台只在自己的站点里支持,跨平台就没有了。

网页阅读辅助插件解决的就是这个空白。它不依赖页面本身的代码,通过content script注入,你选中一段文字后可以在右键菜单里"高亮"它。高亮的表现形式是给选区包裹一层带背景色的span标签,这样文字保留了原有的DOM结构,不影响页面的其他交互。这个功能实现起来有个难点,或者说一个需要权衡的地方:改动DOM之后,如果用浏览器自带的"后退"功能,高亮标记往往就消失了,因为那是JavaScript注入的,而不是存储在页面本身。

我的解决思路是:高亮信息不存页面,而是存IndexedDB。每次看到一篇新文章,如果检测到当前URL和标题和数据库里某条记录匹配,就自动重新渲染高亮。同时提供一键导出所有高亮的摘录,格式和采集插件类似,也是Markdown,但会把高亮段落和原文段落并列出来:

> 原文:这里是被高亮的原文段落…… > 摘录:这里是你自己写的批注……

5.2 正文提取与阅读模式

阅读辅助还有一个功能是"阅读模式"——把杂乱无章的网页内容提取成干净的正文,去掉导航、广告、页脚等噪音。实现这个功能的方法很多,从简单的正则匹配到机器学习模型都有。对浏览器插件来说,最实用的还是基于阅读性的算法。有一类经典算法是计算每个DOM节点下的文本密度和链接密度,密度高且链接占比低的节点,往往就是正文区域;文本稀疏的节点则大概率是导航、广告或补充阅读部分。

阅读模式的实现本质上就是找到正文节点后用CSS重排样式。这里需要注意一个语种区分的问题:中文和英文的正文密度特征差别很大。英文单词之间有空格,文本密度统计相对好算;中文没有空格,同样的字符数算出来的密度要乘一个系数。我当时调参的时候在英文页面上效果很好,一换到中文长文就整体失效,后来在密度阈值上对中英文页面分别用不同的参数,不是同一个模型硬套的。

字体、行宽、行高这些参数我做了几个预设档位,用户可以选"舒适"、"紧凑"和"代码优先"三档。代码优先档针对的是技术文档,它会把文中的code块和pre块字体调成等宽字体,背景色加深,行距拉开,因为技术文章里代码块的阅读体验往往比正文更关键。

5.3 稍后读与定时提醒:采集太随意的补偿

阅读模式之外还有一个容易被忽略的实用功能:稍后读。很多时候你扫到一个标题很有意思,点进去看了一眼觉得现在没时间细读,关掉了,然后永远想不起来。这个插件集里我加了一个默认的快捷键:Alt+Shift+R,当前页面立即收入稍后读队列。它不是简单收藏一个URL,而是把当前页面的正文也一起提取存起来,即使原文链接失效你依然能读到内容。

稍后读队列我没做成弹窗或新页面,而是放在了侧边栏里。右侧边栏会分两个标签:一个叫"AI助手",一个叫"稍后读"。稍后读标签里按保存时间倒序排列,点击条目直接在当前标签页打开原始文章,同时在侧边栏底部显示提取好的正文摘要按钮。定时提醒是在插件设置里做得比较简单的小功能,用一个setTimeout链,每隔一段时间检查稍后读队列里有没有存了很久没打开的文章,有的话就弹一条系统通知。这个功能我用了一段实际帮助很大,因为知识管理最大的敌人就是"收藏了就当学过了"。

6. 工程化、打包与分发实践

6.1 工程结构:一个仓库管理多个插件

插件集既然是多个扩展,工程组织方式就很重要。我采用的是pnpm workspace的单仓库结构,根目录下有packages/,里面按插件分目录。每个插件目录里都是一个完整的浏览器扩展,可以独立打包、独立加载、独立分发。共享的代码抽到一个公共包里,比如文本清洗、指纹去重、SSE解析这些工具函数,通过workspace协议引用。

knowledge-work-plugins/ ├── packages/ │ ├── extension-capture/ │ ├── extension-sidebar/ │ ├── extension-sync/ │ └── extension-reader/ ├── packages/shared/ │ ├── src/cleanText.ts │ ├── src/fingerprint.ts │ └── src/sse.ts └── package.json

为什么多插件要用workspace而不是一个插件里通过权限配置做半隐藏模块?因为不同功能的用户群不一样,有人只需要采集,有人只想要侧边栏AI,打包在一起会导致整个插件包体积变大,而且用户对"隐私权限"的顾虑会增加。单独一个插件声明自己的权限范围,用户一看知道这个插件为什么需要"阅读数据"权限,另一个只需要"存储"权限,信任感更强。

6.2 代码共用:避免四份互不相通的实现

四个插件虽然各自独立,但它们处理的数据模型是同源的。比如采集插件的清洗函数,AI助手在读取选中文本时也需要做同样的清洗,不然发给模型的内容里满是乱码和不可见字符;同步插件在导出时也要用同一套时间格式和URL清洗规则。

公共包里的每个工具函数我都加了单元测试,因为这类函数一旦出错,影响的是所有插件。清理函数至少覆盖了中文弯引号转换、英文撇号处理(将英文弯引号、撇号转成直引号)、Unicode零宽字符剔除这几个场景。指纹函数则测试了同一文本不同空白格式的哈希一致性——注意这里我用的是严格字符串输入,不做额外模糊化,所以空格不同就算不同内容,这是有意的,避免把两段完全不相干但因为空白相似的内容误判为重复。

6.3 多浏览器产物的差异处理

浏览器扩展虽然有大多数标准模型,但各厂商的具体实现还是有差异。Chrome和Edge相对统一,都支持Manifest V3,Firefox虽然也支持MV3,但它的browser命名空间和Chrome的chrome命名空间在部分API上行为不一致。实际开发中,Firefox的chrome.*大部分能兼容,但sidePanelAPI在写这篇文章之前Firefox虽然提供,但语义和Chrome不一样,需要加一个判断分支。

打包的时候我直接用一套Vite配置,只是对不同浏览器输出不同的manifest文件。Chrome用标准的manifest.json,Firefox用manifest.v2.json或者根据Firefox要求调整成MV3格式,内容相同但属性细节有差异。这里的经验是:不要在代码里到处写if (browser === 'firefox'),而是把所有差异集中到一个browserEnv.ts文件里,封装成几个方法,比如openSidePanel()、getStorageArea('local'),业务代码只跟这些封装方法打交道,换浏览器时只改一个文件就够了。

6.4 开发调试时的几个关键流程

本地开发调试我强烈建议用"加载已解压的扩展程序"模式,而不是每次打包成crx再装。Chrome扩展管理页面右上角开启"开发者模式",选择"加载已解压",直接把插件目录拖进去就行。每次改动代码,到扩展管理页面点一下刷新按钮,内容脚本和后台脚本会重新加载,但注意service worker不会自动重启,你要去扩展详情页清理一下service worker或者点一下"更新"按钮,否则后台改动不生效。

调试优先用扩展的service worker控制台。在扩展管理页面对应插件卡片上点"检查视图",弹出的DevTools是service worker的独立的调试窗口,console.log会输出在那儿,而不是页面控制台里。这是初学者最容易搞混的点——他们以为代码没跑,其实跑到另一个控制台去了。content script的调试则直接在当前页面的DevTools里看"Sources"面板,因为content script是注入到页面里的,它的运行环境挂在页面自己的调试面板下。

7. 常见问题与排查实录

7.1 划词捕获在某些页面完全无效

我在实际使用中遇到的第一类问题:某些页面划词捕获点了没反应,右键菜单也不出来。排查后发现大多是两种情况。一种是页面里嵌了iframe,选中的文字在iframe内部,而content script默认不会注入到跨域的iframe里。Manifest里content_scripts可以用all_frames: true来覆盖同源iframe,跨域iframe默认还是读取不了的,捕获时上下文里就没有内容。对这个场景我做的降级是:如果没有捕获到选区,就把当前页面的URL和标题记录下来,正文留空,让用户自己决定是否粘贴。

另一种情况是单页应用(SPA)的动态渲染。SPA的DOM是JavaScript动态生成的,content script如果按document_idle时间点注入,可能在页面初始阶段拿不到完整DOM,导致右键菜单无法绑定到元素上。解决方法是监听history变化或者用MutationObserver检测页面主体内容变化,在变化后再初始化事件绑定。这个方法不复杂,但却是SPA场景下很多扩展失效的根源。

7.2 AI请求被CSP拦截

侧边栏AI助手在部分站点上请求模型API时报错,Failed to fetch以及CSP相关提示。原因很典型:有些站点的内容安全策略设置很严格,阻止了扩展页面向外部域名发请求,即使请求是在侧边栏页面发出的。Chrome扩展页面默认不受宿主页面CSP影响,但如果是通过某个页面注入的方式去调用,情况就不同了。

解决方案是把fetch请求放在service worker里做。service worker的网络安全策略和content script不同,它有独立的来源,可以不受页面CSP约束。侧边栏只负责组装消息,通过chrome.runtime.sendMessage把请求参数发给service worker,service worker调用完模型接口再用同样的方式把结果返回。改完之后问题就消失了,这个架构调整顺便提升了安全性,因为API密钥始终只存在于service worker环境里。

7.3 IndexedDB数据损坏或版本升级

IndexedDB本身比较稳定,但升级过程中容易出现"版本不匹配"的问题。如果你在开发中新加了字段或索引,版本号要递增,同时要提供onupgradeneeded的迁移逻辑。不然的话,用户装新版本后打开插件时浏览器会抛一个类似The requested version exceeds the current version的错误。调试者的第一个反应往往是"插件坏了",其实只是数据库版本冲突。

我后来把数据库访问封装成一个单例模块,内部维护一个migrations数组,每个迁移都写明要做什么操作。新版本来了,按顺序执行迁移即可;迁移失败就回滚到旧版本并弹出一个明确错误提示,不会出现半迁移状态。数据库结构相对稳定期间,我不会频繁改字段,因为改一次要写一次迁移代码,成本不高但容易漏,漏了就是线上数据问题。

7.4 侧边栏和popup同时打开导致状态不同步

开发过程中还有一个很隐蔽的状态管理问题:当你给AI助手提供了侧边栏入口,又顺手加了一个popup入口用于快速启动对话,两个界面对应同一个后台状态,如果后台没有做同步,可能会出现侧边栏里已经聊了十轮,popup打开后却显示空白,或者拿着一个旧的会话在跟你对话。

解决方案是状态统一放在chrome.storage.session里管理,它保持会话级实时同步,所有打开的面板都能读取同一份状态。侧边栏和popup都监听storage.onChanged事件,一旦状态变化就立即刷新界面。这里有一个性能小优化,监听storage.onChanged时要注意过滤掉不必要的key变化,不然任何一个配置项的修改都会触发所有面板的重绘,白白浪费资源。

7.5 本地辅助程序端口冲突

同步插件的本地辅助程序监听固定端口时,如果用户本地已经有一个服务占了同一个端口,就会启动失败。为这个问题我做了端口探测,启动时先尝试连接默认端口,如果被占用,自动顺延到下一个可用端口,并把这个端口号通过一个本地临时文件传回给浏览器插件端。插件读取到这个临时文件后,后续的POST请求都发往这个自动分配好的端口。

这个做法在单机使用场景下足够可靠。多用户同时使用同一个辅助程序的情况我还没遇到过,因为知识库本来就应该是私有的,如果真有多人共享的需求,那应该改造的是辅助程序本身的鉴权,而不是端口策略。

8. 最后分享几个实际经验

插件集跑了大半年,功能加起来不少,但我自己最常用的反倒是划词捕获和侧边栏AI这两个。原因很简单,知识管理的核心不在于工具多复杂,而在于"捕获到整理"这段路径摩擦够不够低。如果每次想记点什么需要打开一个专属软件、新建一条笔记、填一堆字段,那你迟早会放弃记录。插件形式的好处是它寄生在使用频率最高的浏览器里,做笔记的动作跟阅读的动作几乎是一体的。

有一点让我特别有成就感的功能是侧边栏AI助手在阅读外文资料时做的即时摘要。以前遇到一篇英文长文,想判断值不值得精读,得先复制标题去别处搜,或者整篇粗读一遍才发现没太多内容。现在选中摘要段落,侧边栏直接给我一个三段式概要:核心论点、支持论据、我的对比意见。虽然AI的总结不一定百分百准确,但筛掉明显不符合需求的文章足够了,剩下值得精读的再花时间去读,整体效率提升非常明显。

如果你打算自己复刻这套方案,我建议你别一开始就把四个模块全做出来,先从划词捕获加本地存储做起,用上一个月感受一下知识采集的频次和需求,再决定要不要加AI能力。在真实使用需求还没验证之前就急着堆功能,最后大概率会得到一个功能很多但从未打开过的工具集。

还有一个小建议:给采集的数据定期做整理,别指望AI替代人的判断。AI可以帮你清洗文字、生成摘要、提取关键词,但"这篇文章值得我记住什么"这件事,只有你自己在整理的时候才知道。插件是帮你降低整理的门槛,不是替你思考。这个边界想清楚,工具会越用越顺手,数据库里沉淀下来的内容才会真正有价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进