21k star 热榜聚合器,源码里藏着一颗会过期的 Cookie

发布于 · 2,734 字 · 约 7 分钟#Github 解读#Agent 抓取原文链接
21k star 热榜聚合器,源码里藏着一颗会过期的 Cookie 封面图
  • 热榜聚合器 NewsNow 将 66 个数据源聚合到单页面,以代码形式自部署,不依赖中心化服务
  • 添加新源只需在 server/sources 目录下新增一个文件,无需修改注册表,实现零配置扩展
  • 采用双时间窗口缓存策略,抓取失败时用旧缓存兜底,保证前端始终有数据可展示
  • 架构分 server、shared、src 三层,服务端数据用 db0 抽象,推荐 Cloudflare D1,支持 Docker 部署
  • 提出“可腐烂架构”,承认每个源会死,通过文件级隔离和缓存机制让死亡影响限定在单个模块

晚上十一点,你想知道今天全网都聊了什么。

微博开一遍,知乎开一遍,B 站再开一遍,抖音好像也有热榜,虎扑那边今天吵起来没有。十几个 App 轮着切,等你看完,半小时没了,真正想看的信息可能就那几条。

NewsNow 干的事很朴素,把这些热榜全部装进一个页面。GitHub 上 21,487 颗星,5,897 次 fork,TypeScript 写的,MIT 协议,作者 ourongxing,2024 年 9 月立项。它不追 AI,也不追什么 Agent 风口,就是一个热榜聚合器,互联网上古时期就有的品类。

那它凭什么。

我把源码翻了一遍,发现这个项目最有意思的地方,不是界面多干净,而是它对「抓取这件事随时会死」有着近乎偏执的防备。源码里甚至藏着一颗写死的微博 Cookie,明知道会过期,还是写死了。

这篇文章就顺着这条线拆。

热榜聚合是个老生意,NewsNow 换了个活法

聚合热榜的产品你大概率见过。今日热榜 TopHub 做了快十年,更早还有各种导航站和 RSS 阅读器。这个品类的宿命也几乎公开了,中心化服务要么被广告慢慢吃掉,要么因为平台封锁逐渐残废,要么干脆关停。

NewsNow 的差异点一句话能说清,它不是服务,是代码。你 Fork 一份,部署到 Cloudflare 或者自己服务器上,热榜聚合这件事就从「依赖别人的网站」变成了「自己的一件固定资产」。平台再怎么封锁,封的是部署方,你随时可以换 IP 换环境重跑。

这个定位在 README 里毫不掩饰,作者直接说当前版本是 demo,完整版之后再说。一个自称 demo 的项目拿了两万多星,说明它解决的问题足够真实。

加一个源,简单到只需要一个文件

先看它怎么组织抓取。整个项目 66 个数据源,微博、知乎、B 站、抖音、虎扑、雪球、财联社、Hacker News、GitHub Trending、Product Hunt,全放在 server/sources/ 目录下,一个源一个文件。

注册机制是我见过最短的之一。server/getters.ts 全文只有几行,核心就一句

import * as x from "glob:./sources/{*.ts,**/index.ts}"

Nitro 的 glob import 把目录下所有源文件自动收集进来,每个文件的 default 导出注册成一个 getter。你往目录里丢一个新文件,重启服务,新源就上线了,不用改任何注册表。zhihu 的实现短到可以整段贴出来,直接调知乎的私有接口

export default defineSource({
  zhihu: async () => {
    const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true"
    const res: Res = await myFetch(url)
    return res.data.map(k => ({
      id: k.target.link.url.match(/(\d+)$/)?.[1] ?? k.target.link.url,
      title: k.target.title_area.text,
      url: k.target.link.url,
    }))
  },
})

不是所有源都这么体面。server/utils/source.ts 里准备了三件套,defineSource 直接抓,defineRSSSource 解析 RSS,defineRSSHubSource 借道 RSSHub。最后这个值得注意,它的默认地址硬编码为公共实例 https://rsshub.rssforever.com,也就是说一部分源的可用性,寄托在别人维护的免费 RSSHub 上。这个伏笔后面会回收。

