版本: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 事件处理(defineEventHandler、getQuery、readBody 等) |
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 类型声明,之后代码里直接用 useQuery、atom、myFetch 等无需 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 脚本:
- scripts/favicon.ts(file:///d:/AIWorkspace/newsnow/scripts/favicon.ts) :用
favicons-scraper抓取所有源的 favicon,落到public/icons/{id}.png - 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}"
插件工作流程:
-
resolveId :识别
glob:前缀,返回glob:{pattern}:{encodeURIComponent(引用者路径)} -
load :用
fast-glob在引用者所在目录匹配所有文件,生成虚拟模块代码:tsexport * as weibo from '/abs/path/server/sources/weibo.ts' export * as zhihu from '/abs/path/server/sources/zhihu.ts' // ... -
生成类型声明 :写入
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:RSS (rss2json 统一处理)
server/utils/rss2json.ts(file:///d:/AIWorkspace/newsnow/server/utils/rss2json.ts) 兼容 RSS 2.0 的 channel.item 和 Atom 的 feed.entry,并处理 media:*、content:encoded、itunes:* 等命名空间。
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 前端的两级数据加载
前端同样做了缓存感知优化:
- 首屏批量拉缓存 :useEntireQuery(file:///d:/AIWorkspace/newsnow/src/hooks/query.ts) 调
POST /api/s/entire,一次性拿到所有源的历史缓存(不触发新抓取),瞬间渲染全部卡片 - 单源按需刷新 :每张卡片(card.tsx(file:///d:/AIWorkspace/newsnow/src/components/column/card.tsx))挂载后自己的
useQuery(["source", id])再调/api/s?id=xx拿最新数据,覆盖对应卡片 - 热榜排名 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 替换为真实 IDcreatePrimitiveMetadataAtom包装了 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) 的几个细节:
useInView({ once: true }):卡片进入视口才挂载NewsCard内部组件,配合useQuery懒触发数据请求------初始渲染零网络请求placeholderData: prev => prev:refetch 期间保留旧数据,避免闪烁staleTime: Infinity+ 手动 refetch :数据永不过期,由useRefetch显式控制刷新时机delay(200):命中cacheSources时也人为延迟 200ms,等动画播完再显示- 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) - Workbox :
navigateFallbackDenylist: [/^\/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 特殊处理
- 数据库 :D1 是 CF 自家的 SQLite 边缘数据库,
wrangler.toml配置database_id与binding = "NEWSNOW_DB" unenv别名 :safer-buffer → node:buffer(解决某些依赖在 Workers 环境的兼容问题)- 禁用部分源 :
pre-sources.ts中disable: "cf"的源(如 36kr)在 CF 环境被禁用(可能因为反爬或需要 Node API) - 代理源 :
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 log:wrangler pages deployment tail实时查看 CF 日志
13. 工程化与代码质量
13.1 类型系统策略
- 双 tsconfig :
tsconfig.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 hooks :
simple-git-hooks+lint-staged提交前自动 lint - 风格 :无分号、双引号、
import type显式分离
13.3 测试
- Vitest :
pnpm test - 测试文件:
test/common.test.ts、server/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 值得借鉴的工程技巧
- 自定义 Rollup 插件实现 glob import :比 webpack 的
require.context更优雅,且能生成类型声明 waitUntil非阻塞写缓存:响应与持久化并行- 批量缓存 + 单源刷新的两段式首屏:先全部渲染(旧数据)再逐个更新,兼顾首屏速度与最终一致性
- Hottest 源的排名 diff:纯前端计算,零服务端成本,提供"↑3 / ↓5"的热榜变化感
useInView({ once: true })+useQuery懒加载:首屏只渲染视口内卡片,请求量随滚动按需发生- 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 |