☰
37天独立开发自托管RSS阅读器:技术选型与踩坑复盘
2026/9/28 6:28:37 网站建设 项目流程

开篇先交代一个前提:这是我100天独立开发挑战的第37天,项目代号FeedBox,一个自托管的RSS阅读器。之前三十多天里我陆续在朋友圈和两个技术社区贴过进度,很多人私信问我到底做到哪一步了、技术路线怎么选的、有没有什么坑。趁着今天正好完成数据层重构,我干脆把前面这段时间的完整决策、踩坑和复盘一次性写清楚。这篇东西不是教程,更接近一个工程日志,但里面每一条我都亲手改过代码验证过,该给的命令、参数和思路都会给全。

先说项目现状:FeedBox已经能完成订阅源管理、文章抓取、全文提取、离线缓存和基础阅读这五件事,目前跑在我一台闲置的2核4G小服务器上,每天定时抓取约60个订阅源、4000多篇文章,去重和全文提取的成功率稳定在90%以上。今天做完数据层重构之后,单次全量抓取耗时从原来的4分多钟降到了1分半以内,数据库体积也小了一半。下面是完整复盘。

1. 37天项目全貌与整体思路

1.1 这个项目到底是什么

FeedBox定位很明确:给自己用的自托管RSS阅读服务。市面上的阅读器我基本都试过,问题集中在几个方面:要么是云端服务有封号风险,要么是免费版限制订阅源数量,要么是全文抓取质量太差只给摘要。我自己的需求其实很朴素,就是想在一个地方集中看技术博客、几个行业资讯站、还有关注的一些独立作家的更新,并且能离线缓存全文,出差路上一样能读。做一个自托管的服务,数据完全在自己手里,没有账号和配额限制,想怎么折腾都行。

技术上这就是一个典型的"抓取+解析+存储+展示"流水线。抓取层定时去拉取RSS/Atom源,解析出文章列表;内容层对每篇文章决定是直接用摘要还是抓取全文;存储层保存原始XML、解析结果和提取出来的正文;展示层提供Web页面阅读。听起来不复杂,但每个环节都有不少细节,我在前37天里绝大部分时间不是花在写新功能上,而是花在把这些细节打磨到可用状态。

1.2 为什么选这个方向而不是跟风做AI应用

现在AI应用很热,我倒是认真考虑过要不要追。后来想清楚了:AI应用的基础模型、API费用、评测指标这些变量太多,一个人短期很难做出护城河。RSS阅读器虽然是个老赛道,但恰恰因为老,生态很成熟,协议标准稳定,用户需求明确,不会突然出现一个技术变革把整个方向推翻。做这种工具型项目,技术上完全可控,能真正打磨出好东西。更重要的是,我每天自己都在用,狗粮自己先吃,有问题立刻能发现,这种正向反馈对长期坚持特别重要。

选RSS还有一个好处:它天然是"数据管道",后续不管是加全文搜索、做智能分类、生成摘要,都是在这条管道上做增量,不需要推倒重来。我自己计划里,Day 70之后会在这个基础上接大模型做文章摘要和标签推荐,这是后话。

1.3 前37天的时间线拆解

我先说结论:37天里真正"写代码"的时间大概只占六成,剩下的时间在做什么?做决策、查资料、重构、修bug。很多人做个人项目容易陷进"每天必须提交多少行代码"的误区,我自己的经验是,想清楚再动手比闷头写重要得多。下面是我前37天的大致划分,尽可能忠实记录当时实际做的事情:

  • Day 1-3:需求梳理和竞品分析。列了一个竞品功能对照表,把FreshRSS、Miniflux、Feedbin、Inoreader的核心功能全部拉出来对比,最后确定了MVP范围:订阅管理、抓取、全文提取、Web阅读、Docker部署,多用户和API放到二期。
  • Day 4-7:技术选型和项目骨架搭建。前后端分离,Vue 3写界面,Node.js写服务端,数据库先用SQLite。这个阶段产出的是能跑通"添加订阅源→拉到文章→展示列表"的最小原型。
  • Day 8-15:把抓取调度、解析、存储、阅读界面这四条线分别做完。这中间最费劲的是全文提取质量,后面单开一节细说。
  • Day 16-22:加缓存策略、做下载超时和重试机制、写Dockerfile和docker-compose。这周开始部署到服务器上,真正7x24小时跑起来。
  • Day 23-29:优化抓取稳定性。处理了一大批"抓取失败、乱码、重复文章"的问题,肉眼可见地稳定了。
  • Day 30-37:数据层重构。把原来ORM大量查询改成手写SQL,重新设计表结构,加入FTS全文搜索索引。今天刚把回归测试跑完。

这张表不是精确到天的计划表,项目中途一定会偏移。我心态比较好,里程碑只是参考坐标,功能达不到预期就砍掉,不硬拖。

