当前位置: 首页 > news >正文

基于AI与双级缓存的新闻聚合器:从架构设计到工程实践

1. 项目概述:一个只传递好消息的AI新闻聚合器

最近在做一个挺有意思的Side Project,起因是受够了每天被各种负面新闻轰炸。不知道你有没有同感,一打开新闻App,满屏都是冲突、灾难和让人焦虑的标题党。这不仅仅是个人感受,有研究确实表明,过度消费负面新闻会显著提升焦虑感和无助感,甚至扭曲我们对世界风险的认知。世界明明在发生很多美好的事,只是缺少一个产品把它们筛选出来。

于是,我动手做了一个叫GoodNews的Web应用。它的核心想法很简单:用AI当“过滤器”,从多个新闻源抓取文章,自动识别并只展示那些积极、中立或鼓舞人心的内容,最终呈现一个干净、高质量、像杂志一样的阅读流。没有登录,没有广告,没有任何诱导你沉迷的黑暗模式。用户只需要提供自己的OpenAI API密钥(仅保存在浏览器会话中),剩下的全自动完成。

这个项目完全是在Cursor这个AI编程助手的帮助下,一步步从零构建起来的,算是一次完整的“氛围编程”实践。整个过程涉及前端架构、AI集成、数据缓存、状态管理等多个环节,踩了不少坑,也积累了一些心得。下面我就把这个项目的设计思路、技术实现细节以及实操中遇到的问题和解决方案,完整地分享出来。

2. 核心架构设计与技术选型

2.1 为什么选择“自带密钥”的无后端架构?

项目启动时,第一个要决定的就是技术架构。是做一个传统的、带后端的全栈应用,还是追求极致的轻量和易部署?我选择了后者,即完全静态的单页应用(SPA),核心逻辑全部在浏览器端运行。

这个决策背后有几个关键考量:

  1. 零服务器成本与极简部署:静态站点可以部署在Vercel、Netlify或GitHub Pages上,完全免费,且部署过程就是一次git push,简单到极致。这大大降低了项目的维护门槛和长期运营成本。
  2. 隐私与安全:最敏感的用户数据就是OpenAI API密钥。如果由我的服务器中转,我就要承担保管密钥的安全和合规责任。而采用“自带密钥”模式,密钥只存在于用户的浏览器中,通过sessionStorage临时保存(关闭标签页即清除),并直接发送给OpenAI。我的服务器代码甚至“看不见”这个密钥,从根本上消除了密钥泄露的风险。
  3. 降低使用门槛:不需要注册账号,打开网页,输入密钥,立刻就能用。这种极低的摩擦对于工具类产品至关重要,用户愿意尝试的可能性大大增加。

当然,这个架构也带来了明显的 trade-off:

  • 密钥暴露:用户可以在浏览器开发者工具的Network面板中看到发送给OpenAI的请求包含其API密钥。这是有意为之的产品决策——GoodNews被定位为一个个人工具,用户是在用自己的账户调用OpenAI,就像他们在Jupyter Notebook或脚本中做的那样。我们需要在UI中明确告知用户这一点。
  • 功能限制:没有后端,意味着无法做严格的速率限制、无法维护用户账户系统、也无法进行复杂的服务器端个性化推荐。但对于一个“只读”性质的新闻阅读器,这些功能在初期并非核心。

2.2 分层数据源策略:平衡成本与质量

新闻内容从哪里来?单一源容易有偏向性,所以我设计了一个三层的数据源策略,目的是在覆盖广度、内容质量和AI处理成本之间取得平衡。

第一层:精选正能量媒体(RSS)

  • 来源:The Better India, Positive News, Good News Network。这些媒体本身只发布积极向上的内容。
  • 处理方式:因为源本身可信,所以它们提供的文章跳过了AI情感分类这一步。AI只负责优化标题、生成摘要和标签。这直接省下了约一半的AI调用token,因为分类是最耗token的环节。

