上周清理浏览器收藏夹的时候我发现里面整整躺着 600 多条链接有的是技术教程有的是一时兴起想看的深度报道还有几篇是朋友转来却一直没时间打开的文章。我大概率不会再点开它们可让我直接删除又舍不得因为里面确实有一些当时很有价值的内容。这种收藏即遗忘的焦虑折磨了我很久于是我花了三个周末写了一个叫 rea 的自托管稍后读工具解决链接失效、文章消失、想找以前看过的一句话却翻遍所有 app 都搜不到的问题。这篇文章就是从零到跑通整条链路的完整复盘包括技术选型、正文抓取、全文索引、常见坑位和日常部署维护如果你也想有一个完全属于自己的稍后读 全文搜索存档库可以直接照着做。1. 600 条收藏夹链接把我逼到什么份上1.1 收藏夹的本质是一座信息坟场我统计过自己过去一年的信息消费习惯每天平均会看到 20 条左右值得收藏的内容真正打开细读的可能不到 2 条。收藏那一刻的心理是先存着以后有空再看但以后永远不会来。更麻烦的是过了一段时间你真的想找某个内容时会发现几个很尴尬的事实。第一原文可能已经没了。有些网站的文章会删除、改版、或者关闭归档你收藏夹里的那条链接点开就是 404。第二收藏夹本身不会帮你搜索。浏览器的书签只能按标题匹配我想找那篇讲布隆过滤器的文章如果标题里没写布隆两个字就永远翻不出来。第三不同入口的数据是割裂的。手机上看的东西、电脑上看的文档、某个 App 里的推送各存各的没有一个统一的地方能让我全文检索自己看过的所有东西。我试着用现成的笔记软件加书签插件做管理发现它们要么是云端服务、数据不经我手要么在抓取网页正文时特别随缘排版错乱、图片丢失是常事。对我这种比较在意数据归属的人来说唯一能接受的方向是自己搭一套链接由我保存正文由我抓取索引由我生成备份由我做。1.2 为什么不能直接用现成的稍后读产品市面上的稍后读服务其实不少但用下来总有几处让我不舒服。首先是费用模型真正好用的高级功能基本都在订阅制里每个月为一件事付费没问题问题在于我的数据它的格式——哪天不想续费或服务停运导出数据变成一件麻烦事。其次是抓取质量很多平台对中文长文、代码块、表格的还原度一般有些页面干脆只存了标题和一个链接等于白存。最后是封闭性你没法写脚本去拿到自己的文章列表也没法在里面对正文做任意角度的分析。我当然不是劝所有人都来自托管。如果你完全不 care 数据在谁手里、也不太需要自定义抓取逻辑现成产品体验确实不错。但如果你和我一样希望存进来的每一篇正文都以我能控制的格式存在自己的硬盘里那 rea 这种自托管方案就是刚需。1.3 我给自己划的 MVP 边界动手之前我先定义清楚做什么、不做什么否则很容易做成一个永远上不了线的老坑。rea 的最小可用版本只需要四件事输入一条链接抓取正文并转成干净的纯文本或 Markdown把正文放进本地数据库并建立全文索引提供一个简单的网页界面用来搜索和阅读。不做用户体系不做多语言不做复杂的数据分析不做移动端原生 App也不做任何社交功能。时间预算也很粗暴每周只有两三个晚上三周内必须看到能用的东西否则砍功能而不是加功能。这个约束帮了大忙后面很多技术选型都是为了在最短时间里稳定跑通服务的。2. 技术选型为什么每个依赖都是砍出来的2.1 后端语言和存储的取舍逻辑最开始我列了两个候选一个是用现成的静态站点生成器配合脚本去抓取 HTML 文件另一个是写一个真正的后端服务。前者看起来更简单但搜索、去重、更新这些需求很快就绕不开数据库所以最后还是选了后者。语言用了我常用的 Python理由很实际网页抓取和正文解析这块生态比较成熟遇到问题时我能少踩很多轮子。存储我选了 SQLite而不是单独开一个数据库服务。这在单用户、单机的场景下其实是最务实的决定。SQLite 本身就是一个文件备份直接复制文件就完了不需要考虑账号、端口、内存参数一堆东西。很多人的第一反应是SQLite 会不会不够用对 rea 这个场景来说完全不会即使我每天存 50 篇文章跑十年也就是几十万条记录SQLite 在这个量级上绰绰有余。真正要做的就两件事打开 WAL 模式减少读写锁冲突以及把抓取过程和检索写入过程做简单的串行化。HTTP 客户端用了最常用的 requests 这类库解析 HTML 用标准文档树的解析器。前端没有引入任何框架就是服务端渲染的几页模板加一小段搜索交互因为这种工具的界面只有三个功能输入链接、列表展示、搜索框不值得为它背上一个前端工程的复杂度。2.2 数据模型一篇文章在 rea 里怎么存数据表我尽量往简单设计核心表就一张。字段包括 id、url、title、author、site、content_md、excerpt、tags、fetched_at、updated_at、status。url 做了唯一约束这样就天然防止同一条链接被保存两次。content_md 存的是抓取并清洗后的 Markdown 正文这是整个系统里最关键的字段因为全文搜索和离线阅读都靠它。我没有把原始 HTML 一起存进主表主要是省空间、也避免脏数据干扰。但有个例外当正文提取失败时我会把原始 HTML 压缩后存到一个单独的 raw_html 表里作为兜底至少保证内容还在后续想改进提取算法还能重新解析。tags 字段直接用 JSON 数组存因为标签只用于筛选不参与关系查询没必要拆一张多对多表。搜索用专门的倒排索引表字段是 term、article_id、tf、positions。term 是分词后的词条tf 是这个词在文章里出现的次数positions 记录它在正文中的位置列表。这张表会在文章抓取成功、入库之后异步更新。之所以坚持用倒排而不是简单 SQL LIKE是因为 LIKE 在大文本上的效率很差而且无法做词频排序这种稍微有点质量的搜索。2.3 抓取链路从一条 URL 到干净正文rea 的抓取链路是典型的流水线设计。第一步是链接预处理校验 URL 合法性、去掉明显无意义的锚点和 tracking 参数。第二步是下载页面这一步要主动设置 UA 和超时时间很多站点对脚本访问比较敏感。第三步是编码判断后面会专门讲这个坑。第四步是正文提取用 readability 思路的算法在 DOM 树里找一个文本密度最高的区域作为正文节点。第五步是清洗把脚本、样式、广告这类噪音节点删掉再把剩余内容转成 Markdown。第六步是入库加索引。下载和解析我放到一个线程池里跑每篇文章提交为一个独立任务。没有引入消息队列因为单机小项目的任务积压量非常有限用队列会议是在给自己增加运维负担。唯一的保护是限速连续抓同一域名时加一个几百毫秒的间隔同时对并发数做总上限免得某个站点把 rea 当成攻击来源。2.4 中文分词和全文索引的思路英文分词在空格就完了中文不是这么回事。全文搜索听起来简单实际难点全在分词上。我的做法是内置一个小词典词库里放常见的中文词汇、技术术语和一些专有名词分词时优先走词典最长匹配没命中词典的单字再和相邻字做二元组合作为兜底。这样布隆过滤器会切成布隆过滤器两个词搜布隆或过滤器都能命中而一个随意的词如悬浮粒子如果不在词典里就会切成悬浮浮粒粒子这种二元组虽然词边界不完美但搜索时也能起到召回作用。索引更新不是实时的而是等正文入库后抓一个待索引队列。这里有个经验索引过程中遇到脏文本比遇到分词错误更常见比如网页里残留的大段样式文本会被当成正文关键词导致搜索垃圾结果。这种情况我在清洗阶段做了过滤规则把常见无意义段落和 CSS 样式关键字从正文里移除能挡掉七成左右的干扰。3. 正文抓取与全文索引的实现细节3.1 正文提取不能只靠一个正则很多人一想到提取正文第一反应是把 HTML 里 p 标签的内容全拿出来这在小测页面上可能有效放到真实网站上就是灾难。原因是现在的网页通常包含大量非正文内容推荐位、评论、页脚、相关文章、版权声明它们的文本密度在某些页面上甚至比正文还高。rea 沿用了阅读器类软件的那套思路把页面转成 DOM 树之后给每个节点算一个分数打分维度包括文本长度、标点符号数量、链接文本占比、段落数量、以及它和潜在标题层级的位置关系。正文节点通常是文本长度长、链接占比低、且包含多个连续段落的那个容器。找到正文节点之后还要做二次清洗。我维护了一份低价值区块的选择器列表像是评论区、相关阅读、广告位这类结构特征明显的区块直接在提取前移除。提取完成后正文文本会经过一遍标点压缩和空白归一化避免因为缩进和全角半角混乱导致排版难看。这块事情做得多细都不为过因为后面所有搜索质量都建立在正文干净这个前提上正文里混进导航菜单文本搜索时那些高频词就会霸榜。3.2 链接归一化同一个页面不要存两份收藏夹里最常见的情况是同一个链接被保存了多次只是 URL 上带的参数不一样。比如一篇报道一次是从首页点进去的URL 带了个 fromhome 参数另一次是从某篇推荐位点进去的带了个 sourcexxx 参数。单纯用字符串比对 URL 会觉得这是两个页面但实际正文完全一样。rea 在保存前会做一层链接归一化去掉常见的 tracking 参数像 utm_source、utm_medium、from、source、spm 这类关键词开头的参数去掉锚点部分同时统一 http 和 https 的差异。归一化之后如果 URL 已经存在于库里就触发复用逻辑而不是新增一条记录。这时候我会顺手更新一下抓取时间把新旧标题做个校对但不覆盖原有正文除非用户显式要求强制刷新。这样既保证了同一篇文章不会重复占用空间也保留了第一次抓到时的原始快照对付页面后来被改得面目全非的情况非常有用。3.3 搜索排序标题命中比正文命中值钱得多索引建好了搜索界面也能返回结果了但排序一开始做得很粗糙把所有命中的文章按词频排序结果发现一个严重问题正文里高频名词出现的文章永远排最前但用户想找的往往是标题里就带这个词的那篇。后来我调整了打分模型给不同字段分配不同权重。当前 rea 的排序公式大致是score title_hits * 3 content_tfidf * 1 time_decay这里的 content_tfidf 是正文中命中的词的 TF-IDF 加权和time_decay 是一个时间衰减系数公式是0.5 ** (days_old / 90)也就是 90 天前的文章权重减半一年前的文章权重降到大约六分之一。这么做能避免老文章因为内容详尽就一直霸占搜索结果。具体权重我调过好几轮目前的经验是标题权重可以大胆给到正文的三倍因为用户对标题的记忆通常比正文内容可靠得多。搜索展示结果时我还会把命中词在正文里的上下文各取前后几十个字展示出来。这个小功能特别实用有时用户记不清整句原话但看到几个关键词所在的那一段就能立刻确认就是这篇。通过 positions 字段可以做这种上下文切片不用重新去正文里全文扫描。3.4 一个真实的检索验证案例跑通之后我拿真实数据做了个小测试上个月我收藏了一篇讲一致性哈希的技术长文当时的标题其实我没记住只记得里面提到过虚拟节点这个术语。我直接在 rea 的搜索框输入虚拟节点结果第一屏就命中了那篇文章点开正文果然在中间段落里找到了这个词。整个过程不用回忆标题、不用翻历史记录这让我确认这条链路的设计方向是对的。4. 抓取和检索的坑附三条完整排查链路4.1 坑一中文乱码——为什么 requests 默认的解码逻辑救不了你我第一次跑通抓取后存进库里的中文标题是乱码。现象很明确英文页面的标题完全正常凡是没有在响应头里明确声明 charset 的中文页面全乱。第一反应是requests 没猜对编码于是我调试打印了响应头发现很多中文站点响应头里的 Content-Type 只有 text/html没有 charset 字段而真正的编码声明藏在 HTML 文档开头的 meta 标签里。requests 的默认行为是按响应头找编码找不着就按 ISO-8859-1 处理这等于把所有希望寄托在服务器正确配置上。排查链路走完之后解决方式也不复杂先看响应头有没有 charset有就用没有就解析 HTML 前 1024 字节里的 meta 标签提取 charset再不行就用候选编码列表去逐个尝试解码看哪个编码下报错最少。这个候选列表我按常见度排列中文站点基本是 UTF-8、GBK、GB18030 三选一。修好之后我又加了一层检查入库前标题和摘要里如果出现 UFFFD 替换字符就标记为可疑编码重新抓一次。这个坑最坑的地方在于它不是偶发而是稳定发生只要没修每一篇中文文章都在写乱码。4.2 坑二正文为空——有些站点把正文藏在页面里的 JSON 中某天我收藏了一条某个新闻聚合站的文章结果 rea 只存到了标题和发布时间正文是空的。第一反应是选择器写错了排查时我把页面 HTML 打印出来看发现页面里确实有完整的标题层级但正文那一块根本没有静态内容只有一个引导脚本。再往里翻才发现正文文本全部放在一个长字符串里这个字符串是某个状态对象的 JSON 序列化内容前端框架用页面加载后再去解析它渲染 DOM。我想了两种方案一种是给 rea 接一个真正的浏览器内核去执行 JS 再提取说做就做好但代价是要引入一个很重的运行时资源占用和崩溃率都会上来。另一种是给这种站点单独写一个 adapter直接从它页面内嵌的那个 JSON 字段里把正文捞出来。考虑 rea 是单用户自托管长尾站点数量其实不多用 adapter 更划算。最后我在 rea 里留了一个插件点每个 adapter 本质上就是一个给我原始 HTML我告诉你正文在哪的小函数。遇到一个匹配的域名就用对应的 adapter匹配不到就回落到通用提取算法。经验是通用提取算法最多只能覆盖七八成的正常页面剩下两成必须靠站点定制这不是什么羞耻的设计反而是内容抓取类工具非常正常的形态。一个 adapter 通常只要几十行代码但价值非常高。我记得那天的 adapter 大概是这样的逻辑先定位页面里最大的 JSON 块然后解析里面的 content 字段再走一遍清洗管道。4.3 坑三搜不到短视频但能搜到视频——被分词坑了一次存了一篇讲短视频平台运营的文章之后我在搜索框里输短视频结果一条结果都没有但输视频就能搜到。这个现象很有迷惑性第一反应是索引没更新于是我手动去库里查了分词结果发现短视频被词典切成了短和视频两个独立词条而文章正文里的短视频是一个连续字符串索引里根本没有一个完整的短视频词条。也就是说搜索时我把 query 也走了一遍同样的分词它同样被切成了短视频理论上应该能通过视频这个词召回这篇文章为什么没召回呢因为我在搜索时做了 AND 逻辑必须所有词都命中才返回而正文里其他地方的短字未必出现足够高的词频去满足条件。这个坑反映出两个设计问题一是词典覆盖不足短视频这种常见词竟然没有二是 AND 逻辑太严格单词碎片没同时出现就漏召回。修复分两步先在词典里补充一批常见复合词再把搜索逻辑从必须全部命中改成至少命中一个词按命中个数加权排序。后者牺牲了一点精准率但召回率立刻上来了对于这种个人知识库场景召回比精准重要得多。另外我在索引管理界面加了一个调试入口可以看某条文章的分词结果这样以后排查分词问题就不用靠猜了。4.4 坑四连续保存文章时偶发database is locked刚开始用的时候我一边抓取新文章一边搜索旧文档偶尔会碰到 SQLite 报 database is locked 的错误。这个错误在并发访问数据库时很典型但 rea 在单机上只跑了一个进程怎么也会锁排查后发现原因在写事务和读事务的互相阻塞抓取线程在插入文章的同时另一个线程可能正在做一轮全文检索SQLite 的默认模式里写操作会挡住读操作时间段拉长之后就会出现锁超时。修复方案是三步组合拳把数据库引擎调到 WAL 模式让读和写并发执行而不是互相排队设置 busy_timeout让写操作遇到锁时等待而不是立即报错在应用层把写数据库的所有入口集中到一个串行化调度器里同一时刻只允许一个写任务在跑。这三步加完locks 就再没出现过。这里我学到的教训是SQLite 在极低并发下几乎不用调优但一旦你引进了线程池就必须把读写模式理解清楚否则会在意想不到的时候给你上课。5. 部署到一台旧服务器之后的运维日常5.1 容器化部署一份配置走天下rea 的部署方式我选了容器化。这不是因为它功能多恰恰是因为它足够简单一个进程加一个数据文件容器化之后部署和升级都变成固定动作。我的容器编排文件大意是一个 app 服务暴露 8080 端口挂载一个数据目录到容器里的 /data环境变量只有端口、数据库路径和访问令牌。整个服务从启动到可用不到十秒。数据目录里就是 SQLite 文件和一对后台任务的工作目录没有其他有状态的东西。这意味着升级逻辑非常纯粹把新版本镜像拉下来替换旧容器数据目录不动一切照旧。这种无状态应用 有状态数据卷的模型是我最喜欢的小项目运维方式出问题时的排查面非常窄。5.2 备份恢复复制文件就够了因为用了 SQLite备份简单得令人感动。执行一次 checkpoint 让 WAL 文件合并进主库然后直接复制那个数据文件就完成了完整备份。我写了一个每天凌晨运行的备份脚本保留最近 30 天的备份文件。恢复时更直接停掉服务、把备份文件放回数据目录、启动服务就完了。我没做更复杂的增量备份因为数据量实在不算大。一篇带图片引用的文章正文压缩后通常几十 KB 到几百 KB一天存十几篇也就是几 MB 级别全量备份完全承担得起。数据都要在本地放着这一点我最看重rea 里存的所有东西包括全文、标签、索引没有一个字节放在我控制不了的服务器上。5.3 日常使用姿势和抓取体验实际用起来之后rea 的入口非常朴素。电脑端我在浏览器里装了一个非常轻的收藏扩展快捷键一按就把当前标签页的链接发送给 rea手机端则在浏览器分享菜单里把链接转到 rea 的接收地址。没有做成原生的移动 App因为我不需要随时浏览抓取和搜索的入口能用就行。每周我会打开一次 rea 的未读列表把过去七天存下来的东西快速扫一遍。这个动作有个意外收获它会逼我面对自己的囤积习惯。以前收藏夹里的东西眼不见心不烦现在变成每周都要翻一遍清单我反而会认真判断这个链接是不是真的值得收存进去的量质量都高了不少。偶尔想回忆某个专题时在搜索框里敲两个词所有相关文章按时间排开那种掌控感是用别人家的书签服务体会不到的。5.4 增量更新的小优化有些文章是长期更新的比如某框架的发布说明页或者某个项目的文档首页我第一次抓取时内容还不完整过几个月再访问会发现多了不少章节。我给这类页面加了一个刷新正文按钮触发后 rea 会用原链接重新抓取一遍再在后台对比新旧正文的相似度。如果相似度超过九成就认为是微调直接覆盖否则就视为大改保留旧版本并新建一条记录避免丢失彼时的快照。6. 跑了一个月我对 rea 的真实感受6.1 它确实改掉了一个坏习惯用 rea 之前我的收藏行为基本是无脑收藏反正存哪都能找到。用过之后由于每周都要面对存了但没读的清单我开始在收藏前多问自己一句这篇文章未来一个月会不会真的打开大多数情况下答案是不会于是就不存了。这个变化比 rea 本身还值钱因为它从源头减少了信息垃圾。从技术角度看rea 也证明了一个观点个人级的知识管理工具不需要很大的系统复杂度。一条抓取管道、一张关系表、一个倒排索引把这三样做扎实了就已经能覆盖大部分人想保存、想搜索、不想丢失的核心需求。那个我曾经觉得很难处理的全文搜索自己的收藏问题真正落地后技术含量并没有想象中高难的反而是清洗数据、分词质量这些琐碎细节。6.2 后续我打算补上两块拼图现在的 rea 还只是一个能存能搜的工具离我理想中的个人阅读工作台还有一些距离。下一步我最想加的是边读边批注在正文阅读界面里选中一段文字然后写一句笔记这会直接变成这篇文章的一条标注记录。另一个想尝试的方向是把文章之间的关系网络画出来比如两篇文章共同提到多个相同的关键词就在它们之间建立一条关联往后想找和这篇观点相近的文章时就不一定非要通过搜索词了。你可能注意到了我一直没给 rea 制定什么宏大的路线图。这是故意为之。个人工具没有商业产品那种增长压力功能可以按真实需求慢慢长。我的原则是它首先要顺手其次要可控最后才谈得上漂亮。如果你也在被书签和稍后读折磨我建议不妨动手做一个属于自己的最小版本规模不用大能跑通保存、提取、搜索这一条链路就已经赢了八成。