一个月前写了个 RSS 监控脚本,跑 GitHub Actions,发现新文章推飞书和邮件。用着还行,但有个问题——想看历史文章还得打开链接。而且配订阅源要改 YAML 文件推代码,太麻烦了。
所以干脆重写了一个带前端的版本。这次直接扔 EdgeOne Pages 上了,Cloud Functions 处理后端,Blob Storage 存数据,前端就是个单页 HTML。
架构
EdgeOne 有两种运行环境。Edge Functions 类似 Cloudflare Workers,跑 V8 隔离,不支持 npm 包。Cloud Functions 跑 Node.js 20,能装依赖——rss-parser、uuid、@edgeone/pages-blob 这些都能用。
所以选了 Cloud Functions。
所有逻辑写在一个文件里,cloud-functions/[[default]].js,手动路由。大概五百行,包含了:
- RSS 解析(rss-parser,带重试)
- 订阅源 CRUD
- SSE 实时推送
- Blob Storage 读写
- OPML 导入导出
- 简单的 IP 级限流
- API Key 认证
没用框架。Node.js 20 原生 fetch,ReadableStream 做 SSE。就一个文件,部署上去完事。
前端
前端是纯 HTML 文件,没有框架没有构建步骤没有 node_modules。CSS 变量做主题切换,EventSource 连 SSE。
设计上抄了 Claude 的配色——深色背景配橙色点缀。
布局是双栏,左边 220px 侧边栏管订阅源,右边文章区用 CSS Grid:repeat(auto-fill, minmax(300px, 1fr))。移动端会自动折叠成单列。
状态管理?三个变量:feeds、articles、activeFeedId。没有使用常用的svelte框架,改了就重新渲染。
SSE 实时推送
连上之后服务端推送两件事:
- 连接时发当前全部文章(当初始状态)
- 抓取完成后发增量文章
每 30 秒发一次心跳保活。前端断线自动重连,5 秒后重试。
实现代码不多,ReadableStream 包一下就行:
const stream = new ReadableStream({
start(controller) {
// 发连接消息 + 初始文章
// 设心跳定时器
// context.waitUntil 等断开清理
}
}); 存储
Blob Storage 存三个 key:
feeds— 订阅源数组last-check— 最近一次抓取结果(每次覆盖,不会无限膨胀)settings— 时间范围之类的配置
每次手动点「抓取」或者 GitHub Actions 定时触发,都会重新拉取 RSS,比对最新结果,只推增量文章到 SSE 客户端。
时间范围默认 24 小时,可以在设置里调到最长 168 小时(7 天)。
OPML 导入导出
加这个功能是因为换平台的时候一个个加订阅源太痛苦了。导出是一个 OPML 文件,导入解析 <outline> 标签提取 URL 和标题,自动跳过已存在的。
前端界面侧边栏底部两个按钮,下载和上传。
踩坑
@edgeone/pages-blob 版本。 一开始写 ^1.0.0,部署报错找不到包。查了一下最新是 0.0.8。npm registry 没骗人,EdgeOne 的包确实在 1.0.0 之前。
CORS。 本地写测试页调远程 API,被浏览器拦了。最后是把测试页一起部署到 EdgeOne 同域名下才解决。
SSE 断线闪动。 EventSource 重连的时候页面会闪。加了重连状态判断和心跳检测才稳住。
API 缓存。 一开始设了 max-age=60,导致点了「抓取」之后文章列表不更新。改成 no-cache 就好了。EdgeOne 边缘节点会缓存响应,动态接口不能缓存。
和之前那个 rss-robot 比
rss-robot 是纯后端脚本,跑 Actions 推飞书。优点是零运维,缺点是没 UI、改配置要推代码。
这个新版本有前端页面可以直接看文章、管理订阅,SSE 实时推送也省了轮询。但需要部署到 EdgeOne,比 GitHub Actions 多了一层。
两个项目我都还在维护。rss-robot 适合只需要通知的场景,这个适合想在一个地方读完所有订阅的场景。