第二层 & 第三层:广义新闻源(API)

  • 来源
    • 第二层:GNews.io,通过预设的积极关键词(如“innovation”, “kindness”, “breakthrough”)进行搜索。
    • 第三层:The Guardian API,抓取其特定板块(如“Science”, “Culture”)的内容。
  • 处理方式:这些源的内容良莠不齐,所以每篇文章都必须经过完整的AI分类流水线。AI会判断其情感倾向(积极/中立/消极),只有被标记为“消极”的文章会被静默丢弃,不会进入feed。

这样分层的好处是:用最低的成本(第一层)保证了feed的基本盘是高质量的积极内容,同时用第二、三层来拓宽内容的多样性和时效性。在实操中,大约60-70%的feed内容来自第一层,这极大地优化了运营成本。

2.3 双级缓存系统:速度、成本与共享的魔法

如果每次用户刷新页面,都要重新抓取新闻并用AI处理,那体验将是灾难性的——慢且昂贵。因此,一个高效的缓存系统是项目的生命线。我设计了一个本地与远程结合的双级缓存。

第一级:浏览器本地缓存

  • 技术localStorage
  • 策略:缓存键以gn:为前缀,每条记录包含文章数据和过期时间(TTL设为7天)。每次应用启动时,会清理过期的条目。
  • 作用:为同一用户在不同会话间提供瞬时加载体验。

第二级:远程共享缓存

  • 技术:Supabase(一个开源的Firebase替代品,提供PostgreSQL数据库和实时API)。
  • 策略:所有经过AI处理的结果(无论来自哪个用户)都会存入一个公共的articles表。新用户或清理了本地缓存的用户,在首次加载时,会先尝试从Supabase批量读取缓存。
  • 魔法效果:这就是“共享缓存”的威力。当一位伦敦的用户首次阅读一篇关于东京科技突破的文章时,AI完成了分类和摘要。一小时后,一位孟买的用户打开应用,他看到同一篇文章时,会直接命中Supabase缓存,无需再次调用AI。这使冷启动延迟从约30秒(等待AI处理)降至2秒内,并让所有用户共同分摊了AI成本。

缓存解析流程: 当需要获取文章时,系统按以下顺序解析:

  1. 本地缓存:检查localStorage,命中则立即返回。
  2. 远程缓存:若本地未命中,则向Supabase发起批量查询。
  3. AI处理:若远程也未命中,则调用OpenAI API进行处理,并将结果同时写入本地localStorage和远程Supabase。

注意:Supabase的匿名密钥是公开的。为了安全,数据库表被设计为“只追加”(append-only)。文章一旦写入就不会被更新。最坏的滥用情况是有人向表中插入垃圾数据,但这不会影响现有数据。未来如果需要,可以很容易地通过Supabase的行级安全策略来限制写入权限。

3. 核心模块实现与实操要点

3.1 AI处理流水线:从原始文章到结构化数据

这是项目的核心“大脑”。我们利用OpenAI的gpt-4o-mini模型,将一篇原始的、可能冗长的新闻,转化为前端可以直接渲染的、富含元数据的结构化对象。

关键函数classifyAndSummarize(article: RawArticle, apiKey: string)它的输入是一篇原始文章(包含标题、描述、链接等),输出是一个结构化的Article对象。

系统提示词设计: 我设计了两套系统提示词,对应之前提到的分层策略。

  1. 完整提示词:用于第二、三层来源。核心指令是:

    • 情感分类:判断文章整体情感是POSITIVE,NEUTRAL还是NEGATIVE
    • 标题重写:将可能带有“标题党”性质的原文标题,改写成更平静、信息量更大的版本。
    • 生成摘要:提供一个单行总结和三个要点的详细总结。
    • 打标签:分配1-3个主题标签(如科技、环境、教育)。
    • 地理标记:识别文章主要涉及的国家(geoCountry),如果是印度,则进一步标记州、城市(geoRegion)。
    • 影响力标签:标记文章属于哪种积极类型,如“突破”、“善举”、“希望”、“社区”等。
  2. 简化提示词:用于第一层(可信正能量源)。它跳过了情感分类步骤,只执行标题重写、摘要生成和打标签。这显著减少了token消耗。