然后就是那颗 Cookie。打开 server/sources/weibo.ts,你会看到请求头里明晃晃写着一颗完整的 SUB=_2AkMWIuNSf8...,后面跟了长长一串。注释里还贴心地给了出处,引用自另一个开源项目 weibo-trending-hot-search。这是微博的游客会话 Cookie,社区里传来传去的那种,靠它才能访问 s.weibo.com 的热搜页。拿到 HTML 后用 cheerio 解析表格,连「新」「热」「爆」三种角标都映射成了微博官方的旗子图片。

一颗大家共享的、随时会被微博作废的 Cookie,就这么躺在两万星项目的源码里。

抓不到的时候,架构才真正开始工作

抓取的本质是寄生。宿主平台改版、加验证码、封 IP,任何一件事都能让一批源集体阵亡。NewsNow 服务端的核心 server/api/s/index.ts 花了最多笔墨处理的,恰恰是「抓不到怎么办」。

它设计了一个双时间窗口的缓存策略,shared/consts.ts 里定义了两个常量,TTL 是 30 分钟,Interval 默认 10 分钟。这两个数字控制着完全不同的两件事,作者在源码注释里自己解释了

// interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,
// 这个间隔内可能内容压根不会更新。
// 而 TTL 缓存失效时间,在时间范围内,就算内容更新了也要用这个缓存。

翻译成人话,interval 是「这个源的内容本来就更新得慢,别去烦它」,TTL 是「就算内容更新了,30 分钟内也先用缓存扛着」。请求进来先查缓存,两个窗口都不满足才真正去抓,抓回来 .slice(0, 30) 截前 30 条入库。如果抓取抛了异常,还有一层兜底,catch 块里直接把旧缓存吐回去,只要数据库里还有一份历史数据,前端就永远有东西可看。

更好的是 interval 不是全局一刀切,shared/sources.json 里给 66 个源逐个标了级。微博热搜和雪球是 2 分钟,华尔街见闻、财联社这些快讯源是 5 分钟,绝大多数热榜 10 分钟,Solidot 这种日更站直接放宽到 1 小时。更新快的抓得勤,更新慢的少打扰,既省资源,也别把宿主惹毛。

双窗口缓存决策流程
双窗口缓存决策流程

还有一个细节藏在 server/utils/source.ts 的 proxySource 函数里。它判断 process.env.CF_PAGES,如果部署在 Cloudflare Pages 上,微博这类国内源会改走一个代理地址去取。原因你猜得到,Cloudflare 的出口 IP 段早就被国内平台拉黑了,直连必挂。但反过来讲,这个保护只对 CF_PAGES 环境生效,用 Docker 起在自家机器上的用户,享受不到这层,得自己想办法。issue 区里「支持网络代理设置」的请求后面跟着一串留言,就是这个问题的回声。

三层结构,一处依赖一个文件

把上面的机制拼起来,整个项目其实是三层。server/ 是 Nitro 驱动的服务端,管抓取、缓存、OAuth 登录和数据同步。shared/ 放两端共用的类型和 66 个源的元数据。src/ 是 React 19 的前端,TanStack Router 加 TanStack Query 做路由和数据获取,jotai 管状态,@atlaskit/pragmatic-drag-and-drop 做栏目拖拽排序,UnoCSS 出样式,外加 PWA 离线可用。

系统架构图
系统架构图

服务端的数据落在 db0 抽象的数据库里,作者推荐 Cloudflare D1,建一张 cache 表就三列,id、updated、data,data 直接 JSON 字符串塞进去。简单粗暴,但和「反正 30 分钟就换一次」的场景严丝合缝。写缓存还用了 event.context.waitUntil,先响应请求,缓存慢慢写,Cloudflare Workers 的生命周期特性被用得很熟。源码注释里甚至记了一个坑,Cloudflare Worker 的运行时里 Date.now() 整个生命周期不会更新,这种经验一般只有真踩过才写得出来。

把服务搬回自己家

