
开篇先交代一个前提这是我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写出来是N1查询我优化成一条带窗口函数的SQL之后查询时间从800ms降到60ms。第二分类和订阅源的多对多关系在ORM里维护方便但想按分类统计未读数量时要join三张表再group bySQL又臭又长。第三文章正文我本来存在一个独立的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, contentarticles, content_rowidid, tokenizeunicode61 ); 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, accept-language: zh-CN,zh;q0.9,en;q0.8, }, });每抓一个源之间至少间隔2秒并发数控制在3。有些源返回的Content-Type没有charset我就会在解析前用jschardet检测一下编码然后再交给cheerio。具体编码的坑我放到后面问题部分讲。全文提取用的Readability算法官方npm包是mozilla/readability但直接调用它效果并不理想需要先对HTML做几轮预处理这部分细节也放到踩坑清单里展开。3. 这37天踩过的坑每一个都是真金白银3.1 抓取乱码百分之八十的网页编码问题我在Day 11的时候发现一个很诡异的现象同样是中文博客一部分抓取过来是乱码一部分是正常的。排查半天发现不是网站本身问题而是我自己的解析链路没有统一处理编码。HTTP响应的Content-Type头里如果写了charsetgbkHTML的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_sourcexxx这样的追踪参数导致同一个内容出现多个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-size768并且把抓取并发数从默认的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解决N1查询问题理由很简单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天后它已经能稳定服务我的日常阅读了。接下来这个项目还会继续迭代我也会把后续的进展和踩坑记录下来。如果正在读这篇东西的你也想做一个类似的、自己每天都在用的工具我的建议很简单先让它跑起来再让它变好别在第一天就想着做到完美。