输出格式控制: 通过OpenAI的response_format参数,强制要求模型返回一个严格的JSON对象,其结构完全匹配我们定义的TypeScript接口AIClassificationResult。这确保了后端数据格式的稳定性,前端无需做复杂的解析或错误处理。

// 简化的类型定义示例 interface AIClassificationResult { sentiment: 'POSITIVE' | 'NEUTRAL' | 'NEGATIVE'; rewrittenHeadline: string; oneLineSummary: string; threeBulletSummary: string[]; topics: string[]; geoCountry: string | null; geoRegion: string | null; impactTag: string; }

实操心得

  • 温度参数:将temperature设置为0.1,以获得尽可能稳定、可重复的输出。对于摘要和分类任务,一致性比创造性更重要。
  • 错误处理:AI调用可能因网络或额度问题失败。代码中必须用try-catch包裹,失败时要么静默丢弃该文章,要么将其标记为“待处理”状态,并在UI上给予适当提示,而不是让整个应用崩溃。
  • 成本监控:由于是用户自带密钥,我们无法直接控制成本。但在应用内可以添加一个简单的估算,例如显示“本次处理大约消耗了X个token”,帮助用户建立认知。

3.2 前端状态管理与数据流

应用采用Zustand进行状态管理,因为它足够轻量且与React结合良好。我们创建了多个独立的store来管理不同方面的状态。

核心Store分解

  1. 文章Store:管理文章列表、加载状态、错误信息。包含获取文章、过滤文章、排序文章的核心逻辑。它是Feed页面的数据源头。
  2. 过滤器Store:管理用户的所有筛选条件(选择的主题、排序方式、国家/地区筛选)。这个store使用Zustand的persist中间件,将状态持久化到localStorage,这样用户刷新页面后筛选条件不会丢失。
  3. 设置Store:管理用户设置,包括OpenAI API密钥和GNews API密钥。这里有个重要区别:OpenAI密钥保存在sessionStorage(会话级),而GNews密钥保存在localStorage(长期)。这是出于安全性和便利性的权衡。
  4. 互动Store:新加入的store,专门记录用户对文章的“点赞”和“分享”行为。数据同样持久化到localStorage,并会异步上报到Supabase,用于计算文章的“热度”排序。

数据流全景图

用户打开/刷新页面 或 更改筛选条件 ↓ 触发 `articlesStore.fetchArticles()` ↓ 调用 `fetchAllArticles()` 聚合三层数据源 ↓ 调用 `resolveArticles()` 进行缓存解析 ├── 1. 查本地缓存 -> 命中则返回 ├── 2. 查Supabase远程缓存 -> 命中则返回并回填本地 └── 3. 调用AI处理 -> 处理结果写入本地和远程缓存 ↓ 应用过滤器:丢弃情感为“负面”的文章;根据地理位置过滤 ↓ 更新 `articlesStore` 状态 ↓ React组件重新渲染,更新UI ↓ 用户点击文章 -> 打开`ArticleDialog`模态框 ↓ 用户点赞/分享 -> 更新`engagementStore`并异步上报Supabase

一个巧妙的设计:跨页面去重在首页,文章会以“精选”、“最新”、“按主题分块”等多种形式组织。我们发现同一篇文章可能属于多个主题,从而在同一个页面里出现多次。为了解决这个问题,在组装首页各个区块的数据时,我们维护了一个usedInSections的Set集合。一旦某篇文章被放入一个区块,就将其ID加入集合,后续区块在选择文章时会跳过它。这保证了用户在单次浏览中不会看到重复内容。

3.3 用户体验与交互细节打磨

产品层面的细节决定了工具是好用还是难用。在Cursor的帮助下,我们迭代了好几轮。

从多页到单页的演变: 最初,设置页面是一个独立的路由(/settings)。但用户反馈说,调整设置后再返回新闻流,上下文就中断了。于是我们将其改成了一个从右侧滑入的面板,用户可以随时打开或关闭,始终停留在阅读上下文中。这个改动虽小,但对沉浸式阅读体验的提升很大。

