1. 项目起因与整体思路:被 AI 信息洪水淹没之后的“反叛”
我每天打开电脑的第一件事,不再是写代码,而是刷各类 AI 资讯:公众号推送、技术社区热帖、模型发布公告、开源项目的 release note,还有各种榜单和复盘。刷了半小时之后,大脑里剩下的往往只有一句“噢,好像有个新品发布了”,至于它解决了什么问题、跟我的技术栈有什么关系、值不值得我花一晚上读源码,完全是一片模糊。
这个项目标题里“中文 AI 情报自己来找你”说的就是我做这个插件之前最真实的状态:信息获取这件事已经从“人主动找信息”变成了“信息来轰炸人”。我做了一个本地优先的浏览器插件,把中文 AI 资讯的采集、清洗、筛选、摘要全部放到本地完成,最终只把值得看的内容以通知和聚合列表的形式推到我面前。它不是一个内容平台,也不是又一个订阅列表页,而是一条能把“噪音和信号分离”的信息管线。适合长期被推荐算法拿捏、又不想把自己每天的阅读清单交给别人决定的人,也适合想熟悉浏览器插件完整开发链路、顺手提升信息效率的开发者参考。
1.1 我踩过的信息过载深坑
先说说我为什么受不了。中文 AI 圈的信息源极其分散,有人只在某社区发长文,有人只在公众号更新,有人天天在社交平台刷屏,还有人发布会开完只在官网上挂一页 PDF。想盯全,意味着每天早上要把五六个平台全部扫一遍。更麻烦的是,这些平台的推荐机制是按照热度来的,不是按照“跟我的相关性”来的。我搞后端多一些,结果推送给我最多的反而是前端工具链的新闻;我对推理优化感兴趣,结果满屏都是跟我的场景八竿子打不着的行业融资稿。
这种情况在设计上有个专门的说法叫信息过载。当信息量超过人的处理能力时,决策质量和行动意愿都会明显下降。我自己最典型的症状就是“收藏了等于学会了”:看到一篇长文先丢进收藏夹,想着周末读,结果周末打开收藏夹发现两百多条未读,直接放弃。做这个插件之前,我先给自己定了一个很朴素的评判标准:一条 AI 资讯的“值钱程度”,取决于它能不能让我产生一个具体的后续动作。所谓动作可能是“把某个模型接入我的项目做一次评测”,也可能是“修改我本地工具的某个默认参数”。没有任何潜在动作的信息,不管多热门,对我来说都是噪音。
这个标准后来变成插件里的一整套打分逻辑。过滤不是靠我记住几百个关键词,而是靠一套“标题命中 + 正文权重 + 来源可信度”的本地计算规则。前端只需要一个简洁列表,后台像一个尽职的筛选员,先把所有信息粗筛一遍,再挑出值得打扰我的那几条。
1.2 为什么是浏览器插件而不是 App、网页服务或脚本
在动手之前,我其实考虑过三种形态:做一个独立的桌面 App、做一套定时跑的爬虫脚本、再就是浏览器插件。桌面 App 的问题是太重。我的核心需求只是“每天看几眼可信的中文 AI 信息”,为一个列表功能装一个常驻内存的程序,性价比太低。爬虫脚本的问题是没有展示端。脚本跑完了把结果写进文件,我还是得再打开另一个工具去看,跟原来的信息获取路径没有任何区别,只不过从刷 App 变成了刷文件。
浏览器插件刚好卡在中间:它有常驻后台做定时任务,有独立的存储空间保存历史数据,有弹窗承载轻量交互,还能直接调浏览器的通知能力。对用户来说,不需要额外打开任何页面,插件加载在当前浏览器环境里,看到通知点一下就能展开详情。对开发者来说,一个插件的开发成本远低于维护一个服务端加客户端的完整闭环。
而且这里有一个非常重要但经常被忽略的优势:浏览器插件的权限模型是相对干净的。我在清单文件里声明了需要访问哪些站点、需要哪些权限,用户在安装时能看到。比起在本地跑一个拥有全部文件读取权限的爬虫脚本,插件的“越权风险”要低很多,这也符合我一开始设想的本地优先里的“可控”原则。
1.3 本地优先具体指什么,边界在哪里
“本地优先”这四个字在技术圈有很多层含义,在不同语境下指的东西不太一样。我只说说我这个项目里的定义:数据的获取、处理和存储首先发生在本机浏览器环境中,云端只作为信息源的最终出口,而我自己的任何业务逻辑都不依赖服务端。
具体来讲,插件周期性地从我订阅的 RSS 源和公开信息接口拉取内容,解析之后把原始数据、处理中间态和最终筛选结果全部写进浏览器的本地数据库。摘要提取用的是 JavaScript 的文本处理逻辑,在本地完成;关键词打分也是在本地遍历;只有最终展示出来时,才按需向来源站点发起内容请求。这样一来,即使某一天某平台修改了接口,或者某个远程服务挂了,至少我的本地历史数据不会丢,插件的基本功能也不会立即瘫痪。
当然,本地优先也有边界。比如插件无法做到真正的跨设备状态同步,这个问题在后面扩展部分我会单独聊。另一个边界是存储容量,虽然 IndexedDB 的配额在绝大多数场景下够用,但如果订阅源非常多、而且每篇文章都保存完整 HTML,早晚会遇到空间瓶颈。所以我在设计里做了一层妥协:正文只存纯文本提取后的前 N 个字符,不存原始 HTML。这既保住了摘要和检索的素材,又不会让数据库膨胀得太快。
2. 整体设计与技术选型拆解:把三个模块拼成一条流水线
插件整体的设计思路可以概括为“一条单向流水线”:采集器负责把外部内容拉进来,处理器负责把内容变成结构化的可筛选条目,展示器负责把筛选结果推到用户眼前。三个模块各司其职,之间通过约定好的数据格式通信,不写一堆搅在一起的面条代码。
我把这个架构选型写出来,不是因为它有多高级,而是因为它足够直观。每次我新开一个项目,第一版架构总是在追求简单和可维护。一个只有几千行的小插件,如果搞出微服务化、消息队列、事件溯源那一套,后续维护成本早就超过收益了。流水线模型最大的好处是每个环节都能独立测试,我在开发过程中可以先只调试采集器,不牵涉后续的过滤和展示逻辑。
2.1 三个核心模块:采集、处理、展示
采集模块的核心是一个定时器驱动的调度器。我会在配置里维护一张订阅源列表,每天按设定的时间间隔访问。为了照顾目标网站的负载,我强制要求同一个源的访问间隔不小于 15 分钟,如果一个源连续 N 次返回错误会进入冷却名单,等一段时间再重试。这里有一个很关键的细节:采集器只接收规范化的 RSS 或 JSON Feed 数据。我之前也考虑过直接抓取 HTML 页面再解析正文,但 HTML 结构千变万化,一个小改版就能让解析器报废,而 RSS 的字段相对稳定,维护成本低很多。
处理模块是把采集到的原始条目变成“候选情报”的关键。它做三件事:去重、提取摘要、打分。去重依据的是条目的内容哈希,只要标题和发布时间一致就不会再入库;提取摘要不是调大模型,而是用句子长度、位置和关键词密度做启发式抽取;打分则依赖一组权重参数,后文会详细展开。这一整块是完全离线的,除了读取本地配置,不产生任何外部网络请求。
展示模块包括两大部分:一个弹窗页面和一个浏览器通知系统。弹窗页面按“今日重点”“昨日回顾”“全部条目”三个标签页组织数据,每个条目显示标题、来源、摘要和打分值。通知系统只在打分超过阈值时才触发,并且有节流控制,默认一个小时最多弹两条,不然用户体验会非常差。
2.2 本地存储方案:IndexedDB 与 localStorage 的取舍
很多第一次做插件的朋友会用 localStorage 存一切,因为它 API 简单、同步读写。但我的数据量显然不符合这个方案的使用场景:每个条目带摘要、来源、时间戳和标签,一天可能产生几十上百条结构化数据,时间一长就是上万个记录。localStorage 的容量通常只有 5MB 左右,而且是字符串存储,读写大量对象还需要手动序列化,性能必然出问题。
IndexedDB 是浏览器自带的对象数据库,支持索引、游标、事务,容量上限也大得多(通常达到数百 MB 甚至更宽松)。它唯一的门槛是 API 太啰嗦,全部是异步回调,写起来不如 localStorage 顺手。我的解决办法是封装一个非常薄的数据访问层,只暴露四个方法:保存条目、查询某时间段条目、按关键词检索、删除过期条目。内部再用 Promise 把 IndexedDB 的请求对象包起来,避免在业务代码里到处写回调。
关于存储,我还有一条实际教训:不要把原始 HTML 塞进 IndexedDB。我做第一版的时候图省事,把拉到的内容直接存原文,结果数据库体积在两周内爆炸到 300 多 MB,打开弹窗都要卡几百毫秒。后来改成只存纯文本提取结果和摘要,数据量降了两个数量级,速度问题立刻缓解。这条经验也验证了我一开始定下的“本地优先也要有边界”的思路:本地存储不是无限大的,必须提前设计好数据取舍策略。
2.3 中文 AI 信息源的选择与结构化处理
信息源选择是整个项目里最需要持续维护的环节。我订阅的源分了四类:第一类是官方发布页,主要是模型厂商的技术博客和更新日志,可信度高但更新频率不稳定;第二类是深度技术社区的长文,质量参差,需要靠打分过滤;第三类是聚合型新闻站点,更新快但内容普遍短平快;第四类是特定作者的独立博客,数量少但经常有深度实践内容。
光选源还不够,必须处理源之间的“信息重复”。同一个模型发布,官网发一篇、社区转一篇、媒体再写一篇,内容经常大同小异。如果不去重,通知列表里就会出现三条几乎相同的信息。我的去重策略是以规范化后的标题为基准,把全角半角、空格、括号统一处理后再做相似度比较,相似度超过一定阈值就只保留来源优先级最高的那一条。
这里顺带说一下 RSD 与 JSON Feed 之间的选择:很多中文源只提供 RSS 2.0,少数提供 Atom,几乎没有几个支持 JSON Feed。因此我的解析器做了三端适配:RSS 2.0、Atom、JSON Feed,统一转换成内部条目结构。解析库我选了经过验证的公共包,但为了减少依赖体积,只引入了解析核心模块,没有引入任何额外框架。结构化输出统一成{id, title, summary, url, source, published_at, score}这样的字段,后续所有逻辑都围绕这个结构展开。
3. 核心功能与关键实现:让情报筛选真正“懂”中文 AI 语境
就像前面提到的,插件最终要解决的并不是“能不能抓到信息”,而是“能不能在合适的时间把合适的信息推给你”。这部分的难点不是网络请求,而是怎么在本地代码里理解中文内容的语义。我没有选择在插件里内嵌大型语言模型,因为那会显著增加资源占用和网络开销。我的方案是用一套可配置的关键词矩阵加上多种启发式规则,尽量在“轻量”和“准确”之间取得平衡。
这套方案的根基是一条朴素的观察:中文 AI 资讯的领域特征主要集中在一组高频名词上,比如模型名、训练框架、开源项目、业务场景等。只要在本地维护一组分领域的词表,再结合场景权重,就能做到相当不错的粗筛。当然,这种基于词表的过滤无法理解隐喻和上下文,但它足够快,也足够透明。出问题的时候我可以直接看打分依据,而不是面对一个黑盒输出。
3.1 增量拉取与调度策略:用最小请求拿到最新内容
拉取策略设计的目标是“不打扰源站、不遗漏重要更新、不给浏览器带来额外负担”。我采用了标准的增量更新逻辑:每个订阅源记录两个时间点,上次成功拉取时间和当前条目库里的最新发布时间。每次调度的第一步是比较这两个时间点,决定实际请求参数里带哪个since值。这样能在源支持增量查询时大幅减少无关流量。
实际操作时,我还在每个源的请求之间加了随机抖动,而不是固定等待。这个细节是踩坑得来的:之前所有源都间隔 10 分钟整齐地请求,结果某平台在某个时间点把所有请求集中处理,返回了一大堆 429 限流错误。改成 8 到 15 分钟的随机等待之后,限流基本消失了。还有一个经验是不要相信响应头里的Cache-Control,有些服务端会刻意缓存较旧的内容,我在解析前会比对条目的实际发布时间,比自己当前库里最新的发布时间还要早的条目直接丢弃。
调度器本身用的是后台脚本的定时器,最小间隔设为 15 分钟。这个频率对资讯类源来说完全够用,因为大部分源的更新节奏是小时级或天级。过于频繁的调度会浪费资源,也会让用户看到通知列表里全是陈旧内容。如果有人需要秒级更新(比如追踪某次在线发布会),我建议单独为那个源增加一个手动刷新按钮,而不是把全局调度频率提上去。
3.2 关键词矩阵与本地打分机制:算出每条信息的“相关度”
打分机制是整个项目里最“主观”的部分,因为它反映的是我个人对信息价值的判断。我把关键词分成三层:核心词层、场景词层、干扰词层。核心词是指必须命中才值得保留的词,比如模型名、框架名、技术术语;场景词用于区分这条信息属于开发、产品、行业还是学术,不同的场景词有不同的权重系数;干扰词则是明确不感兴趣的内容,比如纯营销、招聘、活动报名,命中后直接扣分。
每条候选情报会从三个维度计算原始分:标题命中得分乘以标题权重,正文摘要命中得分乘以正文权重,来源可信度得分乘以来源权重。三项求和后,再减掉干扰词的累计扣分,得到最终的score。我设了一个阈值为 60 分的开关,达到这个分数才进入通知队列;低于这个分数但高于 40 分的条目仍然会在弹窗列表的“全部条目”里出现,方便我偶尔回看被误判的内容。
这套打分逻辑有个肉眼可见的好处:我可以随时调整参数。某段时间我对端侧推理特别感兴趣,就把端侧相关词的权重调高两档,通知列表立刻变成我关心的方向。如果我出差太忙,不想被热点轰炸,就把干扰词列表增加几项临时屏蔽词。这种可配置、可审计的筛选逻辑是推荐算法永远给不了我的,因为推荐算法是为了留住我的注意力,而这套逻辑是为了帮我把注意力夺回来。
3.3 本地摘要提取:不调大模型也能抓住重点
摘要功能曾经是我纠结最久的部分。第一反应肯定是调大模型接口做摘要,效果最理想。但仔细想了想:每条信息都要发一次网络请求,摘要的延迟可能在几秒到几十秒,而且每天的调用量积少成多是一笔不小的成本。最关键的是,这会破坏本地优先的原则——一旦摘要逻辑依赖远程服务,插件就不再是个自洽的本地工具了。
最终我用了基于文本特征的抽取式摘要方法。具体步骤是:先从正文里去掉导航、页脚、版权声明等常见噪音段落(通过正则和 HTML 标签过滤),然后把段落按句号、问号、感叹号切分成句子。接下来按三个特征给每个句子打分:句子在文中的位置权重(开头和结尾段落里的句子分更高)、句子含有关键词的密度、句子长度是否在合理范围内(太短或太长的句子都要扣分)。最后取总分最高的前三句,按原文顺序拼接成摘要。
这个方案的效果比大模型摘要肯定差一些,但胜在稳定和即时。实测下来,对技术公告和测评类的文章,这三句话基本能覆盖“谁发布了什么”“解决什么问题”“效果如何”这三个要素。我在弹窗里给摘要限制了一行的高度,想看全文就点标题打开原文页。如果确实需要对某篇文章做深度总结,我会用阅读模式插件再处理一次,没必要把这个重量级功能塞进信息筛选管线里。
3.4 通知系统:“主动来找你”的前提是克制
“让信息自己来找你”听起来很美,但如果通知频率控制不好,插件很快就变成新的噪音源。我在这部分的经验是:通知系统最该花心思的其实不是如何弹出通知,而是如何决定“什么时候不弹”。
插件默认开启了一个“免打扰窗口”,比如晚上十点到次日早上八点不弹任何通知,只把条目写进本地库,等用户第二天打开弹窗看。另外还有一条节流规则:同一个来源在一小时内最多产生一条通知;所有来源合并起来,每半小时最多一条。超出的条目只进入弹窗列表,不算丢弃。
通知条的文案我也做了模板化处理:第一行是来源和分类标签,第二行是标题,正文直接放摘要的前半段,还有一个“打开原文”的按钮。整个通知点击后只做一件事——打开对应链接的新标签页。不看次数、不做推荐、不加赞,就是一个纯净的跳转工具。这种极简设计是我刻意追求的,因为任何多余的信息都可能在引导我把时间花在“看通知”而不是“看内容”上。
4. 完整实操:从零搭建一个可用版本的详细记录
这一节我假设你已经具备基础的 HTML/CSS/JavaScript 能力,但没写过浏览器插件。我会把开发过程中每个关键环节的决策和代码写出来。版本我建议直接采用清单文件第三版规范,因为旧版本已经被主流浏览器逐步淘汰,学习新规范能省去后面迁移的麻烦。
整个项目严格来说只有四个关键文件:清单文件、后台脚本、弹窗页面、以及一个共享的数据处理脚本。没有复杂的构建流程,不需要框架,甚至可以不用打包工具。我刻意在这个项目里避免引入构建步骤,因为一个本地优先工具的核心价值就在透明可控,如果你塞进一套 Node 工程链,使用者想审查代码都会变得困难。
4.1 项目初始化与清单文件配置
先建一个空目录,结构如下:
ai-intel-extension/ manifest.json background.js popup.html popup.js processor.js store.js sources.json清单文件是整个插件的入口,它告诉浏览器这个扩展的权限范围、入口文件和资源位置。我的manifest.json关键内容如下:
{ "manifest_version": 3, "name": "中文 AI 情报筛选器(本地优先)", "version": "0.1.0", "description": "本地优先的中文 AI 资讯采集、过滤与通知插件", "permissions": [ "storage", "alarms", "notifications" ], "host_permissions": [ "https://*/", "http://*/" ], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html", "default_title": "打开情报列表" } }关于host_permissions我要多说一句:这里我用了通配符把所有 http/https 站点都授权了,实际使用时如果很在意安全边界,建议在通配符里限制到你真正订阅的域名列表。我在开发阶段用通配符图方便,等到发布前会收窄。注意,在第三版清单规范里,后台脚本的定位是 Service Worker,它不会像常驻页面那样一直在运行,所以定时任务必须依赖alarmsAPI 而非普通setInterval。
4.2 定时调度与数据采集的后台实现
后台脚本主要承担两件事:管理定时器,以及调用采集流程。由于 Service Worker 的特性,不能直接setInterval,而是要注册一个周期性的 alarm。代码如下:
// background.js const CHECK_INTERVAL_MINUTES = 15; chrome.runtime.onInstalled.addListener(() => { chrome.alarms.create("fetch-sources", { periodInMinutes: CHECK_INTERVAL_MINUTES, }); runFetchCycle(); }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "fetch-sources") { runFetchCycle(); } }); async function runFetchCycle() { const sources = await loadSources(); for (const source of sources) { // 用一个随机延迟避免所有请求在秒级时段内集中 const delay = 1000 + Math.floor(Math.random() * 4000); await sleep(delay); try { const entries = await fetchAndParseSource(source); const processed = await processEntries(entries, source); await storeEntries(processed); } catch (err) { logSourceError(source.name, err); await setSourceCooldown(source.id); } } await checkNotifyCandidates(); }fetchAndParseSource内部做的事情是用浏览器内置的fetch请求订阅地址,拿到响应文本后按源类型解析。这里有一个非常关键的参数设置:请求时必须带上合理的User-Agent和Accept头。有些源站会拒绝空 User-Agent 请求,我之前吃过这个亏,返回的全是 403 错误页。
async function fetchAndParseSource(source) { const response = await fetch(source.feedUrl, { headers: { "User-Agent": "Mozilla/5.0 AI-Info-Extension/0.1", "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8", }, // 单次请求最多等 20 秒,避免拖垮后台任务 signal: AbortSignal.timeout(20000), }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const xmlText = await response.text(); return parseFeedByType(source.type, xmlText); }最初我没有设置超时机制,结果有一次某个源的服务端迟迟不返回响应,后台等待了整整几分钟才超时,直接浪费了一次调度周期。后来给每个请求都包了一个二十秒的AbortSignal超时,情况立刻好转。
4.3 数据处理核心:打分与摘要的本地实现
数据处理脚本processor.js是整个项目最核心的文件。打分逻辑用一个配置对象驱动,我把它组织成三层关键词结构。下面是一个简化版:
// processor.js const CATEGORY_WEIGHTS = { core: 3.0, scene: 1.2, noise: -2.0, }; function scoreEntry(entry, source, rules) { let score = 0; score += hitScore(entry.title, rules.core) * CATEGORY_WEIGHTS.core; score += hitScore(entry.title, rules.scene) * CATEGORY_WEIGHTS.scene; score += hitScore(entry.summary, rules.core) * CATEGORY_WEIGHTS.core * 0.8; score += hitScore(entry.title, rules.noise) * CATEGORY_WEIGHTS.noise; score += source.trustScore || 0; return Math.max(-20, Math.min(score, 100)); }hitScore的逻辑不是简单计数,而是区分短词和长词。短词(二到四个字的术语)需要连续命中才算分,长词(如完整的模型名称)允许在句中存在部分前缀匹配。中文里分词是一大难题,如果对每个标题都跑一遍分词库,性能开销会很大。我的做法是维护一个“词边界”正则集,配合对常用标点和空格的处理,能达到够用的匹配准确度,同时保持毫秒级耗时。
摘要提取函数我前面已经描述了思路,这里补充一下切分句子的实现细节。中文不像英文那样有明确的空格分词,我在切分时用的是split(/[。!?;\n]+/),然后对每个句子做长度过滤和关键词密度计算。为了不让摘要里的三句话来自同一段落,我增加了一个简单的段落索引,确保选择的句子尽量覆盖不同段落。
4.4 弹窗界面与通知服务:把结果干干净净地交给用户
弹窗页面我做得非常克制,只用了原生 HTML 和一点点 CSS。界面分三个区块:顶部的筛选标签、中间的情报列表、底部的状态摘要。列表每项展示四行信息:来源与标签、标题、摘要、评分与时间。评分用颜色弱化处理,红黄色表示高分,灰色表示低分,不会喧宾夺主。下面是一个列表项的结构示例:
<div class="entry-card"> <div class="entry-meta"> <span class="source">某技术社区</span> <span class="category">模型发布</span> <span class="score">87</span> </div> <div class="entry-title"> <a href="原文链接" target="_blank">某模型正式开源,推理性能提升显著</a> </div> <div class="entry-summary"> 该模型在标准评测集上取得了显著提升,团队重点优化了推理阶段的内存占用…… </div> <div class="entry-time">2025-01-15 09:30</div> </div>弹窗的 JavaScript 读取本地库的数据,按当前选中的标签筛选后渲染。这个页面没有任何异步耗时操作,打开就能看到结果,体验和本地 App 已经非常接近。为了性能,我还给列表做了分页,每页最多三十条,超过部分通过“加载更多”按钮获取。
通知服务在后台脚本里实现,触发条件有三个:条目的打分超过阈值、当前不在免打扰窗口、最近一小时通知数量没达到上限。发送通知用chrome.notifications.create,设置一个类型为basic的通知,其中contextMessage字段放摘要,按钮点击事件用chrome.notifications.onClicked监听并跳转原文。当用户在免打扰时段收到跨时段积压的高分情报时,我不会立刻补发通知,而是等第二天上午统一列在弹窗“昨日回顾”里,这样比直接轰炸式补发更有人情味。
5. 实战中的坑与排查实录:这些问题我不写出来,你可能要踩一遍
做这个插件的过程中,我至少遇到了四类不可忽略的问题,有的是调试环境的坑,有的是中文内容处理的经典问题,有的是浏览器平台本身的机制限制。这一节我按“现象 - 原因 - 解决”的结构整理出来,方便你对照排查。
5.1 跨域请求时灵时不灵:宿主权限与混合内容的双重陷阱
现象是插件启动后,部分源能正常拉到内容,部分源一直报错,而且同一个源在不同机器上表现不一致。排查后发现原因有两层:第一层是宿主权限没有覆盖目标域名。我在清单文件里声明了host_permissions,但确实有些源是通过跳转或镜像域名提供内容的,实际请求的域名和订阅链接域名不一致,导致权限校验失败。
第二层更隐蔽:某些源页面强制走 HTTPS,但其 RSS 地址却写着 HTTP。在浏览器环境里,HTTPS 页面引入 HTTP 资源属于混合内容,通常会被拦截,而这个拦截行为在开发工具的 Network 面板里已经被吞掉了,不加自动重定向根本看不出来。我的解决方式是统一在请求层做一次协议升级——检测到源地址是 HTTP 且目标域名支持 HTTPS 时,自动改写地址再用 HTTPS 请求。这一个改动解决了将近一半的拉取失败问题。
5.2 中文乱码与编码识别:看似小事,坑起人来一点不含糊
有一部分中文源返回的响应头里没有明确的字符集声明,或者声明的是ISO-8859-1,但正文实际是 UTF-8。如果直接调用response.text()方法,浏览器会用默认方式解码,结果就是一堆乱码。这个问题在英文环境下几乎不会出现,但在中文源里非常普遍。
解决办法是先用arrayBuffer拿到原始字节,然后尝试用 UTF-8 解码,如果结果中包含大量替换字符(常见表现为“�”,中文内容理论上是不会出现这个字符的),再用GBK或GB2312的编码解码。不过,浏览器原生的TextDecoder并不一定支持GBK,我引入了一个极小的编码转换库来处理这些兼容编码。整个解码逻辑只有不到五十行,却直接把数据可用率从六成拉到九成以上。
5.3 IndexedDB 异步竞态:看起来数据没写进去,其实是覆盖了
IndexedDB 的操作全是异步的,刚开始写存储层时我犯了一个典型的并发错误:每次拉取完一批条目,就直接调用getAllEntries去刷新界面,结果每次界面显示的都只有最近一批数据,之前的数据好像“不见了”。排查了半天才发现,问题出在保存操作没有真正完成时就开始读取,读取到的是旧数据,而且连续两次保存的事务如果交错执行,后一次可能覆盖前一次的部分结果。
修复方式也很朴素:给数据访问层加一个 Promise 队列,所有读写操作按调用顺序排队执行,前一个操作完成后才执行下一个。初始版本里我是图省事没有做队列的,毕竟看起来数据量不大,但真实并发场景下,后台脚本和弹窗页面几乎可能同时触发读写,没有队列就一定会出现竞态。这算是我在“简单架构”和“正确性”之间学到的一课。
5.4 通知轰炸与沉默交替:节流策略也要考虑真实生活节奏
第一次跑通通知功能时,我一次性积压了十几条高分情报,结果打开浏览器后通知像连珠炮一样一条条弹出来,烦得我差点把整个功能删掉。后来我加了两道阀:总节流和来源节流。总节流是每小时最多弹一定数量的通知,超出部分全部续到下一个时间窗口;来源节流是同一来源每小时至多一条。两道阀同时生效后,通知频率立刻变得可接受。
还有一个容易被忽略的点:通知的点击行为。我在默认模板里给通知加了一排操作按钮,有的用于直接跳转,有的用于标记忽略。但实际操作中很多人会误点“忽略”,导致后来真正想看的时候找不到入口。我的最终策略是:通知只保留一个“打开”动作,忽略操作放在弹窗列表里完成。这样可以让通知本身更简单,也更不容易造成误操作。
6. 我能继续扩展的方向,以及给同样做本地工具的朋友几句实在话
当前这个插件已经能稳定帮我处理每天的 AI 资讯了,但它仍然有明显的扩展空间。最自然的下一步是加入端侧模型的总结能力。现在很多浏览器都能加载小型本地模型,如果在摘要环节调用本地模型做生成式总结,效果会比启发式抽取更好,而且仍然保持“数据不出本机”的本地优先原则。这个方向对资源占用有要求,但对追求信息提取效率的人来说,值得投入精力。
另一个扩展方向是支持多设备间的“选择性同步”。我现在的立场是继续保留本地优先的核心,不同步全量数据,只同步用户的过滤规则、忽略标签和订阅列表。这些配置数据量很小,即使通过云同步也几乎没有隐私风险,而且能显著降低换设备后的配置成本。不过同步加密方案需要仔细设计,不能简单地把配置文件明文传到云端。我目前的临时做法是手动导出 JSON 配置文件,换设备后导入,虽然有点原始,但胜在可控。
我个人在这一整个开发过程中最大的体会是:信息过载这件事,靠“卸载 App”是无法根治的,因为真正有价值的信息源永远分散在多个角落。一个本地优先的工具能做的事情,不是替你判断什么是对错,而是把“判断要不要看”这个动作的代价降到最低。插件把筛选规则给你看清楚,把打分依据摆到你面前,把通知频率限制住,剩下的决策权仍然在你手里。
最后再分享一个我用下来觉得特别值的小技巧:每周抽五分钟调整一次关键词表和来源列表。模型圈和开源社区的变化非常快,一个上周还热门的关键词,这周可能已经过气。把维护规则当成日常习惯的一部分,插件的筛选效果就会一直贴合你真实的信息需求。希望这个过程也能给你带来一点启发,不一定是这个插件本身,而是“自己掌控信息入口”这件事的从容感。