部署给了三条路。最简单是 Fork 后导入 Cloudflare Pages 或 Vercel,构建命令 pnpm run build,输出目录 dist/output/public。要登录和数据同步就去建一个 GitHub App,回调地址设成 https://your-domain.com/api/oauth/github,配好 G_CLIENT_ID、G_CLIENT_SECRET、JWT_SECRET 三个环境变量。自己有机器的,项目根目录 docker compose up 一步到位。首次启动记得把 INIT_TABLE 设为 true,建完表再关。

有个配置项很多人会忽略,ENABLE_CACHE。默认开,关掉的话每次请求都真抓,自己的 IP 自己负责。

说个题外的玩法,README 里挂了一个 MCP Server 的接入方式,npx 一行 newsnow-mcp-server,BASE_URL 指到你自己部署的实例。也就是说你把这个项目部署好之后,Claude 或者其他 AI 客户端可以直接查你这份热榜,「现在微博在爆什么」变成了一句可调用的工具指令。热榜聚合器顺手把自己变成了 AI 的数据源,这个组合比我预期的自然。

它随时会碎,区别在于碎得体面

回到开头那颗 Cookie。它不是孤例,是这个品类的缩影。微博哪天把这个共享会话作废,weibo 源就整个黑掉,直到有人发现并换上新的。issue 区里高赞的「知乎板块显示获取失败」挂着 8 条讨论没关,知乎的私有接口 api/v3/feed/topstory/hot-list-web 同样说变就变。借道 RSSHub 的那些源,命悬在 rsshub.rssforever.com 这个公共实例的存活上。你自己部署的实例再稳,也只能保证服务不宕,保证不了源不腐烂。

项目的维护状态也要如实说。18 位贡献者,主力一直是作者本人。最新版本 v0.0.41 发布于 2026 年 6 月 26 日,README 里那句划掉「欢迎贡献」、改写为「新版本即将推出,不再接受贡献」的公告,加上 Roadmap 末尾那句 italic 的「release when ready」,读起来更像一个漫长的等待。对一个标着 v0.0.x 的项目,这个信号不能装作看不见。

还有个小门道,Docker 部署的用户想在 66 个源里做增删,得改 shared/pre-sources.ts 再重新构建,issue 里「docker 部署的,是否可以修改、添加、禁用某些源」的提问有 7 条回复,都是绕路的答案。现阶段它对非 Cloudflare 部署的友好度,坦白讲一般。

但这些碎法都被设计过。一个源死掉,影响半径是它自己那一个文件。抓取挂了,缓存兜底。缓存也没有,前端只是那一栏空着,其他 65 个源照常工作。它没有假装自己不会碎,它只是让每一次碎,都碎在一个可以单独替换的格子里。

可腐烂架构

拆完之后我一直在想,这个项目有什么可以带走的。

我认为是一个可以被命名的思路,暂且叫可腐烂架构。做抓取类系统的人常有一种执念,想用技术手段一劳永逸地解决反爬,堆更深的代理池,养更真的浏览器指纹。NewsNow 反着来,它承认每一个源都会死,然后把「死」设计成系统的常规状态。源与源之间文件级隔离,死了不影响邻居。缓存让死亡有潜伏期,回退让死亡有尸体可用,双窗口让大部分死亡根本不会被用户看见。

这套思路的适用范围不止抓取。任何依赖外部不可控组件的系统,第三方 API、汇率接口、大模型服务、供应链里的某个开源包,都可以问自己同一个问题,如果它明天就死,我的影响半径是多少。半径能缩到一个可单独替换的模块,你就有资格睡个好觉。

至于该不该用 NewsNow,我的结论很简单。你想要一个自己掌控的热榜主页,愿意每月花几分钟维护部署,它现在就值得装,Docker 一条命令的事。你想要开箱即用、永远新鲜、英文源齐全的成品,那就等作者的完整版,或者先去演示站 newsnow.busiyi.world 过过眼瘾。

一颗会过期的 Cookie 不可怕,可怕的是没有为它的过期做任何准备。

评论互动

© 2026 王若风的技术博客 · Powered by Astro