在 Windows 上找不到顺手的 RSS 阅读器,于是我自己写了一个

起因

起因很简单:我想多看一些文章。

不是被信息流推着刷,而是自己挑一些值得读的源,攒起来慢慢看。RSS 恰好就是这个形态——没人给我排序,也没有推荐算法替我决定该看什么,读到什么完全取决于我订阅了谁。

于是我开始找客户端。我主要用 Windows,试过一圈之后发现选择相当尴尬:

  • 网页版:打开慢,切个标签页就找不着了,还要看服务商什么时候关站。
  • 在线服务:数据在别人服务器上,隐私且不说,免费额度、导出限制、突然改版都是变量。
  • 老牌桌面客户端:功能其实够用,但界面停留在十年前,高分屏下字体发虚,装完先弹广告或者引导你买 Pro。
  • Electron 系:装完先吃掉 300 MB 内存,冷启动能看见窗口一帧帧画出来。

我不要求它有多花哨。我的需求清单其实很短:

  1. 订阅源和阅读状态存在本机,不上传;
  2. 启动要快,常驻内存别太夸张;
  3. 中文排版能看,浅色 / 深色跟随系统;
  4. 支持 OPML 导入导出,别把我锁死;
  5. 图片能正常显示——这点后面成了最大的坑。

找了几天没找到满意的,索性自己写。于是有了 RSS Reader

技术选型:为什么是 Tauri 2

选 Tauri 而不是 Electron,核心就一条:我不需要一个完整的 Chromium

Windows 上 WebView2 是系统自带的(Win11 内置,Win10 装一次),Tauri 直接复用,安装包和内存占用都能压下来。代价是三个平台的 WebView 内核不同,遇到怪问题时得自己扛——但对我这种「自己用的工具」来说,这个交易划算。

分工也很清晰:

技术 负责什么
桌面壳 Tauri 2(Rust) 窗口、持久化、网络、图片代理、更新
前端 React 18 + TypeScript strict + Vite 5 只做展示与交互
Feed 解析 feed-rs 2 RSS 2.0 / 1.0、Atom、JSON Feed
HTTP reqwest 0.12 rustls、gzip/brotli、代理

有一条设计原则贯穿全程:状态的权威在 Rust 侧。订阅源、文章、分组都落在 app_data_dir/state.json,前端只是视图。主题、字号、抓取频率这些纯展示偏好才放 localStorage。这样窗口关掉再开,数据一定是对的;前端崩了也不会把数据写坏。

几个真正花时间的坑

功能列表写起来一行,做起来是另一回事。下面这几件事占了我大部分调试时间,也最值得记下来。

1. 图片:防盗链、混合内容与一条重试阶梯

RSS 里的图片看起来是 <img src="..."> 这么简单,实际上一半显示不出来:

  • 图床校验 Referer,空 Referer 放行、带错 Referer 就 403;
  • 正文里是 http:// 图片,在 WebView 里被混合内容策略拦掉;
  • GitHub Pages 上的图片没跟着发布,原地址恒为 404;
  • 图床按 IP 限流。

最后的方案是本地注册一个 rssimg:// 协议,所有文章图片都走它,由 Rust 侧带浏览器 User-Agent 去抓。抓取按阶梯来:

  1. 先直连、不带 Referer(多数防盗链对空 Referer 是放行的);
  2. 遇到 403 就补上文章页的 Referer 重试,并把这个图床记进一个集合——下次同一图床的第一个请求直接带 Referer,省掉一次注定失败的往返;
  3. 还失败就回退直连再试一次;
  4. GitHub Pages 的图片额外生成 cdn.jsdelivr.net 镜像候选。这里有个细节:镜像优先走当前链路(国内直连 cdn.jsdelivr.net 经常不通),失败后才退直连。

成功响应缓存 7 天,单张上限 50 MB,只放行 http / https

顺带一个工程上的小坑:rssimg:// 是自定义 URI scheme,不走 IPC,所以它拿不到命令参数上下文。应用里的代理配置得靠 update_proxy_setting 先同步到 Rust 的全局状态,图片代理才读得到。这种「看起来是一件事、实际跨了两条通道」的地方,最容易漏。

2. 写盘:临时文件 + 原子改名

用户点一下「标记已读」,就可能要写盘。直接 fs::write 有风险:写到一半断电,state.json 就成了半截 JSON,下次启动数据全丢。

做法是写临时文件再 rename。而 Windows 上 rename 在目标被占用时会失败,所以失败后要小睡一下重试一次。这类平台差异,文档里通常只有一句话,踩到了才知道。

另一半是频率。标记已读、收藏、排序都是高频操作,每次都落盘会把磁盘打满。所以走 800 ms 防抖合并,窗口隐藏或关闭前强制 flush 一次——既省 IO,也不会丢最后一次操作。

3. WebView 里的整页跳转和右键菜单

这两个是我自己用的时候最先受不了的:

  • 文章里点个链接,WebView 直接整页跳转,自定义标题栏被外部网页盖住,回不来了
  • 右键弹出的是 WebView 自带的 Back / Refresh / Save as / Print 菜单,一股「这是个浏览器」的味道。

