资讯动态

本地优先浏览器插件:打造中文AI情报聚合与推送系统

发布时间:2026/10/21 18:52:52 来源:尧图企业网站定制
1. 信息过载时代的个人情报站搭建思路每天打开浏览器光是各种技术社区、行业媒体、社交平台推送的AI相关资讯就够刷上大半个小时。更麻烦的是大量优质内容还是英文的中文圈子里真正有信息密度的讨论又散落在各个角落。我粗略统计过自己过去一个月的浏览记录光是主动打开又关掉的资讯页面就有四百多个真正值得细读的不到十分之一。这种状态下我决定不再被动地被算法投喂而是自己动手做一个本地优先的浏览器插件让中文AI情报主动来找我。这个项目的核心目标很明确把分散在各处的AI相关中文内容聚合起来在浏览器层面做一层轻量的信息过滤和推送所有数据和处理逻辑都跑在本地不依赖任何云端服务。它解决的不是“信息获取”的问题而是“信息筛选”和“主动触达”的问题。适合那些每天需要跟踪AI领域动态、但又不想被信息洪流裹挟的开发者、产品经理和技术爱好者。哪怕你只会写一点JavaScript也能跟着我的思路把这个东西搭出来。为什么强调“本地优先”因为资讯聚合类工具最让人担心的就是隐私和数据流向。你浏览了什么、对什么内容感兴趣、在哪些页面停留了多久这些数据一旦上传到某个服务器就完全脱离了你的控制。本地优先意味着所有抓取、解析、匹配、存储都在你的浏览器里完成插件不需要任何后端接口也不需要你注册账号。你甚至可以断网使用已经缓存的内容。这个选择在架构上会带来一些限制比如无法做跨设备的同步但换来的是完全的数据自主权我认为这笔账是划算的。在技术选型上我对比了几种方案。用油猴脚本是最轻的但它的生命周期管理比较弱多个脚本之间容易冲突而且UI定制能力有限。用Electron做一个独立应用倒是可以但那样就脱离了浏览器的使用场景每次还要单独打开一个应用反而增加了操作成本。最终我选择了浏览器插件的形式具体来说是Manifest V3规范下的扩展程序。MV3对后台脚本的限制比较严格Service Worker不能常驻但这反而逼着我用更事件驱动的方式去设计减少了不必要的资源消耗。对于需要定时抓取的任务可以用chrome.alarms API来替代setInterval既符合规范又省电。整个插件的功能模块可以拆成四层内容采集层、解析过滤层、本地存储层和展示推送层。内容采集层负责从用户配置的源站拉取数据这里我用的是RSS和公开API两种方式RSS覆盖面广、格式统一公开API则用于那些提供结构化数据的平台。解析过滤层是核心它要把原始内容拆成标题、摘要、来源、时间戳、关键词等字段然后根据用户设定的兴趣标签做匹配打分。本地存储层用IndexedDB来存因为资讯数据量会随着时间增长localStorage的5MB上限很快就不够用了。展示推送层则包括插件弹窗、新标签页替换和桌面通知三种形式用户可以根据自己的习惯选择。注意Manifest V3的Service Worker在闲置约30秒后会被终止所有依赖全局变量的状态管理都会失效。必须把状态持久化到chrome.storage或IndexedDB中每次唤醒时重新读取。这个架构最大的优势是解耦。每一层都可以独立替换比如你不想用RSS了换成爬虫解析某个网页只需要改采集层后面的逻辑完全不用动。过滤层的打分算法也可以随时调整权重不影响其他模块。我在实际开发中就是先把采集和存储跑通确认数据能稳定落库之后再慢慢打磨过滤和展示的部分。这种渐进式的做法比一上来就追求大而全要靠谱得多尤其是个人项目保持每个阶段都能跑起来比什么都重要。2. 核心细节解析与实操要点2.1 内容源的选取与采集策略内容源的质量直接决定了整个插件的信息价值。我一开始贪多一口气配了二十多个源结果发现每天抓回来的内容里有大量重复和低质信息过滤层根本忙不过来。后来我做了减法把源分成三个梯队第一梯队是更新频率稳定、内容质量有保障的少数几个每天必抓第二梯队是更新不那么规律但偶尔有深度内容的每两天抓一次第三梯队是备选源只在手动触发时才去拉取。这样既保证了日常的信息覆盖又不会让本地数据库膨胀得太快。具体到采集方式RSS依然是最可靠的选择。它的结构固定解析起来不费劲而且大部分技术社区和独立博客都提供RSS输出。我用的是浏览器原生的DOMParser来解析XML不需要引入额外的库。对于不提供RSS的源我会先看它有没有公开的JSON接口如果有就直接fetch。如果两者都没有才会考虑用content script去解析页面DOM但这种方式维护成本高页面结构一变就得跟着改所以我尽量不用。采集频率的设置也有讲究。我试过每15分钟抓一次结果发现很多源一天才更新一两篇大部分请求都是白跑。后来改成根据源的历史更新规律来动态调整间隔更新频繁的源间隔短一些更新少的源间隔长一些。具体实现是在IndexedDB里给每个源存一个lastUpdated和averageInterval字段每次抓取后更新这两个值下次的抓取时间就根据averageInterval来计算。这个逻辑不复杂但效果很明显请求量降了大概六成。// 动态计算下次抓取时间的核心逻辑 function calculateNextFetch(source) { const baseInterval source.averageInterval || 3600000; // 默认1小时 const jitter Math.random() * 0.2 * baseInterval; // 加20%以内的随机抖动 return Date.now() baseInterval jitter; }加随机抖动是为了避免多个源在同一时刻集中请求造成瞬时资源占用过高。这个细节在源数量少的时候感觉不出来但当你配了十几个源之后错峰的效果就很明显了。2.2 中文内容的识别与关键词匹配既然定位是“中文AI情报”那就需要把中文内容从混合语种的信息流里挑出来。我用的判断逻辑比较简单统计标题和摘要中中文字符的占比超过30%就认为是中文内容。这个阈值是我试出来的太低会把一些中英混合但以英文为主的内容误判进来太高又会漏掉一些标题是英文但正文是中文的条目。实际跑下来30%这个值在准确率和召回率之间平衡得比较好。关键词匹配这块我没有用复杂的NLP模型而是采用了“关键词表权重”的方式。关键词表分成核心词和扩展词两类。核心词是“大模型”“机器学习”“深度学习”“神经网络”这种指向性很强的词命中一个就加较高的分。扩展词是“推理”“训练”“微调”“部署”这种在AI语境下常见但其他领域也可能出现的词命中后加的分少一些。最终每条内容会得到一个匹配分数分数超过阈值的才会进入展示队列。关键词表不是写死的我留了一个配置文件让用户可以自己增删。这个设计很重要因为每个人的关注点不一样。有人关注AI绘画有人关注语音识别有人关注推荐系统用一个固定的词表去覆盖所有人是不现实的。我自己的词表里就加了“扩散模型”“文生图”“语音合成”这些偏应用层的词因为我的工作跟这些方向关系比较大。实操心得关键词匹配时要注意词边界问题。比如“Transformer”这个词如果不做边界处理“Transformers”也会被匹配到虽然两者相关但有时候用户可能只想看前者。我的做法是在匹配时检查前后字符是否为字母或数字如果是则视为不匹配。这个细节能减少不少误报。另外我还加了一个简单的去重逻辑。不同源之间经常转载同一篇文章如果不去重展示列表里会出现大量重复条目。去重的方式是对标题做归一化处理去掉标点、空格、大小写差异然后计算SimHash值相似度超过阈值的视为重复。SimHash的实现不复杂网上有很多参考我这里就不展开代码了。实测下来去重能砍掉大约四分之一的内容量展示列表干净了很多。2.3 本地存储方案的选择与优化存储方案我前面提到用IndexedDB这里展开说一下为什么不用其他方案。localStorage的容量限制是5MB左右对于纯文本的资讯数据来说如果只存最近几天的内容勉强够用。但我想保留至少一个月的的历史数据方便回溯和搜索5MB就捉襟见肘了。chrome.storage.local的容量大一些但它的API是异步的而且在大批量读写时性能不如IndexedDB。IndexedDB虽然API用起来麻烦一点但它的容量上限高得多而且支持索引和游标做范围查询和分页很方便。我在IndexedDB里建了两个对象仓库一个存源配置一个存资讯条目。资讯条目的主键用自增ID同时给timestamp和sourceId建了索引这样按时间范围查询和按来源筛选都能走索引速度很快。每条记录大概占1-2KB存一万条也就十几MB完全在可控范围内。// IndexedDB初始化与对象仓库创建 const DB_NAME ai-intel-db; const DB_VERSION 1; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(sources)) { db.createObjectStore(sources, { keyPath: id, autoIncrement: true }); } if (!db.objectStoreNames.contains(articles)) { const store db.createObjectStore(articles, { keyPath: id, autoIncrement: true }); store.createIndex(timestamp, timestamp, { unique: false }); store.createIndex(sourceId, sourceId, { unique: false }); store.createIndex(score, score, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }清理策略也是必须的。我设置了一个上限比如最多保留5000条记录超过之后按时间从旧到新删除。这个清理动作放在每次抓取完成之后执行用游标遍历索引删够数量就停。不要一次性删太多否则会阻塞主线程影响插件的响应速度。我一般每次清理不超过200条分多次完成。2.4 展示层的交互设计与推送时机展示层我做了三个入口插件图标点击后的弹窗、新标签页的替换页面、以及桌面通知。弹窗适合快速浏览最新几条新标签页适合坐下来仔细筛选桌面通知则用于那些匹配分数特别高的内容做到真正的“主动来找你”。弹窗的设计尽量简洁只显示最近24小时内匹配分数最高的10条每条显示标题、来源和时间点击直接在新标签页打开原文。底部有一个“查看更多”的链接点击后跳转到完整的新标签页界面。这个设计参考了邮件客户端的未读列表用最少的信息让用户快速判断哪条值得点开。新标签页的界面就丰富一些支持按来源筛选、按时间范围筛选、按关键词搜索还有一个“已读/未读”的标记功能。已读状态存在IndexedDB里跟条目记录关联。这个功能看起来简单但实际用起来很提升体验因为你可以清楚地知道哪些是已经看过的哪些是新的。桌面通知的触发条件我调了好几次。一开始设的是分数超过80就通知结果一天弹了十几次很快就烦了。后来改成两个条件同时满足才通知分数超过85且该来源在过去24小时内没有发过通知。这样既保证了高价值内容的及时触达又避免了同一来源的连续轰炸。通知的点击行为也做了处理点击后直接打开原文同时把该条标记为已读避免重复提醒。注意桌面通知需要用户明确授权而且不能滥用。我的做法是在插件安装后的第一次运行时用一个引导页面说明通知的用途让用户自己决定是否开启。如果用户拒绝后续就不再请求只在弹窗里做红点提示。3. 实操过程与核心环节实现3.1 项目初始化与Manifest配置动手的第一步是创建项目目录和manifest.json文件。Manifest V3的配置跟V2有不少差异我踩过的坑主要集中在这几个地方background脚本从page变成了service_worker不能再引用DOM APIhost_permissions和permissions的分离更加明确web_accessible_resources的写法也变了。下面是我实际使用的manifest配置去掉了跟这个项目无关的字段。{ manifest_version: 3, name: AI情报聚合器, version: 1.0.0, description: 本地优先的中文AI资讯聚合与推送工具, permissions: [ storage, alarms, notifications ], host_permissions: [ https://*/* ], background: { service_worker: background.js }, action: { default_popup: popup.html, default_icon: icon.png }, chrome_url_overrides: { newtab: newtab.html }, icons: { 48: icon.png, 128: icon.png } }host_permissions我用了通配符因为用户可能会添加任意来源的RSS地址没法提前枚举。如果你对安全性要求更高可以在用户添加源的时候动态申请权限但那样交互会麻烦一些。我权衡之后选择了通配符方案毕竟所有请求都是用户主动配置的风险可控。Service Worker的注册和事件监听是后台逻辑的入口。这里要注意Service Worker随时可能被终止所以所有的事件监听必须在顶层注册不能放在异步回调里。我一开始把alarms的监听写在了IndexedDB初始化完成之后结果经常出现闹钟触发了但监听没注册上的情况。后来改成在顶层直接注册问题就消失了。// background.js 顶层事件注册 chrome.alarms.onAlarm.addListener(handleAlarm); chrome.runtime.onInstalled.addListener(handleInstall); chrome.notifications.onClicked.addListener(handleNotificationClick); // 初始化闹钟 chrome.alarms.create(fetch-articles, { periodInMinutes: 30 });3.2 抓取与解析的完整流程抓取流程从闹钟触发开始到数据写入IndexedDB结束。中间要经过源列表读取、逐个请求、内容解析、去重、打分、存储这几个步骤。我把每个步骤都拆成了独立的函数方便单独测试和替换。请求部分我用的是fetch API配合AbortController做超时控制。超时时间设的是15秒超过就放弃该次请求记录错误但不影响其他源。错误处理很重要因为有些源会偶尔抽风如果不做隔离一个源的失败可能会阻塞整个流程。async function fetchSource(source) { const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 15000); try { const response await fetch(source.url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) throw new Error(HTTP ${response.status}); const text await response.text(); return parseFeed(text, source); } catch (err) { clearTimeout(timeoutId); console.error(抓取失败: ${source.url}, err); return []; } }解析RSS我用DOMParser把XML字符串转成DOM树然后遍历item节点提取字段。这里要注意命名空间的问题有些RSS使用了content:encoded这样的带前缀标签querySelector的时候需要转义冒号。我一开始没注意导致部分源的内容摘要提取不出来排查了好一会儿才发现是选择器写错了。function parseFeed(xmlText, source) { const parser new DOMParser(); const doc parser.parseFromString(xmlText, text/xml); const items doc.querySelectorAll(item, entry); const articles []; items.forEach(item { const title item.querySelector(title)?.textContent?.trim() || ; const link item.querySelector(link)?.getAttribute(href) || item.querySelector(link)?.textContent?.trim() || ; const pubDate item.querySelector(pubDate, updated, published)?.textContent || ; const description item.querySelector(description, summary, content)?.textContent || ; if (title link) { articles.push({ title, link, sourceId: source.id, sourceName: source.name, timestamp: pubDate ? new Date(pubDate).getTime() : Date.now(), summary: stripHtml(description).slice(0, 300), score: 0, read: false }); } }); return articles; }打分环节在解析之后、存储之前执行。每条内容根据标题和摘要中的关键词命中情况计算分数同时结合来源的权重做一个加权。来源权重是我手动设置的质量高的源权重高一些这样同样内容从不同源进来分数会有差异展示时优质源的内容会排在前面。3.3 去重与打分算法的实现细节去重我用的是标题归一化加SimHash的方案。归一化就是去掉标点、空格统一转小写。SimHash的计算过程是把文本分词后对每个词计算哈希值然后按位加权求和最后降维得到指纹。两个指纹的汉明距离小于3就认为是重复内容。这个阈值也是试出来的太小会漏掉一些改写过的重复内容太大会把不相关的相似标题误判为重复。function simhash(text) { const tokens text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]/g, ).split(/\s/).filter(Boolean); const vector new Array(64).fill(0); tokens.forEach(token { const hash stringHash(token); for (let i 0; i 64; i) { vector[i] (hash i) 1 ? 1 : -1; } }); let fingerprint 0n; for (let i 0; i 64; i) { if (vector[i] 0) fingerprint | (1n BigInt(i)); } return fingerprint; } function hammingDistance(a, b) { let xor a ^ b; let count 0; while (xor) { count Number(xor 1n); xor 1n; } return count; }打分算法的权重配置我放在了一个单独的config对象里方便调整。核心词权重是10扩展词权重是3来源权重是0到5之间的一个系数。最终分数是关键词得分乘以来源权重系数。这个公式很简单但实际效果不错因为关键词的区分度已经足够高了。const SCORE_CONFIG { coreKeywords: [大模型, 机器学习, 深度学习, 神经网络, Transformer, LLM], extendedKeywords: [推理, 训练, 微调, 部署, 数据集, 算法, 模型], coreWeight: 10, extendedWeight: 3 }; function calculateScore(article, sourceWeight) { const text article.title article.summary; let score 0; SCORE_CONFIG.coreKeywords.forEach(kw { if (text.includes(kw)) score SCORE_CONFIG.coreWeight; }); SCORE_CONFIG.extendedKeywords.forEach(kw { if (text.includes(kw)) score SCORE_CONFIG.extendedWeight; }); return score * (sourceWeight || 1); }实操心得关键词匹配时中文不需要考虑词边界但英文关键词需要。我的做法是对英文关键词用正则加\b边界中文关键词直接用includes。这个细节在混合语种的内容里很重要能避免不少误匹配。3.4 通知触发与用户反馈闭环通知的触发逻辑我放在每次抓取完成之后。遍历新入库的条目找出分数超过阈值且来源在冷却期外的逐条发送通知。冷却期的实现是在内存里维护一个sourceId到lastNotifyTime的映射但这个映射在Service Worker重启后会丢失。所以我把lastNotifyTime也存到了IndexedDB的源记录里每次发送通知后更新读取时从存储里取。async function maybeNotify(articles) { const now Date.now(); const COOLDOWN 24 * 60 * 60 * 1000; for (const article of articles) { if (article.score 85) continue; const source await getSource(article.sourceId); if (source.lastNotifyTime now - source.lastNotifyTime COOLDOWN) continue; chrome.notifications.create({ type: basic, iconUrl: icon.png, title: article.title, message: article.summary.slice(0, 100), contextMessage: article.sourceName }); await updateSourceNotifyTime(article.sourceId, now); } }用户反馈这块我加了一个简单的“有用/无用”按钮放在弹窗的每条内容旁边。点击后把反馈存到IndexedDB里后续可以用来调整关键词权重。比如某条内容被标记为“无用”但它的分数很高说明关键词匹配有误报可以把这个案例记下来手动调整词表。这个反馈闭环目前还比较粗糙但已经能帮我发现一些明显的匹配问题了。4. 常见问题与排查技巧实录4.1 抓取失败与解析异常的排查抓取失败是最常见的问题表现是某个源连续多次没有新内容入库。排查的第一步是看控制台有没有报错Service Worker的日志在扩展程序管理页面的“检查视图”里可以看到。常见的错误类型有几种CORS跨域被拦截、源站返回了非XML格式的内容、超时、以及证书问题。CORS问题在Manifest V3里比较微妙。虽然host_permissions声明了权限但有些源站的响应头里没有正确的Access-Control-Allow-Origin导致fetch被浏览器拦截。这种情况我试过用declarativeNetRequest来修改响应头但配置起来比较麻烦。后来我的做法是优先选择那些CORS友好的源实在需要的源就通过一个本地代理来转发但代理方案会增加部署复杂度我最终没有采用。解析异常通常表现为条目数量为零或者字段缺失。我遇到过一次是因为源站返回的XML里包含了BOM头DOMParser解析时把BOM当成了内容的一部分导致根节点识别错误。解决办法是在解析前先去掉BOM字符。还有一次是源站用了CDATA包裹内容querySelector的textContent取不到值需要用CDATA节点的nodeValue来获取。function stripBOM(text) { if (text.charCodeAt(0) 0xFEFF) { return text.slice(1); } return text; }4.2 存储膨胀与性能下降的应对IndexedDB用久了之后如果清理策略没做好数据量会持续增长导致查询变慢、内存占用升高。我遇到过的最严重的一次是数据库涨到了快200MB新标签页打开要好几秒才能渲染出来。排查后发现是清理逻辑有个bug删除操作没有正确提交事务导致旧数据一直堆积。修复之后我加了一个监控机制每次抓取完成后检查数据库的总记录数超过阈值就触发清理。同时在新标签页的渲染上做了分页默认只加载最近100条滚动到底部再加载更多。这个改动让首屏渲染时间从两三秒降到了两百毫秒以内。问题现象可能原因排查方法解决方案新标签页加载缓慢数据量过大一次性查询全部记录在DevTools的Application面板查看IndexedDB大小加分页限制单次查询数量抓取后无新内容源站改版或RSS地址失效手动访问源地址确认是否正常更新源地址或移除失效源通知不触发Service Worker被终止闹钟未注册检查扩展程序页面的Service Worker状态确保alarms在顶层注册关键词匹配误报多词表过于宽泛或缺少边界处理查看高分但实际不相关的内容调整词表英文词加边界去重效果差SimHash阈值设置不当统计重复内容的比例调整汉明距离阈值4.3 跨浏览器兼容性的取舍我最初想同时支持Chrome和Firefox但实际做下来发现兼容成本比预期高。Firefox对Manifest V3的支持还在完善中有些API的行为跟Chrome不一致比如alarms的最小间隔、notifications的图标要求等。我试过用webextension-polyfill来做适配但Service Worker在Firefox里的表现跟Chrome差异较大调试起来很费时间。最终我决定先只支持Chrome系浏览器把功能做稳定。如果你用的是Edge或Brave因为都是Chromium内核直接加载解压的扩展程序就能用。Firefox用户如果确实需要可以等后续版本再考虑适配。这个取舍让我省下了大量兼容性调试的时间把精力集中在了核心功能的打磨上。注意在Chrome里加载未打包的扩展程序时每次修改代码都需要在扩展管理页面手动点击刷新而且Service Worker的缓存有时候会导致旧代码继续运行。我的习惯是修改后先点刷新然后在Service Worker的检查视图里执行一次“跳过等待”或者直接注销再重新注册确保新代码生效。4.4 实际使用中的体验优化记录用了一段时间之后我根据实际体验做了几处优化。第一处是弹窗的加载速度原来每次打开弹窗都要重新查询IndexedDB后来加了一层内存缓存把最近查询的结果暂存起来弹窗打开时先显示缓存后台再异步更新。这个改动让弹窗的响应快了很多几乎感觉不到延迟。第二处是通知的点击行为。原来点击通知只是打开原文但不会标记已读导致同一条内容可能被重复通知。后来在通知的创建参数里加了一个notificationId跟文章ID关联点击时根据ID找到对应记录并标记已读同时更新来源的lastNotifyTime避免重复。第三处是关键词的自动学习。我加了一个简单的机制如果用户对某条内容点击了“有用”就把该内容标题中出现频率高但不在词表里的词提取出来提示用户是否加入扩展词表。这个功能还在试验阶段但已经帮我发现了好几个之前没注意到的关键词。// 从已标记为有用的内容中提取候选关键词 function extractCandidateKeywords(articles) { const freq {}; articles.forEach(article { const words article.title.split(/[\s,。、]/).filter(w w.length 2); words.forEach(w { freq[w] (freq[w] || 0) 1; }); }); return Object.entries(freq) .filter(([word, count]) count 3 !isInWordList(word)) .sort((a, b) b[1] - a[1]) .slice(0, 10) .map(([word]) word); }这个提取逻辑很粗糙但对于个人使用来说够用了。它不需要理解语义只是统计词频然后把高频的新词推荐给用户。用户确认后加入词表下次匹配时就能覆盖到这些新词。整个过程是本地完成的不涉及任何外部服务。最后再分享一个小技巧如果你也想做类似的本地优先工具建议把配置和数据的导入导出功能做好。我用JSON格式做了一键导出换电脑或者重装浏览器时导入配置文件就能恢复所有源和词表省去了重新配置的麻烦。这个功能实现起来很简单但实际用起来非常省心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