骨架屏与即时反馈: 早期版本在切换主题或筛选时,界面会“卡住”直到新数据加载完毕。我们加入了骨架屏占位符。当用户点击一个主题时,立即显示一个由灰色方块组成的文章列表骨架图,让用户明确感知到“内容正在加载”,而不是怀疑应用是否崩溃。

地理位置过滤的陷阱与修复: 地理位置过滤功能上线后,我们发现了一个严重的bug:当选择某个国家时,很多无关的文章也出现了。经过排查,问题出在过滤函数filterByCountry的逻辑上。原来的逻辑是:如果文章没有地理标记(geoCountry为null)或者标记的国家与筛选器匹配,则通过。这导致所有没有标记国家的“全球性”文章(数量很多)在任何国家筛选中都会被显示出来。修复方法:我们重写了过滤逻辑为filterByGeo,规则更严格:

  • 如果用户未选择国家,则显示所有文章。
  • 如果用户选择了国家,则必须要求文章的geoCountry字段与所选国家匹配。
  • 如果用户进一步选择了印度的州或城市,则文章的geoRegion字段也必须匹配。 同时,为空的筛选结果增加了友好的提示和重新获取的按钮。

分享功能的实现: 为了让用户方便地分享好消息,我们实现了“一键生成分享图”的功能。点击分享按钮后,前端会动态创建一个1200x630像素的Canvas画布,将文章标题、摘要和品牌样式绘制成一张精美的OG图片,然后调用浏览器的Web Share API进行分享。如果浏览器不支持(如某些桌面端),则提供一个备选方案,直接生成一个包含图片的WhatsApp分享链接。

4. 开发历程、踩坑实录与优化心得

4.1 在Cursor中从零到一的构建过程

这个项目几乎全程在Cursor中完成,它扮演了结对编程伙伴和架构师的角色。

第一步:产品定义与架构规划我没有直接写代码,而是先给Cursor输入了一份详细的产品需求文档,描述了问题、解决方案和所有功能点。然后我要求它:“在写第一行代码之前,请先为整个代码库制定一个计划。”它输出了一份非常全面的规划文档,涵盖了项目脚手架、TypeScript类型、新闻抓取器、缓存策略、AI流水线、状态管理、UI组件等所有层面。我把这个计划中的每一项都转换成了TODO列表,用来追踪进度。这个习惯极大地避免了后期返工。

第二步:构建数据与逻辑层有了计划作为上下文,Cursor开始高效地构建非UI部分。它创建了Vite + React + TypeScript的项目骨架,配置了Tailwind CSS、Zustand等依赖。然后按照模块依次构建:

  • 定义所有核心的TypeScript类型。
  • 实现三个新闻源抓取器(RSS, GNews, Guardian)和聚合去重逻辑。
  • 实现AI分类模块和双级缓存系统。
  • 创建Zustand store和工具函数。

第三步:实现UI组件与页面我给了Cursor一个设计方向:“温暖、有编辑感、平静——像一本高质量的周日杂志,而不是突发新闻快讯。”并指定了字体和主色调。Cursor据此生成了所有核心组件:ArticleCardFilterBarFeedPageSettingsPage等。它甚至处理了复杂的交互状态,比如卡片点击展开、骨架屏动画。

第四步:联调与响应式设置将所有组件连接起来后,遇到了一个有趣的挑战:如何让设置(如字体大小)的更改立即生效,而不引起整个React树的重新渲染?我们采用了一个轻量级的方案:在设置变更时,通过CustomEvent派发一个浏览器自定义事件(如gn:settings-change)。在应用的根组件,我们监听这个事件,并直接将新的字体大小值写入document.documentElement的CSS自定义属性(--gn-font-size)。所有使用rem单位的文本都会自动响应。这比通过Context或状态提升要高效得多。

4.2 典型问题排查与解决