修法是加全局守卫:<a> 的点击一律 preventDefaulthttp(s) 交给系统默认浏览器打开,只放行页内锚点;中键点击也拦掉,因为应用没有多窗口 UI。相对链接要靠正文容器上的 data-link-base 解析——文章原始地址才是正确的基准,不能拿应用自己的 origin 去凑。

右键菜单则整体屏蔽,但文本输入框要放行,否则搜索框里连粘贴都用不了。第一版就是一刀切,被自己骂了一顿才补上的。

4. 图标字体:5 MB 变 37 KB

界面用 Material Symbols Rounded。完整可变字体 5 MB 出头,而我实际只用了 40 多个图标,为了这几十个字形背 5 MB 显然不划算。

于是写了个子集化脚本:从源码里扫出用到的图标名 → 下载完整字体 → 按清单子集化 → 用 HarfBuzz 逐个校验连字可用性 → 写回 woff2。最终 37 KB,还不依赖 Google CDN。

代价是新增图标后必须重跑脚本,否则界面上会直接显示成文字(比如按钮上写着 search)。这个坑我在 README 的常见问题里专门写了一条。

5. 自动更新:签名、双源与回退

0.2.0 加了应用内更新。几个我觉得不能省的点:

  • minisign 签名,公钥内置在应用里,签名校验不过就拒绝安装。私钥丢了就再也发不出老用户能装的更新,所以必须单独备份;
  • 更新清单优先取 GitHub Releases,失败自动回退 Forgejo Releases——两边各托管一份 latest.json,各指向自己的资源;
  • 应用内配的代理一并用于更新请求;
  • Windows 走 NSIS 安装器的 passive 模式,装完自动重启。

现在它长什么样

两栏布局:左边文章列表,右边阅读视图,中间分隔条可拖拽调宽(宽度会记住)。

  • 订阅管理:RSS 2.0 / 1.0、Atom、JSON Feed;分组折叠与未读数;OPML 导入导出;单个源可选应用内阅读或外部浏览器打开
  • 抓取ETag / Last-Modified 条件请求(304 直接跳过下载与解析),并发上限 6,自动刷新间隔 10 分钟到 1 小时可选
  • 阅读:全部 / 未读 / 收藏筛选,最新 / 最早 / 按源排序,紧凑 / 列表 / 卡片三种视图,标题 + 正文全文搜索,大列表首屏 300 篇、滚动加载更多
  • 全文:摘要过短时一键抓原文,正文容器启发式提取并清理广告 / 评论 / 侧栏,最近 20 篇缓存复用
  • 数据:JSON 备份 / 还原,按发布时间清理本地缓存(星标保留)
  • 网络:HTTP / SOCKS5 代理,一键连通性测试(多探测目标,避免单站误报),SOCKS5 走 socks5h 让代理解析域名
  • 安全:最小权限 capabilities、严格 CSP、正文渲染前移除 script / iframe / form / base 等节点

当前版本 0.2.1,Apache-2.0 授权,Windows x64 有现成安装包(NSIS / MSI / 免安装单文件),macOS 和 Linux 需要自己编译。

不足的地方,我很清楚

写着写着就明白为什么这类工具少有人做了——坑太多,而每个坑都要单独填。下面是目前已知的问题,我不打算藏:

  • 正文提取只是启发式。没上 Readability 级别的评分算法,容器选择器 + 无关元素移除在不同站点效果差异明显,有些站抓出来还是会缺段落或者多塞东西。
  • 正文渲染不做完整 HTML 白名单净化。为了保住排版只做节点清理,因此只适合可信订阅源。这是明确的安全取舍,不是疏忽。
  • 没有云端同步。多设备迁移只能靠备份 / 还原或 OPML,计划里的 WebDAV 备份还没做。
  • 跨平台没验证bundle.targets = all 也只打包当前平台,macOS / Linux 我没机器实测,只能保证代码层面没写死 Windows。
  • 翻译功能没做,外文源只能自己看原文。
  • 早期阶段0.2.1 还在快速变动,state.json 的结构虽然带 schema_version 和迁移逻辑,但不保证百分百兼容。

所以,欢迎提 Issue

我写它的直接原因就是「自己没找到好用的」,那就意味着你觉得别扭的地方,多半我也没考虑到。不管是 bug、界面建议、还是「这个源解析出来是乱的」,都欢迎开 issue:

想让 issue 好处理一点,附上这些会省很多来回:

  1. 复现步骤:点了什么、期望什么、实际什么;
  2. 订阅源地址(如果和某个源有关)——解析问题九成要拿原始 XML 复现;
  3. 界面问题直接截图,比文字描述快十倍;
  4. 出错的图 / 文章链接,图片问题基本都要看具体响应;
  5. 版本号(设置 → 关于里有)。

Pull Request 同样欢迎。提交前跑一下 npm run build(TypeScript strict + Vite 构建),改了 Rust 建议再过一遍 cargo fmtcargo clippy --all-targets

最后

做这个小工具最大的收获,是重新意识到「能用」和「好用」之间的距离有多远:抓取解析一两天就通了,但防盗链、原子写盘、WebView 跳转、图标子集化这些看起来边缘的细节,才真正决定你愿不愿意每天打开它。

它现在还不完美,但我自己就是它的第一个用户。如果你也想多看几篇文章、在 Windows 上找 RSS 阅读器,不妨试试;用得别扭,就来提 issue。