NewsNow 技术原理与架构

版本:v0.0.41 | 代码基线:当前仓库 main 分支

目录

  • [1. 项目概述](#1. 项目概述)
  • [2. 技术栈与选型理由](#2. 技术栈与选型理由)
  • [3. 整体架构](#3. 整体架构)
  • [4. 构建系统:Vite + Nitro 一体化](#4. 构建系统:Vite + Nitro 一体化)
  • [5. 数据源系统(核心)](#5. 数据源系统(核心))
  • [6. 缓存与防封禁策略(核心算法)](#6. 缓存与防封禁策略(核心算法))
  • [7. 服务端 API 详解](#7. 服务端 API 详解)
  • [8. 认证与用户数据同步](#8. 认证与用户数据同步)
  • [9. 前端架构](#9. 前端架构)
  • [10. 数据库 Schema 与抽象层](#10. 数据库 Schema 与抽象层)
  • [11. PWA 与离线能力](#11. PWA 与离线能力)
  • [12. 部署架构与多端适配](#12. 部署架构与多端适配)
  • [13. 工程化与代码质量](#13. 工程化与代码质量)
  • [14. 设计亮点与权衡总结](#14. 设计亮点与权衡总结)

github地址:https://github.com/ourongxing/newsnow

运行效果:

1. 项目概述

NewsNow 是一个实时热门新闻聚合阅读工具,从微博、知乎、哔哩哔哩、抖音、GitHub、Hacker News、华尔街见闻等 60+ 个中文/国际内容源抓取热点榜单,以统一的卡片式界面呈现。支持 GitHub 登录与栏目配置的多端同步,可部署在 Cloudflare Pages、Vercel、Docker、Node.js、Bun 等多种运行时。

核心设计目标:

目标 实现手段
优雅阅读 卡片式栏目、拖拽排序、命令面板搜索、暗色模式
实时性 智能两级缓存 + 自适应抓取间隔(最快 2 分钟)
防封禁 源级 interval 限速 + 全局 TTL 兜底 + UA 伪装
多端部署 Nitro preset 切换 + db0 数据库抽象
优雅降级 无数据库、无 OAuth 也能作为纯公开热榜站运行

2. 技术栈与选型理由

2.1 前端

依赖 版本 作用与选型理由
react / react-dom 19.x UI 框架
@vitejs/plugin-react-swc 4.x 用 SWC(Rust 实现)替代 Babel,编译速度提升一个数量级
@tanstack/react-router 1.x 类型安全路由,文件式路由约定,路由参数有完整 TS 类型
@tanstack/react-query 5.x 服务端状态管理:缓存、去重、自动失效,替代手写 useEffect 拉数据
jotai 2.x 原子化客户端状态,比 Redux 轻量,atomWithStorage 一行实现 localStorage 持久化
unocss 66.x 原子化 CSS,按需生成,比 Tailwind 启动快,支持属性化写法(md:(px-10)
@atlaskit/pragmatic-drag-and-drop 1.x Atlassian 出品的无依赖拖拽库,基于原生 HTML5 DnD API,性能优于 react-dnd
cmdk 1.x 命令面板组件(⌘K 搜索)
framer-motion / @formkit/auto-animate - 卡片入场动画 / 列表增删自动过渡
overlayscrollbars 2.x 自定义滚动条,保持跨平台视觉一致
react-use / ahooks / react-device-detect - 常用 hooks 工具集

2.2 服务端

依赖 作用
nitro(via vite-plugin-with-nitro 服务端框架,h3 事件模型,同代码编译到 Node/CF Workers/Vercel Edge/Bun
h3 HTTP 事件处理(defineEventHandlergetQueryreadBody 等)
db0 数据库抽象层,统一 SQL 接口,可切换 better-sqlite3 / cloudflare-d1 / bun-sqlite
better-sqlite3 Node 环境的嵌入式 SQLite,同步 API,零外部依赖
jose JWT 签发与校验(GitHub OAuth 后签发 60 天 token)
ofetch 基于 fetch 的增强 HTTP 客户端,支持超时、重试、实例化默认配置
cheerio 服务端 jQuery 语法 HTML 解析器,用于爬取无 API 的网页
fast-xml-parser RSS/Atom XML → JSON
iconv-lite 处理 GBK 等非 UTF-8 编码的老站点
cookie-es Cookie 解析
dayjs 日期处理(项目打了 patch:patches/dayjs.patch

2.3 构建与工程化

  • Vite 7:构建入口,前端 SPA 与 Nitro 服务端在同一个 build 中产出
  • unimport:自动导入 hooks/utils/shared 函数,前后端代码几乎不需要 import 语句
  • TypeScript 5.9 :分 tsconfig.app.json(前端)/ tsconfig.node.json(服务端)双项目
  • ESLint 9 (flat config)+ @ourongxing/eslint-config + simple-git-hooks + lint-staged
  • Vitest :单元测试(test/server/utils/date.test.ts
  • pnpm 10 :包管理,使用 pnpm-patch-i 应用 dayjs 补丁

3. 整体架构

复制代码
┌──────────────────────────────────────────────────────────────────┐
│                          浏览器 (PWA)                             │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │ React 19 SPA                                                │  │
│  │  TanStack Router (路由)  │  TanStack Query (服务端状态)      │  │
│  │  Jotai (客户端状态)      │  UnoCSS (样式)                   │  │
│  │  pragmatic-dnd (拖拽)    │  Workbox SW (离线缓存)           │  │
│  └──────────────────────────────┬─────────────────────────────┘  │
└─────────────────────────────────┼────────────────────────────────┘
                                  │ HTTP /api/*
┌─────────────────────────────────▼────────────────────────────────┐
│                      Nitro Server (h3 事件模型)                   │
│  ┌───────────────────────────────────────────────────────────┐   │
│  │ middleware/auth.ts  --- JWT 校验,注入 event.context.user    │   │
│  └──────────────────────────────┬────────────────────────────┘   │
│  ┌──────────────────────────────▼────────────────────────────┐   │
│  │ API Routes                                                 │   │
│  │  GET  /api/s?id=xx        单源获取(含缓存逻辑)            │   │
│  │  POST /api/s/entire       批量缓存查询(首屏加速)          │   │
│  │  GET  /api/latest         服务端版本号                     │   │
│  │  GET  /api/login          GitHub OAuth 跳转                │   │
│  │  GET  /api/oauth/github   OAuth 回调,签发 JWT             │   │
│  │  GET  /api/me             当前用户信息                     │   │
│  │  GET/POST /api/me/sync    栏目配置同步                     │   │
│  │  GET  /api/enable-login   服务端是否配置了登录             │   │
│  └──────────────────────────────┬────────────────────────────┘   │
│  ┌──────────────────────────────▼────────────────────────────┐   │
│  │ getters.ts --- Record<SourceID, SourceGetter> 注册表         │   │
│  │  ↑ 构建期由自定义 rollup-glob 插件从 server/sources/       │   │
│  │    自动聚合 60+ 个爬虫文件                                  │   │
│  └──────────────────────────────┬────────────────────────────┘   │
│  ┌──────────────────────────────▼────────────────────────────┐   │
│  │ database/cache.ts + database/user.ts (db0 抽象)            │   │
│  │  SQLite(默认) / Cloudflare D1(CF_PAGES) / bun-sqlite(BUN)  │   │
│  └───────────────────────────────────────────────────────────┘   │
└─────────────────────────────────┬────────────────────────────────┘
                                  │ ofetch (UA 伪装 + 超时 + 重试)
                    ┌─────────────▼──────────────┐
                    │  目标站点 (60+ 源)          │
                    │  HTML / JSON API / RSS      │
                    └────────────────────────────┘

单仓三端目录结构

复制代码
newsnow/
├── src/          # 前端 SPA(React,别名 ~)
├── server/       # Nitro 服务端(别名 #)
│   ├── api/          # h3 路由(文件式路由 → /api/*)
│   ├── sources/      # 60+ 数据源爬虫
│   ├── database/     # cache / user 两张表的封装
│   ├── middleware/   # auth 中间件
│   └── utils/        # fetch / rss2json / defineSource 等
├── shared/       # 前后端共享(别名 @shared)
│   ├── pre-sources.ts    # 数据源声明式定义(唯一事实源)
│   ├── sources.json      # 构建期生成:扁平化源元数据
│   ├── sources.ts        # sources.json 的类型化导出
│   ├── metadata.ts       # 栏目定义与源分组
│   ├── types.ts          # SourceID 等核心类型(类型体操)
│   └── consts.ts         # TTL / Interval 常量
├── scripts/      # 构建前脚本(presource)
└── tools/        # 自定义 Rollup 插件

4. 构建系统:Vite + Nitro 一体化

4.1 构建流水线

vite.config.ts(file:///d:/AIWorkspace/newsnow/vite.config.ts) 中的插件按顺序构成完整流水线:

ts 复制代码
plugins: [
  TanStackRouterVite(),  // ① 扫描 src/routes → routeTree.gen.ts
  unimport.vite({...}),  // ② 自动导入 src/hooks, src/utils, shared/*
  unocss(),              // ③ 原子化 CSS
  react(),               // ④ SWC 编译 React
  pwa(),                 // ⑤ Workbox Service Worker
  nitro(),               // ⑥ 编译 server/ 为服务端产物
]

① TanStackRouterVite :监控 src/routes/ 文件变化,自动生成 routeTree.gen.ts。路由参数(如 /c/$column$column)有完整类型。

② unimport :扫描配置的目录与预设(react hooks、jotai),生成 imports.app.d.ts 类型声明,之后代码里直接用 useQueryatommyFetch 等无需 import。这是项目代码"看起来没有 import"的原因。

⑥ nitro():见下一节。

4.2 一套代码 → 五种运行时

nitro.config.ts(file:///d:/AIWorkspace/newsnow/nitro.config.ts) 通过环境变量决定 Nitro preset 和数据库连接器:

环境变量 preset database connector 部署目标
无(默认) node-server better-sqlite3 Docker / VPS / pnpm start
CF_PAGES=1 cloudflare-pages cloudflare-d1(binding NEWSNOW_DB Cloudflare Pages
VERCEL=1 vercel-edge undefined(用户自配) Vercel
BUN=1 bun bun-sqlite Bun 运行时

切换通过简单的 if/else 修改 nitroOption 完成,Nitro 内部用 unjs/unenv 抹平 Node API 在不同运行时的差异(如 CF Workers 没有 fs)。

4.3 构建产物

复制代码
dist/output/
├── public/          # 静态资源(前端 SPA + PWA)
│   ├── index.html
│   ├── assets/*.js|css
│   └── swx.js       # Service Worker
└── server/
    └── index.mjs    # Nitro 服务端入口(含 API 路由 + 爬虫)

启动方式:node dist/output/server/index.mjs(即 pnpm start)。

4.4 presource 预构建脚本

pnpm dev / pnpm build 前都会先执行 pnpm presource,它跑两个 tsx 脚本:

  1. scripts/favicon.ts(file:///d:/AIWorkspace/newsnow/scripts/favicon.ts) :用 favicons-scraper 抓取所有源的 favicon,落到 public/icons/{id}.png
  2. scripts/source.ts(file:///d:/AIWorkspace/newsnow/scripts/source.ts)
    • shared/pre-sources.ts 生成 shared/sources.json(扁平化主源 + 子源)
    • 通过 git tag / commit 历史分析本次版本相对上一版本更新了哪些源 ,写入 shared/updated-sources.ts(前端"更新"栏目据此展示)

这意味着源列表不是运行时计算的,而是构建期固化的------运行时零开销,且前端可直接 import 静态 JSON。

5. 数据源系统(核心)

数据源是 NewsNow 的灵魂,其设计分三层:声明式配置 → 构建期代码生成 → 运行时抓取

5.1 声明式定义:pre-sources.ts

shared/pre-sources.ts(file:///d:/AIWorkspace/newsnow/shared/pre-sources.ts) 是所有源的唯一事实源(Single Source of Truth):

ts 复制代码
export const originSources = {
  "weibo": {
    name: "微博",
    title: "实时热搜",
    type: "hottest",        // hottest=热榜类 / realtime=时间线类
    column: "china",        // 所属栏目
    color: "red",           // 卡片主题色
    interval: Time.Realtime, // 抓取间隔 2 分钟
    home: "https://weibo.com",
  },
  "wallstreetcn": {
    name: "华尔街见闻",
    column: "finance",
    sub: {                   // 子源:一个站点多个频道
      quick: { title: "快讯", type: "realtime", interval: Time.Fast },
      news:  { title: "最新", interval: Time.Common },
      hot:   { title: "最热", type: "hottest",  interval: Time.Common },
    },
  },
  // ...
}

预定义的抓取间隔档位(shared/pre-sources.ts#L8-L15(file:///d:/AIWorkspace/newsnow/shared/pre-sources.ts#L8-L15)):

档位 用途
Test 1ms 测试
Realtime 2min 微博热搜、财经快讯等高频源
Fast 5min 较快更新
Default 10min 默认(=全局 Interval)
Common 30min 日报类
Slow 60min 一天更新几次的源

5.2 类型体操:SourceID 自动推导

shared/types.ts(file:///d:/AIWorkspace/newsnow/shared/types.ts) 用 TS 映射类型从 originSources 推导 SourceID

ts 复制代码
export type SourceID = {
  [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never :
    ConstSources[Key] extends { sub?: infer SubSource } ? {
      [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never
        : `${Key}-${SubKey}`   // 子源拼成 "wallstreetcn-quick"
    }[keyof SubSource] | Key
    : Key;
}[MainSourceID]

效果:

  • sub 的源 → ID 为 "主源-子源" 联合类型(如 "wallstreetcn-quick" | "wallstreetcn-news" | ...
  • disable: true 的源 → 从 ID 中剔除
  • 前端、后端、API 参数校验全部使用同一个 SourceID 类型,新增/删除源时改 pre-sources.ts 一处即全链路类型更新

5.3 构建期代码生成:rollup-glob 插件

这是整个项目最巧妙的机制。问题:如何在不手动 import 60+ 个爬虫文件的情况下,把它们聚合成一个注册表?

答案是自定义 Rollup 插件 tools/rollup-glob.ts(file:///d:/AIWorkspace/newsnow/tools/rollup-glob.ts),它拦截 glob: 前缀的模块 ID:

ts 复制代码
// server/getters.ts
import * as x from "glob:./sources/{*.ts,**/index.ts}"

插件工作流程:

  1. resolveId :识别 glob: 前缀,返回 glob:{pattern}:{encodeURIComponent(引用者路径)}

  2. load :用 fast-glob 在引用者所在目录匹配所有文件,生成虚拟模块代码:

    ts 复制代码
    export * as weibo from '/abs/path/server/sources/weibo.ts'
    export * as zhihu from '/abs/path/server/sources/zhihu.ts'
    // ...
  3. 生成类型声明 :写入 server/glob.d.ts,为虚拟模块提供完整类型(declare module 'glob:...'

server/getters.ts(file:///d:/AIWorkspace/newsnow/server/getters.ts) 拿到虚拟模块后展开为注册表:

ts 复制代码
export const getters = (function () {
  const getters = {} as Record<SourceID, SourceGetter>
  typeSafeObjectEntries(x).forEach(([id, x]) => {
    if (x.default instanceof Function) {
      Object.assign(getters, { [id]: x.default })      // 单源文件
    } else {
      Object.assign(getters, x.default)                // 多子源文件(目录形式)
    }
  })
  return getters
})()

新增一个数据源 = 在 server/sources/ 放一个文件,零注册、零配置。

5.4 爬虫实现模式

所有爬虫遵循统一签名 SourceGetter = () => Promise<NewsItem[]>,通过 server/utils/source.ts(file:///d:/AIWorkspace/newsnow/server/utils/source.ts) 提供的工厂函数定义:

工厂 用途 例子
defineSource(fn) 通用:手写爬虫 weibo、hackernews
defineRSSSource(url) 标准 RSS/Atom v2ex、solidot
defineRSSHubSource(route) RSSHub 路由(统一走 rsshub.rssforever.com 部分无官方 RSS 的源
proxySource(proxyUrl, source) CF Pages 环境下用代理 URL 替代直接抓取(绕过 Workers 限制) -

模式 A:HTML 抓取weibo.ts(file:///d:/AIWorkspace/newsnow/server/sources/weibo.ts))

ts 复制代码
export default defineSource(async () => {
  const html = await myFetch(url, { headers: { "User-Agent": "...", Cookie: "..." } })
  const $ = cheerio.load(html)
  $("#pl_top_realtimehot table tbody tr").slice(1).each((_, row) => {
    // cheerio 用 jQuery 选择器提取 title/href/热度标记
  })
  return hotNews
})

模式 B:官方 JSON API(多数源)

ts 复制代码
const res = await myFetch("https://api.zhihu.com/topstory/hot-list")
return res.data.map(item => ({ id: item.id, title: item.target.title, url: ... }))

模式 C:RSSrss2json 统一处理)

server/utils/rss2json.ts(file:///d:/AIWorkspace/newsnow/server/utils/rss2json.ts) 兼容 RSS 2.0 的 channel.item 和 Atom 的 feed.entry,并处理 media:*content:encodeditunes:* 等命名空间。

5.5 HTTP 客户端:myFetch

server/utils/fetch.ts(file:///d:/AIWorkspace/newsnow/server/utils/fetch.ts):

ts 复制代码
export const myFetch = $fetch.create({
  headers: { "User-Agent": "Mozilla/5.0 ... Chrome/130.0.0.0 ..." },
  timeout: 10000,
  retry: 3,
})
  • 全局 UA 伪装:模拟桌面 Chrome,避免被识别为爬虫
  • 10s 超时 + 3 次重试:应对目标站点抖动
  • 个别源(如微博)在调用时覆盖/追加 Cookie、Referer 等

5.6 NewsItem 数据结构

shared/types.ts#L85-L100(file:///d:/AIWorkspace/newsnow/shared/types.ts#L85-L100):

ts 复制代码
interface NewsItem {
  id: string | number
  title: string
  url: string
  mobileUrl?: string          // 移动端专用链接
  pubDate?: number | string   // realtime 类源显示发布时间
  extra?: {
    hover?: string            // 悬停提示
    info?: string             // 附加信息(如 HN 的得分)
    diff?: number             // 热榜排名变化(前端计算)
    icon?: { url, scale }     // 角标图标(如微博的"爆")
  }
}

6. 缓存与防封禁策略(核心算法)

这是 NewsNow 最值得学习的工程设计。目标矛盾:用户想要实时数据,但抓取太频繁会被目标站点封 IP

6.1 两个时间常量

shared/consts.ts(file:///d:/AIWorkspace/newsnow/shared/consts.ts):

ts 复制代码
export const TTL = 30 * 60 * 1000      // 全局缓存 TTL:30 分钟
export const Interval = 10 * 60 * 1000 // 默认抓取间隔:10 分钟

每个源还有自己的 interval(见 5.1 节档位表)。

6.2 双闸门算法

server/api/s/index.ts(file:///d:/AIWorkspace/newsnow/server/api/s/index.ts) 的完整决策树:

复制代码
GET /api/s?id=weibo [&latest]
  │
  ├─ 读缓存 (cache.updated = 上次抓取时间)
  │
  ├─【闸门 1:源级 interval】
  │   now - cache.updated < sources[id].interval ?
  │     └─ 是 → 直接返回缓存(status: "success",updatedTime: now)
  │            ↑ 即使缓存已"旧",但这个源本来就更新慢,期间不可能有新内容
  │
  ├─【闸门 2:全局 TTL】
  │   now - cache.updated < TTL ?
  │     ├─ 是,且 (未要求 latest 或 用户未登录)
  │     │   → 返回缓存(status: "cache",updatedTime: cache.updated)
  │     └─ 是,但 登录用户明确要求 latest
  │         → 继续向下强制抓取
  │
  └─【缓存失效】调用 getters[id]() 重新抓取
      ├─ 成功 → 写入缓存(waitUntil 不阻塞响应)→ 返回最新(status: "success")
      └─ 失败 → 降级返回旧缓存(如果有的话)

两道闸门的语义差异(代码注释原文):

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

举例:微博热搜 interval = 2min,联合早报 interval = 30min------前者 2 分钟内重复请求直接用缓存,后者 30 分钟内根本不重抓(因为日报一天就更新几次)。

6.3 缓存存储

server/database/cache.ts(file:///d:/AIWorkspace/newsnow/server/database/cache.ts),cache 表结构:

sql 复制代码
CREATE TABLE IF NOT EXISTS cache (
  id TEXT PRIMARY KEY,    -- SourceID
  updated INTEGER,        -- 上次抓取时间戳 (ms)
  data TEXT               -- JSON.stringify(NewsItem[])
);

写入用 INSERT OR REPLACE。批量查询 getEntire(keys) 兼容了 Cloudflare D1 的返回格式(.all() 返回 { success, meta, results } 而 SQLite 直接返回数组)。

6.4 Cloudflare Workers 的非阻塞写缓存

ts 复制代码
if (event.context.waitUntil) event.context.waitUntil(cacheTable.set(id, newData))
else await cacheTable.set(id, newData)

CF Workers 的 waitUntil 允许响应返回后继续执行任务,用户拿到数据的同时缓存异步落库,进一步降低延迟。

6.5 前端的两级数据加载

前端同样做了缓存感知优化:

  1. 首屏批量拉缓存useEntireQuery(file:///d:/AIWorkspace/newsnow/src/hooks/query.ts) 调 POST /api/s/entire,一次性拿到所有源的历史缓存(不触发新抓取),瞬间渲染全部卡片
  2. 单源按需刷新 :每张卡片(card.tsx(file:///d:/AIWorkspace/newsnow/src/components/column/card.tsx))挂载后自己的 useQuery(["source", id]) 再调 /api/s?id=xx 拿最新数据,覆盖对应卡片
  3. 热榜排名 diff :hottest 类源在拿到新数据后与旧缓存对比,计算每条新闻的排名变化(extra.diff),UI 显示 ↑↓ 箭头

7. 服务端 API 详解

7.1 路由一览

Nitro 文件式路由:server/api/** 自动映射到 /api/**

文件 路由 方法 作用
api/s/index.ts(file:///d:/AIWorkspace/newsnow/server/api/s/index.ts) /api/s GET 单源获取,含双闸门缓存逻辑
api/s/entire.post.ts(file:///d:/AIWorkspace/newsnow/server/api/s/entire.post.ts) /api/s/entire POST 批量查缓存(不触发抓取),首屏用
api/latest.ts(file:///d:/AIWorkspace/newsnow/server/api/latest.ts) /api/latest GET 返回 { v: Version },前端检测版本更新
api/login.ts(file:///d:/AIWorkspace/newsnow/server/api/login.ts) /api/login GET 302 跳转 GitHub OAuth 授权页
api/enable-login.ts(file:///d:/AIWorkspace/newsnow/server/api/enable-login.ts) /api/enable-login GET 返回服务端是否配置了 OAuth
api/oauth/github.ts(file:///d:/AIWorkspace/newsnow/server/api/oauth/github.ts) /api/oauth/github GET OAuth 回调:code→token→user info→签发 JWT→跳回首页
api/me/index.ts(file:///d:/AIWorkspace/newsnow/server/api/me/index.ts) /api/me GET 当前登录用户信息
api/me/sync.ts(file:///d:/AIWorkspace/newsnow/server/api/me/sync.ts) /api/me/sync GET/POST 用户栏目元数据的下载/上传

7.2 单源接口(/api/s)响应协议

ts 复制代码
interface SourceResponse {
  status: "success" | "cache"  // success=新抓的 / cache=命中缓存
  id: SourceID
  updatedTime: number          // 数据时间(注意不是响应时间)
  items: NewsItem[]            // 最多 30 条 (slice(0, 30))
  info: {                      // 固定附加信息
    LICENCE, Github, Sponsorship
  }
}

错误处理:抓取抛异常时如果有旧缓存会降级返回旧缓存(保证可用性),否则 500。

7.3 批量缓存接口(/api/s/entire)

api/s/entire.post.ts(file:///d:/AIWorkspace/newsnow/server/api/s/entire.post.ts) 只查缓存不抓取:

ts 复制代码
const caches = await cacheTable.getEntire(ids)
return caches.map(cache => ({
  status: "cache",
  id: cache.id,
  items: cache.items,
  // 关键:如果缓存仍在 interval 内,updatedTime 返回 now(假装是新的)
  // 否则返回真实 cache.updated,前端据此决定是否再单独请求最新
  updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated,
}))

这个小技巧让前端一次性拿到所有卡片内容,同时保留"哪些源该单独刷新"的判断依据。

8. 认证与用户数据同步

8.1 GitHub OAuth 全流程

复制代码
用户点登录
  → GET /api/login → 302 到 github.com/login/oauth/authorize?client_id=...
  → 用户授权 → GitHub 回调 /api/oauth/github?code=xxx
  → [api/oauth/github.ts] code + client_secret 换 access_token
  → access_token 调 api.github.com/user 拿用户信息
  → userTable.addUser(id, email, "github")
  → SignJWT({ id, type: "github" }).setExpirationTime("60d").sign(JWT_SECRET)
  → 302 跳回首页 /?login=github&jwt=xxx&user={...}
  → 前端从 URL 参数提取 jwt 存入 localStorage

注释里提到一个已知问题:Nitro 在 Cloudflare 里 setCookie 有 bug,所以走 URL 参数 + localStorage 而不是 Cookie。

8.2 JWT 校验中间件

server/middleware/auth.ts(file:///d:/AIWorkspace/newsnow/server/middleware/auth.ts) 拦截所有 /api/*

  • 未配置 OAuth 环境变量disabledLogin = true,除 /api/s/api/proxy/api/latest 外一律 506(Server not configured)
  • 已配置 :从 Authorization: Bearer <jwt> 解析,jose.jwtVerify 验签,注入 event.context.user = { id, type }
  • /api/me 强制要求登录(无 token 或验签失败 → 401);/api/s 则宽容(失败仅警告,按未登录处理)

8.3 用户数据同步

同步的内容PrimitiveMetadata------用户手动调整后的栏目顺序与"关注"列表。

ts 复制代码
interface PrimitiveMetadata {
  updatedTime: number              // 上次修改时间(冲突解决依据)
  data: Record<FixedColumnID, SourceID[]>  // 固定栏目的源列表
  action: "init" | "manual" | "sync"       // 变更来源
}

前端useSync.ts(file:///d:/AIWorkspace/newsnow/src/hooks/useSync.ts)):

  • 挂载时 downloadMetadata() 拉云端配置,与本地 preprocessMetadata 合并
  • 本地变更(action === "manual")防抖 10s 后 uploadMetadata() 推送到云端
  • 冲突解决:简单的 last-write-wins ------updatedTime 大者胜(atom setter 里就做了比较)

服务端api/me/sync.ts(file:///d:/AIWorkspace/newsnow/server/api/me/sync.ts)):

  • GET → userTable.getData(id) 返回 { data, updatedTime }
  • POST → verifyPrimitiveMetadata(body) 校验结构 → userTable.setData(id, JSON.stringify(data), updatedTime)

前端 atom 的防御primitiveMetadataAtom.ts(file:///d:/AIWorkspace/newsnow/src/atoms/primitiveMetadataAtom.ts)):

  • preprocessMetadata 会过滤掉已删除的源、把 focus 里的 redirect ID 替换为真实 ID
  • createPrimitiveMetadataAtom 包装了 jotai 的 atom,写入时同时持久化到 localStorage,且只有 updatedTime 更大才接受更新

9. 前端架构

9.1 路由结构

路由 文件 说明
/ src/routes/index.tsx(file:///d:/AIWorkspace/newsnow/src/routes/index.tsx) 首页:显示"关注"栏目,为空时回退到"最热"
/c/$column src/routes/c. c o l u m n . t s x ( f i l e : / / / d : / A I W o r k s p a c e / n e w s n o w / s r c / r o u t e s / c . column.tsx](file:///d:/AIWorkspace/newsnow/src/routes/c. column.tsx](file:///d:/AIWorkspace/newsnow/src/routes/c.column.tsx) 指定栏目单列视图
根布局 src/routes/__root.tsx(file:///d:/AIWorkspace/newsnow/src/routes/__root.tsx) Header/Footer/SearchBar/Toast + 全局 hooks

根布局里挂载的三个全局 hooks:

  • useOnReload() --- 检测新版本,提示刷新
  • useSync() --- 用户元数据云同步
  • usePWA() --- Service Worker 注册与更新提示

9.2 状态管理分层

复制代码
┌─ 服务端状态(TanStack Query)────────────────────┐
│  queryKey: ["source", id]    单源数据             │
│  queryKey: ["entire", ids]   批量缓存             │
│  staleTime: Infinity(手动 refetch 控制)          │
└──────────────────────────────────────────────────┘
┌─ 客户端全局状态(Jotai + localStorage)──────────┐
│  primitiveMetadataAtom  栏目元数据(核心)         │
│  userAtom / jwtAtom     登录态                   │
│  currentColumnIDAtom    当前栏目                 │
│  focusSourcesAtom       关注列表(派生)          │
└──────────────────────────────────────────────────┘
┌─ 局部状态(useState / hooks)────────────────────┐
│  拖拽中状态、搜索关键词、toast 等                  │
└──────────────────────────────────────────────────┘

9.3 栏目模型

shared/metadata.ts(file:///d:/AIWorkspace/newsnow/shared/metadata.ts) 定义 9 个栏目:

栏目 ID 中文名 类型 说明
focus 关注 固定 用户手动添加的源
hottest 最热 固定 所有 type: "hottest" 的源
realtime 实时 固定 所有 type: "realtime" 的源
updated 更新 固定 本次版本新增/修改的源
china 国内 隐藏 手动切到单列视图
world 国际 隐藏 同上
tech 科技 隐藏 同上
finance 财经 隐藏 同上
sports 体育 隐藏 同上

fixedColumnIds 出现在首页网格;hiddenColumns 只能通过导航栏进入。

9.4 卡片渲染优化

card.tsx(file:///d:/AIWorkspace/newsnow/src/components/column/card.tsx) 的几个细节:

  1. useInView({ once: true }) :卡片进入视口才挂载 NewsCard 内部组件,配合 useQuery 懒触发数据请求------初始渲染零网络请求
  2. placeholderData: prev => prev:refetch 期间保留旧数据,避免闪烁
  3. staleTime: Infinity + 手动 refetch :数据永不过期,由 useRefetch 显式控制刷新时机
  4. delay(200) :命中 cacheSources 时也人为延迟 200ms,等动画播完再显示
  5. diff 算法 :hottest 类源对比新旧 items,计算每条新闻排名变化 extra.diff

9.5 拖拽排序

src/components/column/dnd.tsx(file:///d:/AIWorkspace/newsnow/src/components/column/dnd.tsx) 与 src/components/common/dnd/(file:///d:/AIWorkspace/newsnow/src/components/common/dnd/index.tsx) 基于 @atlaskit/pragmatic-drag-and-drop

  • hitbox 计算落点位置(前/后)
  • auto-scroll 拖拽到边缘自动滚动容器
  • 拖拽结果写入 primitiveMetadataAtom → 触发 localStorage 持久化 + 云端同步

9.6 搜索

search-bar/index.tsx(file:///d:/AIWorkspace/newsnow/src/components/common/search-bar/index.tsx) 用 cmdk 实现 ⌘K 命令面板,可搜索所有源并跳转。

10. 数据库 Schema 与抽象层

10.1 db0 抽象

项目使用 db0(unjs 生态)作为数据库抽象层,所有 SQL 通过 useDatabase() 拿到的 Database 实例执行:

ts 复制代码
await db.prepare(`SELECT ... WHERE id = ?`).get(key)      // 单行
await db.prepare(`SELECT ...`).all()                       // 多行
await db.prepare(`INSERT ...`).run(a, b)                   // 写

不同 connector 的差异(如 CF D1 .all() 返回 { results } 包装)在业务代码里做了兼容。

10.2 Schema

cache 表database/cache.ts(file:///d:/AIWorkspace/newsnow/server/database/cache.ts)):

类型 说明
id TEXT PK SourceID
updated INTEGER 上次抓取时间(ms)
data TEXT NewsItem\[\] 的 JSON

user 表database/user.ts(file:///d:/AIWorkspace/newsnow/server/database/user.ts)):

类型 说明
id TEXT PK GitHub user id
email TEXT 邮箱
data TEXT PrimitiveMetadata 的 JSON
type TEXT 固定 "github"
created / updated INTEGER 时间戳

索引:CREATE INDEX IF NOT EXISTS idx_user_id ON user(id)

10.3 初始化策略

INIT_TABLE=true 时每次启动都执行 CREATE TABLE IF NOT EXISTS(幂等)。README 建议首次部署设为 true,之后关闭以避免每次启动的额外开销。

11. PWA 与离线能力

pwa.config.ts(file:///d:/AIWorkspace/newsnow/pwa.config.ts):

  • Manifest :name/short_name/theme_color #F14D42,4 种尺寸图标(含 maskable)
  • WorkboxnavigateFallbackDenylist: [/^\/api/] ------ API 请求不走 SW 缓存,其他导航请求回退到 index.html(SPA 必需)
  • 文件名swx.js(避免与常见 sw.js 冲突)
  • 开发模式SW_DEV=true 时 dev 环境也启用 SW

前端 usePWA.ts(file:///d:/AIWorkspace/newsnow/src/hooks/usePWA.ts) 用 workbox-window 监听 SW 更新,提示用户刷新加载新版本。

12. 部署架构与多端适配

12.1 部署矩阵

平台 构建命令 输出 数据库 特点
Cloudflare Pages(推荐) CF_PAGES=1 pnpm build dist/output/public D1(绑 NEWSNOW_DB 边缘运行、免运维、免费额度
Docker 见下 镜像 SQLite 文件 完全自托管、数据自主
Vercel VERCEL=1 pnpm build - 用户自配 Edge runtime
Node.js 直跑 pnpm build && pnpm start dist/output SQLite 适合 VPS
Bun BUN=1 pnpm build - bun-sqlite 性能最好

12.2 Docker 两阶段构建

Dockerfile(file:///d:/AIWorkspace/newsnow/Dockerfile):

dockerfile 复制代码
FROM node:20.12.2-alpine AS builder
WORKDIR /usr/src
COPY . .
RUN corepack enable && pnpm install && pnpm run build

FROM node:20.12.2-alpine
WORKDIR /usr/app
RUN apk add --no-cache curl       # healthcheck 用
COPY --from=builder /usr/src/dist/output ./output
ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production
EXPOSE $PORT
CMD ["node", "output/server/index.mjs"]
  • 第一阶段完整构建(约 1GB 镜像层)
  • 第二阶段只拷贝 dist/output,最终镜像极小
  • 配套 docker-compose.yml / docker-compose.local.yml 一键启动

12.3 Cloudflare Pages 特殊处理

  1. 数据库 :D1 是 CF 自家的 SQLite 边缘数据库,wrangler.toml 配置 database_idbinding = "NEWSNOW_DB"
  2. unenv 别名safer-buffer → node:buffer(解决某些依赖在 Workers 环境的兼容问题)
  3. 禁用部分源pre-sources.tsdisable: "cf" 的源(如 36kr)在 CF 环境被禁用(可能因为反爬或需要 Node API)
  4. 代理源proxySource() 工厂在 CF_PAGES 环境改用代理 URL 抓取,绕开 Workers 的部分限制

12.4 CI/CD

  • .github/workflows/docker.yml(file:///d:/AIWorkspace/newsnow/.github/workflows/docker.yml):push tag 自动构建并推送 Docker 镜像
  • .github/workflows/release.yml(file:///d:/AIWorkspace/newsnow/.github/workflows/release.yml):发布流程
  • pnpm logwrangler pages deployment tail 实时查看 CF 日志

13. 工程化与代码质量

13.1 类型系统策略

  • 双 tsconfigtsconfig.app.json(前端 DOM 环境)+ tsconfig.node.json(服务端 Node 环境),pnpm typecheck 分别校验
  • 类型体操SourceID / ColumnID 全部从数据推导,拒绝字符串硬编码
  • 类型安全工具shared/type.util.ts(file:///d:/AIWorkspace/newsnow/shared/type.util.ts) 的 typeSafeObjectEntries / typeSafeObjectFromEntries 替代 Object.entries,保留 key 类型
  • 运行时校验shared/verify.ts(file:///d:/AIWorkspace/newsnow/shared/verify.ts) 的 verifyPrimitiveMetadata 在客户端 localStorage 读取和服务端 sync 接口双侧都做校验

13.2 代码规范

  • ESLint flat config@ourongxing/eslint-config + @eslint-react/eslint-plugin + react-hooks + react-refresh
  • Git hookssimple-git-hooks + lint-staged 提交前自动 lint
  • 风格 :无分号、双引号、import type 显式分离

13.3 测试

  • Vitestpnpm test
  • 测试文件:test/common.test.tsserver/utils/date.test.ts(用 mockdate 固定时间)
  • 测试覆盖有限,主要验证日期处理等纯函数

13.4 补丁管理

patches/dayjs.patch + pnpm-patch-i:修改了 dayjs 的某些行为(可能是 locales 或插件),提交到仓库确保持久化。

14. 设计亮点与权衡总结

14.1 构建期 vs 运行时的取舍

决策 取舍
源列表构建期固化(presource ✅ 运行时零开销 ✅ 前端可直接 import 静态 JSON ❌ 新增源必须重新构建
爬虫用 glob 插件自动聚合 ✅ 新增源零注册 ✅ 类型自动生成 ❌ 构建有少量额外成本
路由用文件式 + 代码生成 ✅ 类型安全 ✅ 重构友好 ❌ 依赖 codegen

14.2 实时性 vs 防封禁的平衡

两级缓存的本质 :把"目标站点内容更新频率"这个外部知识(人工标定的 interval)编码进缓存策略,用 TTL 兜底所有未标定的情况。这是一个领域知识驱动的缓存设计------不是简单的固定 TTL,而是为每个源单独建模。

14.3 一处定义、全链路类型

pre-sources.ts 是唯一事实源,从中派生出:

  • 运行时数据(sources.json
  • 编译期类型(SourceID 联合类型)
  • 前端栏目分组(metadata.ts
  • 版本更新检测(updated-sources.ts

改一处,前后端类型、API 参数校验、UI 分组全部自动更新。

14.4 优雅降级

  • JWT_SECRET → 禁用登录,仅公开热榜
  • 无数据库 → getCacheTable() 返回 undefined,每次都实时抓取(牺牲防封性换可用性)
  • 缓存抓取失败 → 降级返回旧缓存
  • 某源被封 → 仅该源报错,其他源不受影响

14.5 值得借鉴的工程技巧

  1. 自定义 Rollup 插件实现 glob import :比 webpack 的 require.context 更优雅,且能生成类型声明
  2. waitUntil 非阻塞写缓存:响应与持久化并行
  3. 批量缓存 + 单源刷新的两段式首屏:先全部渲染(旧数据)再逐个更新,兼顾首屏速度与最终一致性
  4. Hottest 源的排名 diff:纯前端计算,零服务端成本,提供"↑3 / ↓5"的热榜变化感
  5. useInView({ once: true }) + useQuery 懒加载:首屏只渲染视口内卡片,请求量随滚动按需发生
  6. Jotai atom 包装器统一处理持久化与冲突解决createPrimitiveMetadataAtom 把"localStorage 读写 + updatedTime 比较"封装在 atom 层,业务代码无感知

附录 A:关键文件速查

关注点 文件
构建配置 vite.config.ts(file:///d:/AIWorkspace/newsnow/vite.config.ts) / nitro.config.ts(file:///d:/AIWorkspace/newsnow/nitro.config.ts)
数据源定义 shared/pre-sources.ts(file:///d:/AIWorkspace/newsnow/shared/pre-sources.ts)
爬虫注册 server/getters.ts(file:///d:/AIWorkspace/newsnow/server/getters.ts) + tools/rollup-glob.ts(file:///d:/AIWorkspace/newsnow/tools/rollup-glob.ts)
缓存算法 server/api/s/index.ts(file:///d:/AIWorkspace/newsnow/server/api/s/index.ts)
爬虫工具 server/utils/source.ts(file:///d:/AIWorkspace/newsnow/server/utils/source.ts)
HTTP 客户端 server/utils/fetch.ts(file:///d:/AIWorkspace/newsnow/server/utils/fetch.ts)
认证中间件 server/middleware/auth.ts(file:///d:/AIWorkspace/newsnow/server/middleware/auth.ts)
OAuth 回调 server/api/oauth/github.ts(file:///d:/AIWorkspace/newsnow/server/api/oauth/github.ts)
前端根布局 src/routes/__root.tsx(file:///d:/AIWorkspace/newsnow/src/routes/__root.tsx)
卡片组件 src/components/column/card.tsx(file:///d:/AIWorkspace/newsnow/src/components/column/card.tsx)
元数据 atom src/atoms/primitiveMetadataAtom.ts(file:///d:/AIWorkspace/newsnow/src/atoms/primitiveMetadataAtom.ts)
同步 hook src/hooks/useSync.ts(file:///d:/AIWorkspace/newsnow/src/hooks/useSync.ts)
类型核心 shared/types.ts(file:///d:/AIWorkspace/newsnow/shared/types.ts)

附录 B:环境变量

变量 必需 说明
G_CLIENT_ID 登录功能 GitHub OAuth App Client ID
G_CLIENT_SECRET 登录功能 GitHub OAuth App Secret
JWT_SECRET 登录功能 JWT 签名密钥(通常 = Client Secret)
INIT_TABLE 首次部署 true 时启动建表(幂等)
ENABLE_CACHE false 关闭缓存(默认开启)
CF_PAGES CF 部署 触发 cloudflare-pages preset
VERCEL Vercel 部署 触发 vercel-edge preset
BUN Bun 部署 触发 bun preset
SW_DEV 开发调试 dev 环境启用 Service Worker
相关推荐
他们都叫我GPT侠2 小时前
【无标题】
git·github
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 周榜(2026-07-26)
ai·大模型·llm·github·ai教程
果汁华2 小时前
CLI 命令行与 Python 框架实战
git·python·github
fthux2 小时前
GitHub Actions自动化运维实战:构建高效可靠的CI/CD流水线
运维·自动化·github
zzzzzz3103 小时前
我用 AI Agent 重构了日常开发工作流,效果出乎意料
人工智能·git·github
小锋学长生活大爆炸15 小时前
【福利】最新免费领取云服务器和虚拟主机攻略
网络·github
码流怪侠16 小时前
GitHub 2026年7月热门项目全景盘点:Agent Skills 生态炸裂,开源世界正在重写规则
程序员·github·agent
我叫黑大帅16 小时前
git 的 NFD 与 NFC 有什么区别?为什么我有个文件在 NFC 中间不会被当成改动,在 NFD 中就会当成改动
git·面试·github
DogDaoDao1 天前
【GitHub】WorldMonitor:一个工程极致主义的实时全球情报仪表盘深度解析
python·程序员·架构·github·go语言·worldmonitor·实时全球情报