问题一:输入API密钥后,Feed一片空白这是最令人头疼的运行时bug。控制台没有明显错误,但就是没有文章。

  • 排查过程
    1. 首先检查网络请求,发现GNews API的请求返回401未授权。
    2. 检查代码,发现GNews API密钥是从import.meta.env.VITE_GNEWS_API_KEY读取的。
    3. 在Vite的开发服务器中,环境变量是在构建时注入的。但在某些热更新或模块加载顺序下,这个变量可能在模块初始化时还未就绪,导致读出来是undefined
    4. 同时,还发现RSS抓取请求也失败了,原因是CORS(跨域)问题。我们在开发环境中配置的Vite代理服务器规则没有正确生效。
  • 解决方案
    1. GNews密钥:彻底改变策略。将GNews API密钥也从环境变量中移除,改为和OpenAI密钥一样,由用户在设置面板中输入,并保存在localStorage中。这样应用就实现了“零构建时配置”,部署和分享变得极其简单。
    2. CORS代理:仔细检查并修正了vite.config.ts中的代理配置,确保/api/rss-proxy路径的请求被正确转发到目标RSS源。

问题二:地理位置筛选功能逻辑混乱如前所述,最初的筛选逻辑过于宽松。修复过程需要通盘考虑:

  • filterByGeo函数的重写。
  • applyFiltersloadCachedArticlesfetchArticles等多个调用筛选的地方统一使用新函数。
  • 为空结果设计友好的UI状态,提示用户“当前地区暂无缓存内容”,并提供“尝试获取新文章”的按钮。

问题三:文章图片加载失败或缺失影响布局很多新闻源的图片链接不稳定,或者根本没有图片。这导致卡片高度不一,布局错乱。

  • 解决方案:创建了一个DefaultThumbnail组件。它是一个纯SVG图形,根据文章的主题(如科技、环境)显示不同的简约图标,并配有品牌背景色。在任何文章图片加载失败(onError)或根本没有图片时,就显示这个默认缩略图。这保证了所有卡片视觉上的一致性和专业性。

4.3 性能与优化实践

  1. 图片懒加载:所有文章卡片中的图片都添加了loading="lazy"属性,只有当它们滚动到视口附近时才开始加载,极大提升了首页的加载速度。
  2. 虚拟列表考量:虽然文章列表可能很长,但初期我们评估后认为,由于每篇文章卡片本身高度不大,且我们通过分页和分类浏览控制了单次渲染的数量,浏览器渲染压力在可接受范围内。因此暂未引入复杂的虚拟列表库(如react-window),以保持代码简洁。这是一个需要持续观察的点,如果文章量级增长,这是首要的优化方向。
  3. 缓存键的规范化:在将文章URL作为缓存键存入Supabase前,我们对URL进行了“规范化”处理:移除常见的跟踪参数(如utm_source,fbclid),并进行SHA-256哈希。这避免了同一篇文章因不同来源的跟踪链接而被重复缓存多次。
  4. Supabase写入的“发了就忘”:向Supabase远程缓存写入数据时,我们采用异步操作,且不等待其完成。即使写入失败(网络问题),也不会阻塞用户浏览当前已加载的文章。缓存是用于提升体验的,不应成为单点故障。

5. 项目总结与可扩展方向

回顾整个项目,从被负面新闻困扰的念头,到一个完整可用的产品,再通过Cursor的辅助将其系统化地实现,是一次非常充实的全栈实践。它涉及了现代前端开发的方方面面:React、状态管理、API集成、AI应用、性能优化、用户体验设计。

几个关键体会

  • “自带密钥”模式对于工具类、原型类项目是一个强大的架构选择,它能极大降低启动和维护成本,但必须清晰地向用户传达其安全边界。
  • **AI作为“过滤器”**而不仅仅是“生成器”,是一个非常有价值的应用模式。它放大了人类对信息的 curation 能力。
  • 共享缓存是一个被低估的设计模式。它用很低的成本(一个免费的Supabase项目)为所有用户创造了巨大的价值(速度提升、成本分摊)。
  • 在Cursor这类工具的帮助下,开发者的角色更多地向“产品架构师”和“代码评审者”倾斜。你需要清晰地定义问题、拆解模块、制定规范,然后让AI去生成实现代码,你再进行调试、整合和优化。这大幅提升了开发复杂应用的效率。

