如果你的书签栏和收藏夹里堆了上千条“待读”链接每天打开十几个网站看更新却总觉得什么都读不完——Colibri 就是为解决这个问题而生的。它是一个用蜂鸟命名的轻量级信息聚合工具核心思路是把散落在各个网站、RSS 源、甚至是脚本输出里的内容统一收进一个本地服务里定时抓取、去重、打标签、生成摘要然后你在一个界面里集中阅读。我写 Colibri 的初衷很简单市面上现成的 RSS 阅读器要么太重要么收费要么数据在云端我不放心而自建一套又觉得遥遥无期。后来我花了大概两个周末用 Node.js 加 SQLite 把它从零搭出来中间踩了不少坑也推翻了两次核心设计。这篇文章会把整个项目的定位、模块拆解、实操过程、日常故障排查全部写清楚既有原理也有可复制的代码思路适合想自己动手做信息管理工具的开发者也适合对自托管服务感兴趣、想了解一个工具是如何从需求长成项目的朋友。1. 项目定位与整体设计思路1.1 为什么“小而轻”才是信息聚合工具的出路我给 Colibri 定的关键词是本地优先、插件化、低资源占用。我见过不少同类项目功能大而全但部署完就后悔——一跑起来占几百 MB 内存界面花哨得像个管理系统其实我只需要一个能抓标题、存正文、满了告诉我该读什么的架子。Colibri 的设计哲学参考了蜂鸟本身体积小、动作快、只吃花蜜。这意味着在最初设计时做了几个硬性约束。第一不使用重量级数据库。选 SQLite 而不是 MySQL/PostgreSQL数据文件就一个备份直接把文件拷走对单机使用场景来说完全够用。第二前端不用大型框架服务端渲染加一点原生 JS整个系统加载出来的静态资源压缩后不到 100KB。第三抓取过程全部走异步队列避免一个网站响应慢拖死全部任务。这些约束听起来简单但在后面实现时每一个都在倒逼我简化设计。1.2 技术选型与方案权衡技术栈如下运行时Node.js 20 LTS数据库SQLitebetter-sqlite3抓取axios cheerio解析内置一个兼容常见 RSS 2.0 / Atom 的解析器前端服务端渲染 原生 JS模板用 Nunjucks部署Docker 容器 docker-compose 一键启动选择这些组合的原因是它们互相之间依赖很少出错时容易排查。比如很多人会用 Puppeteer 做整页渲染抓取但我一开始就排除了它——太消耗资源而且对内存小的 VPS 不友好。Colibri 的抓取策略基于一个原则能用 HTTP 请求拿到 HTML 就直接解析只有遇到极少数需要执行 JS 的动态页面才考虑无头浏览器插件。这个取舍让基础抓取模块稳定且快速。如果你比较熟悉 Python 的 Scrapy 或者 Go 的 colly可能会问为什么要用 Node。原因有两点一是方便前端和爬虫共用一套 JavaScript 工具函数二是异步非阻塞模型在处理几百个并发连接时特别自然代码写起来也直观。接下来我会从模块层面把 Colibri 的实际构造拆开讲清楚。2. 核心模块拆解与实现要点2.1 抓取调度器用 setTimeout 代替调度库调度器是信息聚合工具的心脏。它负责按照每个订阅源的抓取间隔定期发起请求并把新内容写入存储。第一版我用的是 node-cron 定时触发后来发现一个问题如果一个源抓取耗时较长而定时器又到了触发时间就会造成任务堆积。最后实现了一个极简的方案核心思想是异步任务链async function schedulerLoop() { while (!stopped) { const feeds db.prepare(SELECT * FROM feeds WHERE enabled 1).all(); for (const feed of feeds) { scheduleFetch(feed); } await sleep(60 * 1000); // 每分钟扫描一次订阅表 } } function scheduleFetch(feed) { const now Date.now(); if (feed.next_fetch_at now) { // 立即发起但用 setImmediate 避免阻塞调度循环 setImmediate(() fetchAndProcess(feed.id)); } }关键点在于 next_fetch_at 这个字段的计算。我采取的策略是每次抓取成功后根据该源历史平均响应时间动态调整下一次抓取时间。比如一个博客原来每 3 天更新一篇文章那 4 小时抓一次就够了但如果一个新闻源每次抓取都有新条目就把间隔缩到 15 分钟。这套逻辑逻辑简单但省下了很多无意义的 HTTP 请求。2.2 内容提取器readability 思路的纯本地实现拿到 HTML 之后最麻烦的是从一堆导航、广告、侧边栏里把正文抠出来。我一开始直接用 cheerio 去匹配常见的正文容器 class 名像 article-content、post-body 之类。这种方式的问题很明显每个网站的前端工程师都有自己的命名习惯一旦改了类名规则就失效。后来我参考了 Mozilla 的 readability 算法思路实现了一个改良版。基本原理是去掉 script、style、nav、aside、footer 等噪声节点。扫描剩余节点给每个节点计算一个“文本密度”得分即节点内文本长度除以子节点数量。文本密度最高且长度超过 200 字的节点大概率就是正文容器。对正文容器内的 p、h2、h3、li 标签按顺序提取还原成纯文本。这段逻辑大约 200 行实测对 90% 的静态博客和新闻站都有效。如果你只用它提取 RSS 全文那会更简单——直接在 XML 里取 content:encoded 字段。但很多源只提供摘要所以这个本地提取器是保证阅读体验的关键。2.3 插件系统如何设计一个 30 行的插件机制Colibri 的插件机制被很多人夸过但实现真的不复杂。它本质上就是一个约定大于配置的 JavaScript 模块列表。插件需要导出一个对象包含 name 和 handlerhandler 接收一个 context 对象可以修改其中的 text、tags、metadata 字段。// plugins/dedupe.js module.exports { name: dedupe, transform: (ctx) { const url ctx.entry.url; ctx.entry.is_duplicate db.prepare( SELECT id FROM entries WHERE url ? ).get(url) ? 1 : 0; } };每个插件针对不同类型的任务分组抓取前插件修改请求头、加代理、处理登录态。抓取后插件净化正文、关键词提取、自动打标签。入库后插件推送通知、生成摘要、执行自定义命令。这种设计的好处是主程序只需要维护一个插件加载器业务逻辑全部解耦。插件市场里的比如 telegram-notifier、摘要生成器都是一百行左右的小脚本。2.4 智能摘要先关键词再抽取式摘要给每篇文章生成摘要是我最想偷懒的部分也是最不能偷懒的。调大模型接口当然效果最好但自托管工具不应该依赖外部付费服务。于是我实现了一个基于词频统计的抽取式摘要方案对正文做分词和停用词过滤。统计每个词在全文中的 TF词频。按句子拆分计算每个句子内关键词的加权和。选出得分最高的前三句合并为摘要。英文、中文都能处理对短新闻类内容友好但对长文经常抽得不够准。所以我在摘要设置里留了一个选项默认用词频摘要也可以配置为调用本地 Ollama 模型做生成式摘要把 Colibri 做成一个能接入本地 AI 的工具。2.5 API 与前端界面一个远程操作入口为了让 Colibri 不只是个爬虫脚本我给它加了一个轻量的 HTTP API。这个 API 实现了几件事查看订阅列表、手动触发抓取、标记已读、查看统计信息。前端页面是服务端渲染的打开首页就能看到今天的抓取统计和未读列表。API 没有引入 Express 框架直接使用 Node 内置的 http 模块加一个极简路由函数。原因只有一个依赖越少后续维护越省心。整个路由代码也只有几十行把 method url 映射到处理函数即可。const routes { GET /: renderHome, GET /feed/:id: renderFeedEntries, POST /fetch: triggerFetch, POST /read/:id: markAsRead }; const server http.createServer(async (req, res) { const [urlPath, id] req.url.split(/).filter(Boolean); const handler routes[${req.method} /${urlPath}]; if (handler) return handler(req, res, id); res.writeHead(404); res.end(Not Found); });这里有个好处值得说API 也能被其他自动化工具调用。比如我写了一个快捷指令在手机浏览器里复制网页链接把它直接推送到 Colibri 的 /api/bookmark 端点链接会在后台自动加入待读列表相当于一个自建稍后读。3. 实操过程与核心实现3.1 环境准备与数据库初始化先准备环境。你需要 Node.js 20 以上版本和 Docker可选用于部署。一切就绪后执行以下命令git clone https://github.com/yourname/colibri.git cd colibri npm install npm run init-dbinit-db 脚本会创建三个核心数据表结构经过几轮演变最终稳定为表名字段说明feedsid, url, name, update_interval, next_fetch_at, enabled, last_fetch_status订阅源信息entriesid, feed_id, title, url, text, summary, is_read, is_duplicate, published_at, created_at抓取到的条目pluginsid, name, plugin_config, enabled, sort_order插件配置特别注意 entries 表必须对 url 建唯一索引。我在早期版本没建索引结果同一篇文章被三个源同时抓到后入库三次阅读列表里塞满了重复内容。后来加了CREATE UNIQUE INDEX idx_entries_url ON entries(url)抓取时用INSERT OR IGNORE重复问题直接根治。3.2 抓取 RSS 源的具体流程以抓取一个 RSS 2.0 订阅源为例完整流程分四步第一步解析 XML。用 fast-xml-parser 把订阅源内容转成 JSON提取 channel 下的 item 列表拿到每篇文章标题、链接、发布时间和正文描述。第二步去重与新增判断。根据链接查询数据库如果已经存在就跳过不存在则进入正文提取流程。第三步正文提取。如果 RSS 里没有全文就用 2.2 节提到的提取器处理文章页 HTML。这里需要设置超时我用的是 10 秒超过则直接放弃避免某个响应极慢的站点拖垮整体任务。第四步触发插件链。正文进数据库之前按 sort_order 依次执行启用的插件例如自动提取关键词、生成摘要、发送 Telegram 通知。整体抓取配置里还有两个实用的调优项。一个是请求头里必须带 User-Agent最好模拟真实浏览器否则不少站会返回 403。另一个是每次请求之间加 1 到 3 秒的随机延迟既能降低对方服务器压力也能减少被临时封 IP 的几率。3.3 配置 Docker 部署与 systemd 托管如果你只想在本地跑node src/server.js就够了。但如果想挂到家里的 NAS 或者云服务器上长期运行建议用 Docker 封装。我写的 Dockerfile 很小只有几行FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY . . VOLUME /app/data EXPOSE 3000 CMD [node, src/server.js]然后 docker-compose.yml 里把数据目录挂载出来端口映射到宿主机的 3000。用 Docker 的好处是升级替换镜像非常方便不会把 Node 环境搞乱。如果不用 Docker也可以用 systemd 服务托管。写一个colibri.service单元文件主要配置 ExecStart 指向 Node 启动命令Restartalways 保证进程崩溃后自动拉起来。这两种方式我都长期跑过推荐优先选择 Docker 方案原因是你不会因为一次npm install全局依赖冲突把整个机器搞坏。3.4 手动添加订阅源与脚本自动化添加订阅源有三种方式。第一种是在管理界面表单里直接输入 RSS 地址同时填名称和抓取间隔。第二种是调用 APIcurl -X POST http://localhost:3000/api/feeds \ -H Content-Type: application/json \ -d {url: https://example.com/feed.xml, name: Example Blog}第三种是我比较喜欢的方式写一个 shell 脚本或快捷指令把网页地址丢进来Colibri 先尝试页面里的link relalternate typeapplication/rssxml自动发现 RSS 地址再添加。这个功能在 API 中对应/api/bookmark端点参数只需要一个 page_url。自动发现 RSS 地址的实现也不复杂。正常网页 HTML 头部会带这样一行link relalternate typeapplication/rssxml titleRSS href/feed.xml用 cheerio 解析 head 节点找到 rel 为 alternate 且 type 为 RSS 或 Atom 的标签拿到 href再拼成完整 URL 即可。对于没有声明 RSS 的网站则直接把它当文章页抓取一次提取正文后入库。4. 常见问题与排查技巧实录4.1 乱码问题UTF-8 之外的编码处理第一个月使用 Colibri 时我遇到的最高频问题是部分中文网站抓取下来正文全是乱码。排查后发现原因有两个网页没有显式声明 charset或者声明的是 GBK/GB2312。HTTP 响应头里一般会有Content-Type: text/html; charsetGBK但有些老站的 HTML 里只在meta charsetgb2312声明。我的解决方法是先读响应头的 charset如果没有再去看 HTML 前 1024 字节里的 meta 声明都没有则用jschardet做编码检测。拿到正确编码后用iconv-lite转成 UTF-8。这段兼容代码加上后中英文和日文站点基本都不再出现乱码。还有个容易忽略的细节是响应头里的Content-Length字段可能跟实际解压后内容不一致尤其是启用 gzip 压缩时。axios 默认会自动解压但要确保decompress: true没有被关掉否则你会拿一坨压缩后的二进制去当 HTML 解析结果自然是乱成一锅粥。4.2 部分网站 403 或反爬策略有些网站对非常规请求很敏感简单的 403 返回直接让抓取任务失败。我总结了几个实用对策按优先级排列设置浏览器风格的 User-Agent 和 Accept-Language。模拟从 Google 搜索点击进入的 Referer。两次请求之间加入 2 到 5 秒随机延迟。对特殊站配置插件里的 cookie 注入功能把登录后的 Cookie 写进去。终极方案接入第三方抓取代理服务但这就引入外部依赖了我本身不太推荐。对于 403 问题最重要的是看响应内容而不是只看状态码。有些反爬返回 403 时页面里其实带了验证码 JS而有些只是简单拦截。Colibri 的日志系统会记录请求状态码和响应体前 200 个字符排错时直接看日志比盲调强得多。4.3 时区与时间显示不对还有一个我折腾了很久的小问题文章发布时间总是差 8 小时。原因在于 RSS 里的时间格式各不相同有的是 UTC有的带 08:00 偏移有的干脆只有日期没有时区信息。解析后如果不统一转为本地时区显示肯定不对。解决方案是在解析层统一处理凡是带时区偏移的时间字符串直接转成 Date 对象存储不带时区的则根据全文抓取请求中 IP 归属地推断时区推断不出来就按服务器时区处理。最后在数据库里统一存 Unix 时间戳展示层再转成浏览器端时区。这样即使你换了一台服务器部署历史时间显示也不会乱。4.4 资源占用过高与优化Colibri 正常运行时的常驻内存大约是 60MB 左右抓取高峰时偶尔升到 100MB。如果你发现它内存涨到几百 MB从这几个方向排查是不是同时抓的订阅源太多了默认并发数我控制在 5超过就排队。是不是有插件泄漏了比如 Telegram 通知插件在断网重连时反复创建新连接。是不是数据库没做 VACUUMSQLite 文件长期增删后体积变大执行一次 VACUUM 能回收空间。如果你抓到超大页面比如一篇文章正文有 20 万字符建议在入库前做截断处理。我设定 5 万字符的上限超过部分直接丢弃反正正文末尾大多是“上一篇/下一篇”之类的无效内容。4.5 插件加载报错与隔离写插件时最怕什么最怕一个插件抛异常整个抓取任务崩溃。我早期确实遇到过一次某个插件里用了一个不存在的模块导致整条任务链中断其他订阅源也跟着遭殃。后来在插件执行外面包了一层 try/catch并把错误信息写进单独的 error 日志表。插件加载器还会在启动时做依赖检查如果插件声明的 npm 包没安装就不加载且给出提示。核心原则是插件调用失败不影响主流程。这条规则应该写进所有插件化应用的开发手册里。4.6 数据库文件备份SQLite 最方便的地方就是备份简单。我通过一个 shell 脚本每天凌晨用sqlite3 colibri.db .backup backup.db做在线备份然后 gzip 压缩再加上保留最近 30 天的清理策略。整个备份脚本不到 20 行比用 PostgreSQL 的逻辑备份轻太多。如果你打算长期自托管这里一个忠告把备份文件放至少两块不同的磁盘或者对象存储上。别问我是怎么知道的真遇到一次 SSD 损坏你才会明白备份的可贵。5. 项目后续规划与扩展方向Colibri 现在已经在我的两台设备上运行了半年抓了几万条内容已经成为我日常信息输入的入口。后面我还打算做几个小升级这里也分享给你算是给想二开的朋友指个路。第一订阅分组与阅读优先级。现在所有未读条目都混在一起我希望通过机器学习简单判断用户阅读习惯比如给技术深度文章和新闻快讯打上不同标签早上推送一篇深度学习内容午休时推送新闻摘要。这个功能用现有插件机制就能实现。第二支持 WebSub。WebSub 是一种即时回调协议订阅源更新时服务器会主动通知不需要定时轮询能大幅降低抓取延迟。官方 RFS 实现起来很简单后续版本我会优先支持。第三多用户支持。目前 Colibri 是单用户的但家里可能好几个人共用。规划是加一个简单的基于 token 的用户隔离每个人只看到自己的订阅和阅读状态。不过说句实在话工具不需要做得太复杂。信息管理工具的归宿从来不是大而全而是帮你能在纷杂信息里快速找到真正值得读的那一小部分。Colibri 目前已经做到了这一点之后的每一次迭代也都应该围绕“更快的帮我读值得读的东西”这个目标。做一个自托管工具最值得的不是最终代码有多优雅而是你在日常使用中不断发现需求、修复体验的过程。每次看到访问日志里那一行行抓取记录我都会想这些内容跨过网络被收集到一个 200KB 的数据库文件里然后我每天只在某个安静的早上花 30 分钟把它们读完——这大概就是信息时代里一个人对自己注意力最好的交代。