2. 核心技术链路逐一落地

2.1 技术栈选型与替换记录

技术栈这件事,我踩过最大的坑是"选型时想的太多"。一开始我列了十几个候选,最后几乎是凭直觉定了方案。但经过37天实践,有几处做了替换,我把最终确定的栈和原因列出来:

  • 前端:Vue 3 + Vite + TypeScript + Tailwind CSS。选Vue纯粹是个人习惯,写起来顺手。Vite的冷启动快得感动,TypeScript在项目过3000行之后的价值非常明显。
  • 后端:Node.js + Fastify。一开始想用Express,后来发现Fastify内置了schema校验和性能监控,对JSON API项目几乎零成本提升,就直接用了Fastify。
  • 数据库:SQLite + better-sqlite3。这是中途换掉的。最开始我用的Prisma ORM,对象模型很优雅,但碰到复杂统计查询和递归分类查询时生成的SQL低效得离谱。有一天我打开SQLite的慢查询日志,发现一条获取"每个订阅源最近文章"的查询居然要800毫秒,这在本地SQLite里不可接受。花了两天把数据访问层全部改成手写SQL,这条查询降到了20毫秒,于是彻底弃用Prisma。
  • 抓取:undici做HTTP客户端,cheerio做HTML解析,mozilla/readability做正文提取。undici是Node.js内置的fetch底层,支持连接池、超时、重定向,做高频抓取很稳。
  • 队列:没有用BullMQ或RabbitMQ。单机场景下一堆消息队列属于杀鸡用牛刀,我自己写了一个within-process的简单任务队列,只有200行代码,支持并发限制、超时、失败重试,完全够用。

个人观点:技术栈没有绝对的好坏,只有匹配不匹配。在我这个项目里"单机、数据量不大、一个人维护"是硬约束,一切靠外部基础设施的方案都不合适。

2.2 数据层重构的完整过程

这次重构是从Day 30开始的,也是今天能写这篇复盘的直接原因。原来的表结构是典型的ORM设计:articles表存文章,feeds表存订阅源,通过外键关联,分类用中间表实现。听上去没什么问题,但实际跑起来有几个痛点:

第一,列表页要展示"每个订阅源最近3篇文章",用ORM写出来是N+1查询,我优化成一条带窗口函数的SQL之后查询时间从800ms降到60ms。第二,分类和订阅源的多对多关系在ORM里维护方便,但想按分类统计未读数量时,要join三张表再group by,SQL又臭又长。第三,文章正文我本来存在一个独立的article_contents表里,理由是"正文可能很大,分表性能好",后来发现SQLite根本不需要这样过度设计,一个表就够了。

重构后的表结构简化成了四张表:

CREATE TABLE feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, title TEXT NOT NULL, site_url TEXT, category TEXT, last_fetched_at INTEGER, fetch_status INTEGER DEFAULT 0, error_message TEXT ); CREATE TABLE articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, title TEXT NOT NULL, url TEXT NOT NULL UNIQUE, content_hash TEXT NOT NULL, summary TEXT, content TEXT, author TEXT, published_at INTEGER, fetched_at INTEGER DEFAULT (strftime('%s','now')), is_read INTEGER DEFAULT 0, is_starred INTEGER DEFAULT 0 ); CREATE INDEX idx_articles_feed_published ON articles(feed_id, published_at DESC); CREATE INDEX idx_articles_read ON articles(is_read, published_at DESC); CREATE VIRTUAL TABLE articles_fts USING fts5( title, content, content='articles', content_rowid='id', tokenize='unicode61' ); CREATE TABLE fetch_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, feed_id INTEGER NOT NULL, fetched_at INTEGER NOT NULL, result INTEGER NOT NULL, article_count INTEGER, duration_ms INTEGER );

这次重构还顺手解决了两个长期问题。一是articles表的url字段加了UNIQUE约束,重复抓取直接冲突跳过,不需要在代码里先查询再插入;二是加了一个content_hash字段,用来配合下面要讲的重复文章判断逻辑。做完之后我跑了一次全量回归测试,3000多篇文章的导入从原来的2分40秒降到50秒,效果非常直接。

2.3 抓取链路:从下载到全文提取

抓取一条RSS源的实际调度逻辑是这样的:解析出订阅源里的文章列表之后,不是每一条都立刻去抓全文。我先看数据库里有没有这个URL,有就跳过;没有的话,再判断这条是"只需要摘要"还是"需要全文"。我目前的策略是默认抓全文,但有三个例外:内容农场类的站点直接取摘要就行,图片站全文意义不大,部分反爬特别凶的站可以单独配置为"只看摘要"。

HTTP抓取这一步我一开始踩了很多坑,陆续加了很多参数,现在的核心配置是这样:

const client = new undici.Client(targetUrl, { connectTimeout: 10_000, headersTimeout: 15_000, bodyTimeout: 20_000, maxRedirections: 5, }); await client.request({ path: '/', method: 'GET', headers: { 'user-agent': 'FeedBox/0.1 (+https://example.com/bot)', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', }, });

每抓一个源之间至少间隔2秒,并发数控制在3。有些源返回的Content-Type没有charset,我就会在解析前用jschardet检测一下编码,然后再交给cheerio。具体编码的坑我放到后面问题部分讲。全文提取用的Readability算法,官方npm包是mozilla/readability,但直接调用它效果并不理想,需要先对HTML做几轮预处理,这部分细节也放到踩坑清单里展开。

3. 这37天踩过的坑,每一个都是真金白银

3.1 抓取乱码:百分之八十的网页编码问题

我在Day 11的时候发现一个很诡异的现象:同样是中文博客,一部分抓取过来是乱码,一部分是正常的。排查半天发现不是网站本身问题,而是我自己的解析链路没有统一处理编码。HTTP响应的Content-Type头里如果写了charset=gbk,HTML的<meta>标签里写的却是utf-8,那以哪个为准?

我现在的规则是按优先级来:HTTP头的charset优先,其次看HTML的meta声明,最后才用检测库。Node.js里做编码转换我用的是iconv-lite,代码很简洁:

const rawBuffer = await response.arrayBuffer(); let html = iconv.decode(Buffer.from(rawBuffer), 'utf-8'); // 如果检测到gbk则重新解码

实践中发现,国内老一批网站和部分政府机构网站还是GBK/GB2312,这个不做兼容,漏掉的信息源会很多。我后来干脆写了一个探测函数:先看HTTP头,没有就看HTML前1024字节里的charset声明,再不行就jschardet检测。这套规则下来,乱码率从最初的5%降到了0.3%以下。

3.2 全文提取质量不稳定

这应该是整个项目里最让我头疼的部分。Readability这个算法本身很成熟,但它是为桌面浏览器阅读模式设计的,喂给它什么样的HTML直接决定输出质量。我一开始直接拿网页原文进去,结果正文里混着大量导航、评论、相关推荐,甚至还有广告。后来查了Readability源码才知道,它对DOM结构干扰项的识别依赖一系列阈值判断,如果页面加载了动态JS,很多内容根本没出现在HTML里。

我的解决方案是三层预处理。第一层,把原文里的<script>、<style>、<noscript>、<iframe>、<nav>、<form>、<aside>、<footer>等无用节点全部移除,这里的<footer>删除要小心,有些文章的版权声明和许可协议就在footer里,对我这种自用场景可以直接删。第二层,对部分明显是"列表页"而非"文章页"的URL做特殊处理,如果发现URL里包含/tag/、/category/、/archive/等路径段,且页面内有超过20个<a>链接指向不同文章路径,直接判定为列表页,跳过全文抓取。第三层,把预处理后的HTML交给Readability提取,提取之后再做一次清洗,比如把里面残留的空段落、纯空格文本、只有广告文案的div清理掉。

我另外发现一个规律:很多网站的移动版页面(m.子域)HTML比桌面版干净得多,因为移动版本身就是为了手机浏览器设计的。现在我抓取时会先试探性地请求一次移动版URL,如果返回200且是HTML内容,就优先用移动版做提取。这个技巧让全文提取成功率从76%直接升到了93%。

3.3 重复文章判断的误杀与漏判

RSS源有一个很烦人的特点:同一个作者的文章会被多个源重复转发,同一篇博客可能出现在个人站和两个聚合站里。最开始我用URL去重,但很快发现很多聚合站会给URL加?utm_source=xxx这样的追踪参数,导致同一个内容出现多个URL,我的去重直接失效,同一篇文章出现在列表里三次。

后来我加了content_hash字段,取文章正文的前2048个字符,去掉所有空白字符和HTML标签之后做SHA256。这个方案对"重复"的定义是严格的:必须是正文内容几乎完全相同的两篇文章才算重复。但这种方式对同一个内容被二次编辑过的场景(比如加了段前言)就无能为力。

最终我采用的是两层配合:URL归一化之后做精确匹配,内容hash做近似匹配。URL归一化就是去掉所有query参数、统一协议、去掉结尾的斜杠和index.html等,这个能挡住80%的追踪链接问题。内容hash如果再碰撞,就保留先抓取的那篇,后到的直接标记为duplicate。实际跑下来,重复率控制在2%以内,误杀率很低。

3.4 服务器部署与资源占用

我的这台2核4G小服务器上还跑着两个其他服务,所以FeedBox的资源占用必须控制得很紧。最开始我没做任何限制,Node.js进程内存随随便便涨到1.5G,因为RSS解析、HTML解析、全文提取这几个环节都会产生大量中间对象。后来做了两个改动:

改动的第一个是给Node.js设置了--max-old-space-size=768,并且把抓取并发数从默认的10降到了3,内存直接掉到500M以内。第二个是SQLite的journal模式改成WAL。WAL模式下读和写不再互相阻塞,而且把大量小的随机写合并成顺序写,对闪存盘友好很多,实测抓取过程中的卡顿明显减少。

另外,Docker部署时我给每个容器都加了内存限制:

services: feedbox: build: . restart: unless-stopped ports: - "8080:8080" volumes: - ./data:/app/data deploy: resources: limits: memory: 768M

定时任务没交给容器里的node-cron,而是用宿主机crontab直接跑一个脚本,每天凌晨3点触发一次抓取。凌晨3点是非高峰时段,不会影响白天阅读体验。

4. 时间管理与工作流沉淀

4.1 一天90分钟,怎么把项目往前推

很多关注这个挑战的人问过我一个问题:你是怎么做到连续37天不中断的?我的答案比较朴素:我不追求每天产出大量代码,我只确保每天有真实的进展。这个项目每天固定投入90分钟,分成三段:前15分钟过一遍issue列表和昨天的未完成事项,中间60分钟专注写代码,最后15分钟提交代码、写log、更新任务看板。

这90分钟我会强制关掉微信和手机通知。看起来规定得很死板,但长期坚持下来发现,90分钟恰好是"能完成一个完整功能点"和"不会产生太多压力"之间的平衡点。中途有三天我因为赶工作进度只写了15分钟,那就写一个文档、改一个样式、修一个注释,也算没断线。

4.2 用Git分支管理个人项目的另类经验

一个人的项目不需要复杂的分支模型,我固定用main作为开发的默认分支,永远不直接往上面推代码。每个功能点开一个feature/xxx分支,写完测试通过之后用--no-ff方式合回main。这样做的最大好处是,任何时候出问题,我都可以快速回滚到"上一个完整可用状态"。单人开发最大的风险不是代码冲突,而是改坏了没法回退。

每次提交我都在message里写清楚"做了什么+为什么这么做",比如:

refactor: 将articles表查询改为手写SQL,解决N+1查询问题

理由很简单:37天前写的代码,可能几天后自己就看不懂了。提交信息是我自己检索代码的一个索引。

4.3 测试策略:先保核心链路

个人项目最容易犯的错误是不写测试,我的项目测试覆盖也不是面面俱到,但核心链路必须有。目前有32条单测和8条集成测试,单测覆盖的是:RSS解析、URL归一化、重复文章判断、HTML清洗、Readability提取结果。集成测试覆盖的是:添加订阅源→抓取→存库→查询列表这条主流程。

新增一个抓取规则或者改一个解析逻辑时,我会先跑一遍测试再手动验证两个真实站点。这个习惯帮我至少拦下了五六次"以为改好了其实把原有功能改坏了"的回归bug。

5. 下一步:Day 38到Day 100的规划

5.1 优先做全文搜索和多用户隔离

Day 38到Day 55的目标是全文搜索和基础的多用户支持。全文搜索在SQLite里用FTS5就能做,表结构里已经建好了articles_fts这张虚拟表,接下来要做的是把新抓取的文章自动同步到全文索引,再加上一个搜索API和前端搜索框。多用户这一块我比较谨慎,RSS阅读器多用户意味着要引入账号系统、用户偏好、订阅隔离,复杂度会上一个台阶。我的计划是先做最基础的"用户表+登录+订阅源归属",不做社交功能,不做推荐系统。

5.2 后段的功能打磨与发布路径

Day 56到Day 70做移动端适配和PWA,目标是在手机浏览器里可以像App一样使用,加上离线阅读和推送提醒。推送这块我打算优先做"移动端Web推送",比做原生App省事很多。Day 71到Day 85做OPML导入导出、一键备份、数据导出,这些是一个自托管工具给人信任感的底线。Day 86到Day 100集中打磨体验、写使用文档、做一个小型官网,然后正式发布v1.0。

这中间可能还会有计划外的事情插进来,我留了15天的buffer。有人可能会说,一个100天的挑战不是应该排得更紧凑吗?我的想法是,做个人项目不是为了"看起来很忙",而是做出来真正能用的东西。留buffer是为了应对那些"改了三行代码结果花了三天查问题"的意外,在这条路上,意外才是常态。

按我个人的体会,独立开发RSS阅读器这类工具型项目,最磨人的不是写代码,是"打磨到自己的标准之上"这个过程。FeedBox离我理想中的状态还有距离,但接手37天后它已经能稳定服务我的日常阅读了。接下来这个项目还会继续迭代,我也会把后续的进展和踩坑记录下来。如果正在读这篇东西的你也想做一个类似的、自己每天都在用的工具,我的建议很简单:先让它跑起来,再让它变好,别在第一天就想着做到完美。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询