如果未来要继续迭代,我会优先考虑以下几个方向

  1. 个性化推荐:虽然目前没有用户系统,但可以基于浏览器指纹或localStorageID匿名记录用户的阅读和点赞历史,在“热门”排序之外,增加一个“为你推荐”的维度。
  2. 更健壮的CORS代理:在生产环境依赖allorigins.win这样的第三方代理存在风险。可以部署一个极简的、无状态的云函数(如Vercel Edge Function或Cloudflare Worker)作为专属代理。
  3. 多语言支持:目前的AI提示词是英文的,新闻源也以英文为主。可以扩展为支持其他语言,让更多地区的人能读到本地的好消息。
  4. 浏览器扩展:做一个浏览器扩展,替换用户常用新闻网站的部分内容,或者在新标签页中直接展示GoodNews的feed,将积极信息的注入变得更无缝。

这个项目的代码已在GitHub上开源。它不仅仅是一个可用的新闻阅读器,更是一个展示了如何将现代前端技术与AI能力结合,快速构建出有价值、有体验的产品的完整案例。希望其中的一些设计思路和实现细节,能给你带来启发。

http://www.jsqmd.com/news/771667/

相关文章:

  • 如何测试 CloudCone VPS 的磁盘 IO 性能是否达标
  • 如何解决Upscayl中的Vulkan兼容性问题:完整指南
  • MAA助手:明日方舟自动化工具终极使用指南
  • 告别模糊屏!AMD黑苹果Sonoma下开启2K HIDPI的详细步骤与工具推荐
  • AISMM评估数据可视化落地难?92%团队忽略的4个关键指标校准点(附权威验证脚本)
  • 开发者技能图谱:结构化学习路径与知识体系构建指南
  • 2026北京小程序开发哪家最靠谱?国内排名前十专业的小程序定制开发服务商盘点 - 品牌策略主理人
  • 收藏!小白程序员轻松入门大模型:6步解锁AI Agent开发全攻略
  • AISMM模型深度解构:从0到1打造技术品牌的4个不可逆阶段
  • 在 Hermes Agent 项目中集成 Taotoken 提供方的详细配置步骤
  • 通过Taotoken CLI工具一键配置开发环境中的API访问密钥
  • AISMM模型实施失败的3个隐性根源,92%CTO至今未察觉——今天不读,下周就可能被审计否决
  • JavaScript 鼠标滚轮事件详解:监听向上/向下滑动
  • 2026年高精度便携式超声波流量计品牌口碑与厂家实力介绍 - 品牌推荐大师1
  • 蓝桥杯单片机备赛:用NE555测频率,从原理图到代码的避坑实操
  • 2026年素材网站选购指南:实测5款优质平台,告别选型焦虑 - 极欧测评
  • 温岭市大溪致翔机械设备租赁:专业的台州吊车租赁公司 - LYL仔仔
  • 基于Next.js与GitHub Pages构建个人开发者门户:从SSG到CI/CD全流程实践
  • 拆解特斯拉Autopilot与比亚迪DiPilot:主流车企的ADAS方案到底有何不同?
  • OR-Tools:如何用Google的运筹学引擎解决现实世界优化难题?
  • 【IEEE出版、高校联合主办、启动评优】第八届物联网、自动化和人工智能国际学术会议(IoTAAI 2026)
  • 别再只写累加和了!汽车CAN总线通信中,这几种Checksum算法你都知道吗?
  • 2026最新 海口代理记账公司排行:合规与服务能力实测盘点 - 奔跑123
  • 广东佛山心理机构怎么选?4家正规心理咨询中心测评对比 - 野榜数据排行
  • 5分钟快速指南:使用WeakAuras Companion告别繁琐的手动更新
  • Obsidian Tasks:5步掌握任务优先级管理,让重要事项不再遗漏
  • 康安倍泰抑菌粉:以标准为尺,以科研为基,守护女性健康 - 品牌排行榜
  • 基于Vue 3与FastAPI的ChatGPT Web应用脚手架:从流式对话到生产部署
  • PCL点云可视化神器pcl_viewer:从安装到常用快捷键的保姆级指南(附坐标查看技巧)
  • 别再乱用LDO了!实测对比MP2315、RT9193和ADR4550,教你根据电流和压差选对电源芯片