先说一个数据:我的浏览器收藏夹里攒了 500 多条链接,真正点开看过的不到 30 条,完整读完的更是屈指可数。收藏时总觉得“以后会有时间”,结果这个“以后”永远不会主动到来。于是我用 Node.js 做了一个本地优先的稍后阅读小工具,代号就叫rea,全称开玩笑写成 Read Early Again,意思是“不仅要存下来,还要尽早回头读完”。这篇文章就是 rea 从设计到落地、再到跑了一个月之后的全过程复盘,重点讲清楚两个问题:为什么普通的收藏夹和书签解决不了“吃灰”,以及一个只有几千行的命令行工具,是怎么把回读率从 5% 拉高到 38% 的。如果你也被“收藏了等于读了”的错觉困扰,又想自己动手做一个不依赖云服务的阅读管理工具,这篇文章应该能给你一份可以直接照着做的实现思路。1. 为什么我的收藏夹吃灰率那么高:rea 的起点先交代背景。我之前用的方案很普通:浏览器书签、第三方收藏夹、偶尔用笔记软件里的“剪藏”功能。东西确实存下来了,但存在一个根本断层——收藏动作只完成了信息的保存,没有完成信息的排队和触发。链接进了收藏夹之后,没有优先级,没有时间预期,也没有人提醒你“该回来读了”。结果就是收藏夹变成一个数字垃圾场。1.1 收藏不等于已读的断层我发现收藏吃灰不是因为懒,而是缺少一个“二次触发机制”。第一次看到文章时,你可能正在通勤、开会、或者只是随手一刷,环境不允许深入阅读;第二次真正想读时,又不知道从哪一条开始。几千条链接平铺在那里,标签体系形同虚设。标签只能回答“这个话题是什么”,回答不了“我现在该读哪一篇”。真正缺的是三个东西:队列:所有待读内容按顺序排好,而不是平铺成一堆。优先级:哪些值得先读,哪些可以永远不读,要有一个可计算、可调整的排序。提醒:过一段时间自动把旧链接翻出来,别让它们沉底。我先试了几个现成的稍后读服务。有的功能确实全,但必须注册账号,数据在云端,离线的时候一篇文章都打不开;有的从手机端阅读体验出发,在电脑终端里使用反而不顺手;还有的标签体系和文件夹互相嵌套,光是整理分类就消耗了大量精力。折腾一圈下来,我的诉求越来越清晰。1.2 我对一款“稍后读”工具的真实需求清单我真正需要的不是又一个内容管理软件,而是一个“命令行收件箱”。它应该满足以下条件:本地优先:数据存成单个文件,不依赖云账号,随时可以用sqlite3命令行直接查。极低添加成本:在终端里敲rea add <url>就能入队,最好还能直接从剪贴板读取链接。自动决定阅读顺序:不需要我每次手动选择,工具根据阅读时长、来源权重、添加时间自动打分排序。有状态流转:待读、在读、已读、归档,四条状态清清楚楚。能被脚本驱动:我可以把它嵌进定时任务,每天早上自动列出一条“今天最值得读的 3 篇文章”。这就有了 rea 的样子:一个运行在终端里的本地稍后读队列。它不做华丽的阅读界面,只负责一件事——把链接变成有优先级的任务,然后按时逼你回去读它们。2. 先搭骨架再填肉:rea 的数据模型、存储与命令矩阵动手写代码之前,我先把数据模型定下来。这个阶段最忌讳直接开写功能,因为后面很容易陷入“每个命令都只处理当下需求”的补丁式开发。2.1 为什么存储选了 SQLite 而不是 JSON 文件最初的版本我偷懒用了 JSON 文件,一个数组存所有任务。量小的时候没问题,但到 500 条之后,每次读取都要JSON.parse整个文件,写入还要处理并发冲突。后来换成了 SQLite,理由很直接:对比项JSON 文件SQLite并发写容易丢数据,需要自己加锁原生事务支持按状态查询只能全量遍历后 filter索引直接命中数据一致性没有约束,容易写坏唯一索引、外键、类型约束备份复制文件即可复制单个.db文件即可依赖复杂度无一个原生模块,可接受SQLite 另一个好处是它本身就是一个完整的数据库,我可以用sqlite3 client.db "select ..."直接调试,不必专门写一堆维护脚本。上游数据永远只有一个.db文件,备份、迁移都很轻松。2.2 任务表设计成什么样任务表我设计得比较精简,字段如下:CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT, excerpt TEXT, source TEXT, reading_minutes INTEGER DEFAULT 5, priority REAL DEFAULT 0, status TEXT DEFAULT 'inbox', created_at TEXT DEFAULT (datetime('now', 'localtime')), planned_at TEXT, read_at TEXT, archived_at TEXT, done_reminded INTEGER DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_url ON tasks(url); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority DESC);几个字段的作用需要说明一下。url加唯一索引:同一篇文章只允许进队一次,重复添加时直接转成“提升优先级”操作,而不是再插一条。status:默认inbox,流转路径是inbox -> reading -> archived,另有trash作为软删除。priority:一个浮点数,由阅读时长、来源权重、添加时间衰减共同计算,列表默认按这个字段倒序排。planned_at:预定的提醒时间,提醒逻辑只认这个字段,不依赖系统计时器。reading_minutes:正文抽取后估算的阅读分钟数,方便用户权衡“现在读还是晚点读”。我没有单独建tags表。一是因为早期版本不需要多对多关系,二是因为标签可以在title和source上做全文检索,犯不上为一个轻量工具引入额外复杂度。真到需要标签的那天,再加表也不迟。2.3 命令矩阵rea 的交互全部走子命令,命令设计刻意保持了“够用就好”的克制:命令作用备注rea add <url>抓取链接并入库不带 URL 时读取剪贴板rea list列出待读任务默认取优先级最高的 20 条rea open <id>在浏览器打开链接顺便把状态改为 readingrea done <id>标记为已读记录read_atrea archive归档已读或过期任务默认只归档 status=readingrea remind扫描到期待读任务输出提醒信息rea stats统计回读率与队列分布用来观察使用效果每个命令都保持单职责。比如open只负责调用系统的默认浏览器打开 URL,并把状态从inbox改成reading,它不负责打分,也不负责提醒。职责分清楚之后,测试和维护都轻松很多。2.4 一条任务的生命周期任务从入队到归档的完整路径是这样的:用户执行rea add <url>,工具抓取标题、正文摘要和阅读时长,写入inbox。rea list按照优先级展示,用户挑选一条执行rea open <id>。用户读完文章后执行rea done <id>,任务进入reading态同时补上read_at。每周跑一次rea archive,把已读超过 7 天且不再需要的任务归档。归档后的任务仍然保留在数据库里,只是从默认列表消失;需要时可以手动查询。这个状态机没有采用“已读之后立即消失”的设计。因为稍后读的意义在于“读完之后还能回顾”,直接删掉太浪费,归档后才方便做周报和索引。3. 重头戏:正文抽取和优先级打分,如何决定哪篇文章先被读我心里清楚一点:如果 rea 只是把链接存进 SQLite,它跟普通书签没有任何区别。真正让它产生价值的,是“自动判断哪篇先读”。3.1 网页正文抽取的清洗流程为了拿到标题、摘要和阅读时长,我需要从 URL 对应的 HTML 里抽取正文。这里没有用过于重的无头浏览器,而是用的fetch抓 HTML + cheerio 解析 DOM。完整的fetchAndExtract流程如下:const cheerio = require('cheerio'); async function fetchAndExtract(url) { const res = await fetch(url, { headers: { 'User-Agent': 'rea-bot/0.1 (local reading agent)', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }, redirect: 'follow' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const $ = cheerio.load(html); // 清除明显非正文区域 $('script, style, nav, footer, aside, form, iframe, .ad, .comment').remove(); const title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || $('h1').first().text().trim() || '未命名'; // 语义标签优先,兜底取段落最多的容器 const candidates = [ $('article').first(), $('main').first(), $('[itemprop="articleBody"]').first(), $('.post-content').first(), $('.article-content').first() ].filter(el => el.length > 0); let content = ''; if (candidates.length > 0) { content = normalizeWhitespace(candidates[0].text()); } else { // 兜底策略:取正文中 p 标签文字总长度最大的父容器 const pMap = new Map(); $('p').each((_, el) => { const text = $(el).text().trim(); if (text.length < 30) return; const parent = $(el).parent(); const key = parent.length ? parent.prop('tagName') + '-' + parent.index() : 'root'; pMap.set(key, (pMap.get(key) || 0) + text.length); }); const bestKey = [...pMap.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; if (bestKey) { const parentNodes = $('p').parent(); parentNodes.each((_, el) => { if ($(el).prop('tagName') + '-' + el.index() === bestKey) { content = normalizeWhitespace($(el).text()); } }); } } const excerpt = content.slice(0, 300); const words = content.split(/\s+/).filter(Boolean).length; const readingMinutes = Math.max(1, Math.round(words / 220)); return { title, excerpt, readingMinutes, contentLength: content.length }; } function normalizeWhitespace(text) { return text.replace(/\s+/g, ' ').trim(); }抽取逻辑里最容易踩的坑是“选择器优先级”。我一开始先找.article,结果很多网站的.article其实是广告容器。后来经验是:<article>标签的权重最高,其次是main,再其次才是各类 class。至于兜底策略里的“找 p 标签字数最多的父容器”,实际上已经能覆盖八成没有语义标签的老站点。readingMinutes = words / 220这个数字来自中文阅读速度的平均估算。220 字/分钟是一个比较保守的读速,宁可估高一点,让用户觉得“这篇只要 3 分钟,可以现在读”,也不要估太低造成压力。3.2 优先级评分公式的拆解评分公式是 rea 的核心策略。我一开始只用“添加时间倒序”,效果很一般——三天前收藏的一篇长文总是压过今天收藏的短文,用户根本没有读它的欲望。后来改成了四个信号加权:function computePriority({ readingMinutes, createdAt, manualBoost = 0, sourceWeight = 1 }) { const now = Date.now(); const created = new Date(createdAt).getTime(); const ageDays = (now - created) / (1000 * 60 * 60 * 24); // 时间衰减:7 天衰减到约 37% const timeFactor = Math.exp(-ageDays / 7); // 阅读时长偏好:3~8 分钟的内容优先 let lengthFactor = 0.5; if (readingMinutes <= 3) lengthFactor = 0.8; else if (readingMinutes <= 8) lengthFactor = 1.0; else if (readingMinutes <= 15) lengthFactor = 0.7; else lengthFactor = 0.4; const score = 0.4 * timeFactor + 0.4 * lengthFactor + 0.15 * sourceWeight + 0.05 * manualBoost; return Number(score.toFixed(3)); }分数区间控制在 0 到 1 之间,方便比较。四个信号分别是:时间衰减:越早添加的权重越低,但不会直接归零,7 天滚动衰减。阅读时长:3~8 分钟的内容最适合“碎片时间补读”,所以分数最高;15 分钟以上的长文容易被刻意回避。来源权重:每个域名可以单独配置权重,我把自己常看的几个技术社区权重调到了 1.2,娱乐站点调到 0.6。手动提升:rea add -b <url>可以额外加分,适合用户明确知道“这篇很重要必须读”的场景。这个公式不是一步到位的。最初版本里sourceWeight占 30%,结果某个权重高的站点每天产出一堆水文,全部排到前面,严重干扰队列。后来把来源权重降到 15%,并增加了一个“同一域名每日最多入队 5 篇”的软限制,问题才缓解。3.3 为什么要把评分结果直接暴露出来rea list默认输出优先级最高的 20 条,但我特意把priority和estimated_time两列打在结果里:$ rea list ID STATE TIME MIN PRIORITY TITLE 12 inbox 82% 6 0.812 理解事件循环:从 setTimeout 到微任务 7 inbox 30% 4 0.765 终端复用器的三条配置技巧 33 inbox 96% 2 0.700 用 SQLite 存时间序列数据把分数直接暴露出来,看起来有点“极客”,但实际效果很好。用户会下意识地和分数做心理博弈:“这篇 0.812,确实值得马上读”“这篇只有 0.5,先放着吧”。分数成为决策的锚点,而不是让用户面对一堆平等的标题做选择。4. 从零搭建 rea:核心模块的代码级拆解确立数据模型和算法之后,我把核心模块按“命令-服务-存储”三层拆开。命令层负责解析参数,服务层负责业务逻辑,存储层封装 SQLite 操作。这里重点讲 add、list、状态流转和提醒四个模块。4.1 项目结构与依赖rea 的项目结构非常简单:rea/ bin/rea.js # CLI 入口 lib/store.js # SQLite 封装 lib/extract.js # 抓取与正文抽取 lib/priority.js # 优先级计算 lib/remind.js # 提醒逻辑 package.json初始化只需要两个依赖:cheerio负责解析 HTML,better-sqlite3负责同步访问数据库。命令行本身我用的 Node.js 内置process.argv解析,没有引 commander,因为命令数量少,引外部依赖反而增加学习成本。npm init -y npm install cheerio better-sqlite34.2 add 子命令:抓取、去重、入库add命令的完整逻辑是这样的:// bin/rea.js 相关片段 const { addTask } = require('../lib/store'); const { fetchAndExtract } = require('../lib/extract'); const { computePriority } = require('../lib/priority'); async function add(url, boost = false) { if (!/^https?:\/\//i.test(url)) { url = 'https://' + url; } const existing = findTaskByUrl(url); if (existing) { boostPriority(existing.id, 0.1); console.log(`文章已存在,优先级 +0.1,当前分数 ${existing.priority.toFixed(3)}`); return; } const { title, excerpt, readingMinutes } = await fetchAndExtract(url); const sourceWeight = getSourceWeight(new URL(url).hostname); const priority = computePriority({ readingMinutes, createdAt: new Date().toISOString(), manualBoost: boost ? 1 : 0, sourceWeight }); const id = insertTask({ url, title, excerpt, source: new URL(url).hostname, readingMinutes, priority }); console.log(`入库成功 ID=${id} 预计阅读 ${readingMinutes} 分钟,优先级 ${priority.toFixed(3)}`); }去重逻辑用了两层:先查tasks表里的唯一索引,如果 URL 已经存在,不新增记录,只给旧任务加 0.1 的优先级。这个设计很关键,否则用户同一篇文章在不同设备上看到三次就会加入三次,队列迅速膨胀。getSourceWeight从配置文件读取,默认所有域名权重是 1.0。配置文件就是一个简单的 JSON 文件,用户自己维护。{ "sourceWeights": { "news.ycombinator.com": 1.2, "example.com": 0.8 } }4.3 list 与状态流转:open / done / archivelist的实现很简单,核心是 SQL 分页和状态过滤:function listTasks(limit = 20, status = 'inbox') { const rows = db .prepare( `SELECT id, url, title, reading_minutes, priority, datetime(created_at) AS created_at FROM tasks WHERE status = ? ORDER BY priority DESC, created_at DESC LIMIT ?` ) .all(status, limit); return rows; }状态流转的代码也不需要魔法。open命令把status改成reading,done命令补全read_at,archive命令统一归档:function changeStatus(id, newStatus) { const info = db .prepare('UPDATE tasks SET status = ? WHERE id = ?') .run(newStatus, id); if (info.changes === 0) throw new Error(`任务 ${id} 不存在`); } function markDone(id) { const info = db .prepare( `UPDATE tasks SET status = 'reading', read_at = datetime('now', 'localtime') WHERE id = ? AND status != 'trash'` ) .run(id); if (info.changes === 0) throw new Error('任务不存在或已在回收站'); }我这里把done之后的状态设计成reading,而不是read,是有意为之:读完不等于归档,它仍在“最近读过”的范围里,方便一周后回顾。真正的归档由archive命令触发。4.4 提醒模块:不是定时器,是状态检查写提醒模块时我踩过一个大坑:最开始模仿 cron 做一个常驻进程 setTimeout 循环,但笔记本一合盖,系统睡眠,定时器全部失效。后来我把提醒从“定时触发”改成了“差值扫描”。function scanReminders() { const now = new Date().toISOString(); const dueTasks = db .prepare( `SELECT id, url, title, planned_at FROM tasks WHERE planned_at <= ? AND status = 'inbox' AND done_reminded = 0 ORDER BY planned_at ASC LIMIT 5` ) .all(now); for (const task of dueTasks) { console.log(`提醒:${task.title}\n${task.url}\n计划阅读时间:${task.planned_at}`); db.prepare('UPDATE tasks SET done_reminded = 1 WHERE id = ?').run(task.id); } }每次运行任意 rea 命令时,都先执行一次scanReminders()。这样即使用户三天没打开终端,只要哪天运行了rea list,系统就会把过去欠下的提醒一次性补齐。它不追求“准时”,追求的是“不错过”。5. 实测中踩过的三个坑和对应的修复方案代码写完不等于能稳定用。连续三周的真实使用里,我遇到了三个影响体验的问题,每次都是从“怎么不对?”到定位根因、修复、验证的完整链路。5.1 页面编码不同,正文变成乱码第一个问题是老站点的编码。Node.js 的fetch().text()默认按 UTF-8 解码,可某些老旧页面用的是 GB2312 或 GB18030,抓回来全是“锟斤拷”一类的乱码。排查过程是这样的:我用fetchAndExtract('http://example.com/old-post.php'),返回的title完全不可读,但是用浏览器打开却正常。浏览器能正常解析是因为 HTML 的<meta charset>里声明了编码。rea 需要自己处理这个信号。修复方案是在拿到 HTML 原始 Buffer 之后,先看前 1024 字节里的 charset 声明,再决定用哪个编码解码:const { TextDecoder } = require('util'); async function getDecodedText(res) { const buf = Buffer.from(await res.arrayBuffer()); const meta = buf.subarray(0, 2048).toString('ascii').match(/charset=["']?([\w-]+)/i); const charset = meta ? meta[1].toLowerCase() : 'utf-8'; if (charset.includes('gb')) { return new TextDecoder('gb18030').decode(buf); } if (charset.includes('big5')) { return new TextDecoder('big5').decode(buf); } return new TextDecoder('utf-8').decode(buf); }Node 的TextDecoder原生支持gb18030、big5等编码,并不需要额外安装字符集库。这个修复之后,老文章的抓取成功率提高了很多。5.2 同一链接被反复加入,任务表长出很多重复行第二次问题是重复数据。一开始去重逻辑放在 JavaScript 层:findTaskByUrl先查一遍再插入。但终端用户经常使用rea add时忘记自己已经收藏过,或者同一个 URL 有utm_source追踪参数,导致数据库里出现同一个页面 5 条记录。我做了两个修复:在表上建url唯一索引,先解决完全重复。在add逻辑里做 URL 规范化,去掉常见的utm_*、fbclid、gclid追踪参数,再判断是否重复。function normalizeUrl(rawUrl) { const u = new URL(rawUrl); [...u.searchParams.keys()].forEach((key) => { if (key.startsWith('utm_') || key === 'fbclid' || key === 'gclid') { u.searchParams.delete(key); } }); return u.toString(); }这个修复之后,重复录入量下降了很多,队列也不会被同一个站点的多篇相似文章塞满了。5.3 后台提醒在笔记本合盖后全部失效第三个问题前面提过一嘴:提醒模块一开始依赖 setTimeout,看起来能工作,但笔记本合盖进入睡眠后,JavaScript 计时器被系统挂起,睡醒之后根本不会补执行。更隐蔽的是,这种失效毫无报错,整个过程像“提醒功能自己消失了”。后来我彻底去掉了常驻进程,改成“命令触发时扫描补提醒”。具体的做法是:每次 CLI 入口执行scanReminders()。用户主动运行rea remind也可以触发。在系统 crontab 里加一行:每天早上 9 点执行rea list,变相触发补提醒。实践下来,这个方案最稳。它不依赖后台进程是否存活,只依赖用户是否执行过命令。对于命令行工具来说,用户每次敲命令都是一次天然的检查点。6. 跑了一个月后的真实收益和下一步准备做的事rea 从设计到写成、再到稳定使用,差不多三周时间。真正开始改变我的,是第四周统计数据的时候。rea stats输出的数字让我有点意外。6.1 数据说话:回读率从 5% 到 38%对比的时间窗口是同样的一个月:指标之前(书签收藏)现在(rea 队列)累计新增链接12086完整读过的链接633回读率5%38%平均每周打清理0 次2 次清理时感到犹豫总是很少回读率提升不是算法多聪明,而是“收藏链路”变了。过去收藏完就结束,现在收藏完还要面对一个排序列表,列表里明确告诉你“这篇预计 6 分钟,优先级 0.812”。优先级数字本身就在传达一个信号:你现在就可以读。用户默认接受了这个信号,回读率自然就上去了。另一个变化是我敢定期清理了。以前清理书签像是要扔掉一段记忆,担心以后会用到。现在 rea 有完整的历史记录和摘要,归档只是状态变化,不是删除,心理负担小很多。6.2 几个值得继续做的方向rea 目前还谈不上完善,我列了几个下一步优先级比较高的方向:浏览器扩展:右键点击链接直接发送到本地 rea,省掉rea add手打 URL 的步骤。本地命令工具加一个 HTTP 服务即可对接。按主题自动聚类:在标题、摘要上做简单的关键词分组,把“事件循环”“HTTP 协议”这类主题聚成一类,避免某个主题长期霸占队列。轻量可选的同步:默认继续本地优先,但可以把 SQLite 文件放到自建的 WebDAV 或网盘目录,实现多设备共用队列。引入简单的全文检索:给excerpt和title建 FTS5 虚拟表,以后搜索历史阅读记录会比LIKE快得多。还有一个小方向:给reading_minutes加上统计反馈。实际阅读速度不是恒定 220 字/分钟,可以根据用户完成done的时间反推真实速度,再调整估算值。这一步不急,但对长期体验有意义。最后分享一个我在实际使用中最受用的技巧:不要追求“把队列清空”。稍后读工具的价值不是所有链接都被读完,而是让重要内容有机会浮到表面。每周固定抽出十分钟,把队列里优先级低于 0.3 的任务直接归档,你会发现可维护性比“全部读完”高得多。rea 不是又一个收藏夹,它是一个帮我把“收藏”和“已读”之间的距离缩短的工具,而这段距离,恰好是阅读真正的价值所在。
先说一个数据:我的浏览器收藏夹里攒了 500 多条链接,真正点开看过的不到 30 条,完整读完的更是屈指可数。收藏时总觉得“以后会有时间”,结果这个“以后”永远不会主动到来。于是我用 Node.js 做了一个本地优先的稍后阅读小工具,代号就叫rea,全称开玩笑写成 Read Early Again,意思是“不仅要存下来,还要尽早回头读完”。这篇文章就是 rea 从设计到落地、再到跑了一个月之后的全过程复盘,重点讲清楚两个问题:为什么普通的收藏夹和书签解决不了“吃灰”,以及一个只有几千行的命令行工具,是怎么把回读率从 5% 拉高到 38% 的。如果你也被“收藏了等于读了”的错觉困扰,又想自己动手做一个不依赖云服务的阅读管理工具,这篇文章应该能给你一份可以直接照着做的实现思路。1. 为什么我的收藏夹吃灰率那么高:rea 的起点先交代背景。我之前用的方案很普通:浏览器书签、第三方收藏夹、偶尔用笔记软件里的“剪藏”功能。东西确实存下来了,但存在一个根本断层——收藏动作只完成了信息的保存,没有完成信息的排队和触发。链接进了收藏夹之后,没有优先级,没有时间预期,也没有人提醒你“该回来读了”。结果就是收藏夹变成一个数字垃圾场。1.1 收藏不等于已读的断层我发现收藏吃灰不是因为懒,而是缺少一个“二次触发机制”。第一次看到文章时,你可能正在通勤、开会、或者只是随手一刷,环境不允许深入阅读;第二次真正想读时,又不知道从哪一条开始。几千条链接平铺在那里,标签体系形同虚设。标签只能回答“这个话题是什么”,回答不了“我现在该读哪一篇”。真正缺的是三个东西:队列:所有待读内容按顺序排好,而不是平铺成一堆。优先级:哪些值得先读,哪些可以永远不读,要有一个可计算、可调整的排序。提醒:过一段时间自动把旧链接翻出来,别让它们沉底。我先试了几个现成的稍后读服务。有的功能确实全,但必须注册账号,数据在云端,离线的时候一篇文章都打不开;有的从手机端阅读体验出发,在电脑终端里使用反而不顺手;还有的标签体系和文件夹互相嵌套,光是整理分类就消耗了大量精力。折腾一圈下来,我的诉求越来越清晰。1.2 我对一款“稍后读”工具的真实需求清单我真正需要的不是又一个内容管理软件,而是一个“命令行收件箱”。它应该满足以下条件:本地优先:数据存成单个文件,不依赖云账号,随时可以用sqlite3命令行直接查。极低添加成本:在终端里敲rea add <url>就能入队,最好还能直接从剪贴板读取链接。自动决定阅读顺序:不需要我每次手动选择,工具根据阅读时长、来源权重、添加时间自动打分排序。有状态流转:待读、在读、已读、归档,四条状态清清楚楚。能被脚本驱动:我可以把它嵌进定时任务,每天早上自动列出一条“今天最值得读的 3 篇文章”。这就有了 rea 的样子:一个运行在终端里的本地稍后读队列。它不做华丽的阅读界面,只负责一件事——把链接变成有优先级的任务,然后按时逼你回去读它们。2. 先搭骨架再填肉:rea 的数据模型、存储与命令矩阵动手写代码之前,我先把数据模型定下来。这个阶段最忌讳直接开写功能,因为后面很容易陷入“每个命令都只处理当下需求”的补丁式开发。2.1 为什么存储选了 SQLite 而不是 JSON 文件最初的版本我偷懒用了 JSON 文件,一个数组存所有任务。量小的时候没问题,但到 500 条之后,每次读取都要JSON.parse整个文件,写入还要处理并发冲突。后来换成了 SQLite,理由很直接:对比项JSON 文件SQLite并发写容易丢数据,需要自己加锁原生事务支持按状态查询只能全量遍历后 filter索引直接命中数据一致性没有约束,容易写坏唯一索引、外键、类型约束备份复制文件即可复制单个.db文件即可依赖复杂度无一个原生模块,可接受SQLite 另一个好处是它本身就是一个完整的数据库,我可以用sqlite3 client.db "select ..."直接调试,不必专门写一堆维护脚本。上游数据永远只有一个.db文件,备份、迁移都很轻松。2.2 任务表设计成什么样任务表我设计得比较精简,字段如下:CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT, excerpt TEXT, source TEXT, reading_minutes INTEGER DEFAULT 5, priority REAL DEFAULT 0, status TEXT DEFAULT 'inbox', created_at TEXT DEFAULT (datetime('now', 'localtime')), planned_at TEXT, read_at TEXT, archived_at TEXT, done_reminded INTEGER DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_url ON tasks(url); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority DESC);几个字段的作用需要说明一下。url加唯一索引:同一篇文章只允许进队一次,重复添加时直接转成“提升优先级”操作,而不是再插一条。status:默认inbox,流转路径是inbox -> reading -> archived,另有trash作为软删除。priority:一个浮点数,由阅读时长、来源权重、添加时间衰减共同计算,列表默认按这个字段倒序排。planned_at:预定的提醒时间,提醒逻辑只认这个字段,不依赖系统计时器。reading_minutes:正文抽取后估算的阅读分钟数,方便用户权衡“现在读还是晚点读”。我没有单独建tags表。一是因为早期版本不需要多对多关系,二是因为标签可以在title和source上做全文检索,犯不上为一个轻量工具引入额外复杂度。真到需要标签的那天,再加表也不迟。2.3 命令矩阵rea 的交互全部走子命令,命令设计刻意保持了“够用就好”的克制:命令作用备注rea add <url>抓取链接并入库不带 URL 时读取剪贴板rea list列出待读任务默认取优先级最高的 20 条rea open <id>在浏览器打开链接顺便把状态改为 readingrea done <id>标记为已读记录read_atrea archive归档已读或过期任务默认只归档 status=readingrea remind扫描到期待读任务输出提醒信息rea stats统计回读率与队列分布用来观察使用效果每个命令都保持单职责。比如open只负责调用系统的默认浏览器打开 URL,并把状态从inbox改成reading,它不负责打分,也不负责提醒。职责分清楚之后,测试和维护都轻松很多。2.4 一条任务的生命周期任务从入队到归档的完整路径是这样的:用户执行rea add <url>,工具抓取标题、正文摘要和阅读时长,写入inbox。rea list按照优先级展示,用户挑选一条执行rea open <id>。用户读完文章后执行rea done <id>,任务进入reading态同时补上read_at。每周跑一次rea archive,把已读超过 7 天且不再需要的任务归档。归档后的任务仍然保留在数据库里,只是从默认列表消失;需要时可以手动查询。这个状态机没有采用“已读之后立即消失”的设计。因为稍后读的意义在于“读完之后还能回顾”,直接删掉太浪费,归档后才方便做周报和索引。3. 重头戏:正文抽取和优先级打分,如何决定哪篇文章先被读我心里清楚一点:如果 rea 只是把链接存进 SQLite,它跟普通书签没有任何区别。真正让它产生价值的,是“自动判断哪篇先读”。3.1 网页正文抽取的清洗流程为了拿到标题、摘要和阅读时长,我需要从 URL 对应的 HTML 里抽取正文。这里没有用过于重的无头浏览器,而是用的fetch抓 HTML + cheerio 解析 DOM。完整的fetchAndExtract流程如下:const cheerio = require('cheerio'); async function fetchAndExtract(url) { const res = await fetch(url, { headers: { 'User-Agent': 'rea-bot/0.1 (local reading agent)', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }, redirect: 'follow' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const $ = cheerio.load(html); // 清除明显非正文区域 $('script, style, nav, footer, aside, form, iframe, .ad, .comment').remove(); const title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || $('h1').first().text().trim() || '未命名'; // 语义标签优先,兜底取段落最多的容器 const candidates = [ $('article').first(), $('main').first(), $('[itemprop="articleBody"]').first(), $('.post-content').first(), $('.article-content').first() ].filter(el => el.length > 0); let content = ''; if (candidates.length > 0) { content = normalizeWhitespace(candidates[0].text()); } else { // 兜底策略:取正文中 p 标签文字总长度最大的父容器 const pMap = new Map(); $('p').each((_, el) => { const text = $(el).text().trim(); if (text.length < 30) return; const parent = $(el).parent(); const key = parent.length ? parent.prop('tagName') + '-' + parent.index() : 'root'; pMap.set(key, (pMap.get(key) || 0) + text.length); }); const bestKey = [...pMap.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; if (bestKey) { const parentNodes = $('p').parent(); parentNodes.each((_, el) => { if ($(el).prop('tagName') + '-' + el.index() === bestKey) { content = normalizeWhitespace($(el).text()); } }); } } const excerpt = content.slice(0, 300); const words = content.split(/\s+/).filter(Boolean).length; const readingMinutes = Math.max(1, Math.round(words / 220)); return { title, excerpt, readingMinutes, contentLength: content.length }; } function normalizeWhitespace(text) { return text.replace(/\s+/g, ' ').trim(); }抽取逻辑里最容易踩的坑是“选择器优先级”。我一开始先找.article,结果很多网站的.article其实是广告容器。后来经验是:<article>标签的权重最高,其次是main,再其次才是各类 class。至于兜底策略里的“找 p 标签字数最多的父容器”,实际上已经能覆盖八成没有语义标签的老站点。readingMinutes = words / 220这个数字来自中文阅读速度的平均估算。220 字/分钟是一个比较保守的读速,宁可估高一点,让用户觉得“这篇只要 3 分钟,可以现在读”,也不要估太低造成压力。3.2 优先级评分公式的拆解评分公式是 rea 的核心策略。我一开始只用“添加时间倒序”,效果很一般——三天前收藏的一篇长文总是压过今天收藏的短文,用户根本没有读它的欲望。后来改成了四个信号加权:function computePriority({ readingMinutes, createdAt, manualBoost = 0, sourceWeight = 1 }) { const now = Date.now(); const created = new Date(createdAt).getTime(); const ageDays = (now - created) / (1000 * 60 * 60 * 24); // 时间衰减:7 天衰减到约 37% const timeFactor = Math.exp(-ageDays / 7); // 阅读时长偏好:3~8 分钟的内容优先 let lengthFactor = 0.5; if (readingMinutes <= 3) lengthFactor = 0.8; else if (readingMinutes <= 8) lengthFactor = 1.0; else if (readingMinutes <= 15) lengthFactor = 0.7; else lengthFactor = 0.4; const score = 0.4 * timeFactor + 0.4 * lengthFactor + 0.15 * sourceWeight + 0.05 * manualBoost; return Number(score.toFixed(3)); }分数区间控制在 0 到 1 之间,方便比较。四个信号分别是:时间衰减:越早添加的权重越低,但不会直接归零,7 天滚动衰减。阅读时长:3~8 分钟的内容最适合“碎片时间补读”,所以分数最高;15 分钟以上的长文容易被刻意回避。来源权重:每个域名可以单独配置权重,我把自己常看的几个技术社区权重调到了 1.2,娱乐站点调到 0.6。手动提升:rea add -b <url>可以额外加分,适合用户明确知道“这篇很重要必须读”的场景。这个公式不是一步到位的。最初版本里sourceWeight占 30%,结果某个权重高的站点每天产出一堆水文,全部排到前面,严重干扰队列。后来把来源权重降到 15%,并增加了一个“同一域名每日最多入队 5 篇”的软限制,问题才缓解。3.3 为什么要把评分结果直接暴露出来rea list默认输出优先级最高的 20 条,但我特意把priority和estimated_time两列打在结果里:$ rea list ID STATE TIME MIN PRIORITY TITLE 12 inbox 82% 6 0.812 理解事件循环:从 setTimeout 到微任务 7 inbox 30% 4 0.765 终端复用器的三条配置技巧 33 inbox 96% 2 0.700 用 SQLite 存时间序列数据把分数直接暴露出来,看起来有点“极客”,但实际效果很好。用户会下意识地和分数做心理博弈:“这篇 0.812,确实值得马上读”“这篇只有 0.5,先放着吧”。分数成为决策的锚点,而不是让用户面对一堆平等的标题做选择。4. 从零搭建 rea:核心模块的代码级拆解确立数据模型和算法之后,我把核心模块按“命令-服务-存储”三层拆开。命令层负责解析参数,服务层负责业务逻辑,存储层封装 SQLite 操作。这里重点讲 add、list、状态流转和提醒四个模块。4.1 项目结构与依赖rea 的项目结构非常简单:rea/ bin/rea.js # CLI 入口 lib/store.js # SQLite 封装 lib/extract.js # 抓取与正文抽取 lib/priority.js # 优先级计算 lib/remind.js # 提醒逻辑 package.json初始化只需要两个依赖:cheerio负责解析 HTML,better-sqlite3负责同步访问数据库。命令行本身我用的 Node.js 内置process.argv解析,没有引 commander,因为命令数量少,引外部依赖反而增加学习成本。npm init -y npm install cheerio better-sqlite34.2 add 子命令:抓取、去重、入库add命令的完整逻辑是这样的:// bin/rea.js 相关片段 const { addTask } = require('../lib/store'); const { fetchAndExtract } = require('../lib/extract'); const { computePriority } = require('../lib/priority'); async function add(url, boost = false) { if (!/^https?:\/\//i.test(url)) { url = 'https://' + url; } const existing = findTaskByUrl(url); if (existing) { boostPriority(existing.id, 0.1); console.log(`文章已存在,优先级 +0.1,当前分数 ${existing.priority.toFixed(3)}`); return; } const { title, excerpt, readingMinutes } = await fetchAndExtract(url); const sourceWeight = getSourceWeight(new URL(url).hostname); const priority = computePriority({ readingMinutes, createdAt: new Date().toISOString(), manualBoost: boost ? 1 : 0, sourceWeight }); const id = insertTask({ url, title, excerpt, source: new URL(url).hostname, readingMinutes, priority }); console.log(`入库成功 ID=${id} 预计阅读 ${readingMinutes} 分钟,优先级 ${priority.toFixed(3)}`); }去重逻辑用了两层:先查tasks表里的唯一索引,如果 URL 已经存在,不新增记录,只给旧任务加 0.1 的优先级。这个设计很关键,否则用户同一篇文章在不同设备上看到三次就会加入三次,队列迅速膨胀。getSourceWeight从配置文件读取,默认所有域名权重是 1.0。配置文件就是一个简单的 JSON 文件,用户自己维护。{ "sourceWeights": { "news.ycombinator.com": 1.2, "example.com": 0.8 } }4.3 list 与状态流转:open / done / archivelist的实现很简单,核心是 SQL 分页和状态过滤:function listTasks(limit = 20, status = 'inbox') { const rows = db .prepare( `SELECT id, url, title, reading_minutes, priority, datetime(created_at) AS created_at FROM tasks WHERE status = ? ORDER BY priority DESC, created_at DESC LIMIT ?` ) .all(status, limit); return rows; }状态流转的代码也不需要魔法。open命令把status改成reading,done命令补全read_at,archive命令统一归档:function changeStatus(id, newStatus) { const info = db .prepare('UPDATE tasks SET status = ? WHERE id = ?') .run(newStatus, id); if (info.changes === 0) throw new Error(`任务 ${id} 不存在`); } function markDone(id) { const info = db .prepare( `UPDATE tasks SET status = 'reading', read_at = datetime('now', 'localtime') WHERE id = ? AND status != 'trash'` ) .run(id); if (info.changes === 0) throw new Error('任务不存在或已在回收站'); }我这里把done之后的状态设计成reading,而不是read,是有意为之:读完不等于归档,它仍在“最近读过”的范围里,方便一周后回顾。真正的归档由archive命令触发。4.4 提醒模块:不是定时器,是状态检查写提醒模块时我踩过一个大坑:最开始模仿 cron 做一个常驻进程 setTimeout 循环,但笔记本一合盖,系统睡眠,定时器全部失效。后来我把提醒从“定时触发”改成了“差值扫描”。function scanReminders() { const now = new Date().toISOString(); const dueTasks = db .prepare( `SELECT id, url, title, planned_at FROM tasks WHERE planned_at <= ? AND status = 'inbox' AND done_reminded = 0 ORDER BY planned_at ASC LIMIT 5` ) .all(now); for (const task of dueTasks) { console.log(`提醒:${task.title}\n${task.url}\n计划阅读时间:${task.planned_at}`); db.prepare('UPDATE tasks SET done_reminded = 1 WHERE id = ?').run(task.id); } }每次运行任意 rea 命令时,都先执行一次scanReminders()。这样即使用户三天没打开终端,只要哪天运行了rea list,系统就会把过去欠下的提醒一次性补齐。它不追求“准时”,追求的是“不错过”。5. 实测中踩过的三个坑和对应的修复方案代码写完不等于能稳定用。连续三周的真实使用里,我遇到了三个影响体验的问题,每次都是从“怎么不对?”到定位根因、修复、验证的完整链路。5.1 页面编码不同,正文变成乱码第一个问题是老站点的编码。Node.js 的fetch().text()默认按 UTF-8 解码,可某些老旧页面用的是 GB2312 或 GB18030,抓回来全是“锟斤拷”一类的乱码。排查过程是这样的:我用fetchAndExtract('http://example.com/old-post.php'),返回的title完全不可读,但是用浏览器打开却正常。浏览器能正常解析是因为 HTML 的<meta charset>里声明了编码。rea 需要自己处理这个信号。修复方案是在拿到 HTML 原始 Buffer 之后,先看前 1024 字节里的 charset 声明,再决定用哪个编码解码:const { TextDecoder } = require('util'); async function getDecodedText(res) { const buf = Buffer.from(await res.arrayBuffer()); const meta = buf.subarray(0, 2048).toString('ascii').match(/charset=["']?([\w-]+)/i); const charset = meta ? meta[1].toLowerCase() : 'utf-8'; if (charset.includes('gb')) { return new TextDecoder('gb18030').decode(buf); } if (charset.includes('big5')) { return new TextDecoder('big5').decode(buf); } return new TextDecoder('utf-8').decode(buf); }Node 的TextDecoder原生支持gb18030、big5等编码,并不需要额外安装字符集库。这个修复之后,老文章的抓取成功率提高了很多。5.2 同一链接被反复加入,任务表长出很多重复行第二次问题是重复数据。一开始去重逻辑放在 JavaScript 层:findTaskByUrl先查一遍再插入。但终端用户经常使用rea add时忘记自己已经收藏过,或者同一个 URL 有utm_source追踪参数,导致数据库里出现同一个页面 5 条记录。我做了两个修复:在表上建url唯一索引,先解决完全重复。在add逻辑里做 URL 规范化,去掉常见的utm_*、fbclid、gclid追踪参数,再判断是否重复。function normalizeUrl(rawUrl) { const u = new URL(rawUrl); [...u.searchParams.keys()].forEach((key) => { if (key.startsWith('utm_') || key === 'fbclid' || key === 'gclid') { u.searchParams.delete(key); } }); return u.toString(); }这个修复之后,重复录入量下降了很多,队列也不会被同一个站点的多篇相似文章塞满了。5.3 后台提醒在笔记本合盖后全部失效第三个问题前面提过一嘴:提醒模块一开始依赖 setTimeout,看起来能工作,但笔记本合盖进入睡眠后,JavaScript 计时器被系统挂起,睡醒之后根本不会补执行。更隐蔽的是,这种失效毫无报错,整个过程像“提醒功能自己消失了”。后来我彻底去掉了常驻进程,改成“命令触发时扫描补提醒”。具体的做法是:每次 CLI 入口执行scanReminders()。用户主动运行rea remind也可以触发。在系统 crontab 里加一行:每天早上 9 点执行rea list,变相触发补提醒。实践下来,这个方案最稳。它不依赖后台进程是否存活,只依赖用户是否执行过命令。对于命令行工具来说,用户每次敲命令都是一次天然的检查点。6. 跑了一个月后的真实收益和下一步准备做的事rea 从设计到写成、再到稳定使用,差不多三周时间。真正开始改变我的,是第四周统计数据的时候。rea stats输出的数字让我有点意外。6.1 数据说话:回读率从 5% 到 38%对比的时间窗口是同样的一个月:指标之前(书签收藏)现在(rea 队列)累计新增链接12086完整读过的链接633回读率5%38%平均每周打清理0 次2 次清理时感到犹豫总是很少回读率提升不是算法多聪明,而是“收藏链路”变了。过去收藏完就结束,现在收藏完还要面对一个排序列表,列表里明确告诉你“这篇预计 6 分钟,优先级 0.812”。优先级数字本身就在传达一个信号:你现在就可以读。用户默认接受了这个信号,回读率自然就上去了。另一个变化是我敢定期清理了。以前清理书签像是要扔掉一段记忆,担心以后会用到。现在 rea 有完整的历史记录和摘要,归档只是状态变化,不是删除,心理负担小很多。6.2 几个值得继续做的方向rea 目前还谈不上完善,我列了几个下一步优先级比较高的方向:浏览器扩展:右键点击链接直接发送到本地 rea,省掉rea add手打 URL 的步骤。本地命令工具加一个 HTTP 服务即可对接。按主题自动聚类:在标题、摘要上做简单的关键词分组,把“事件循环”“HTTP 协议”这类主题聚成一类,避免某个主题长期霸占队列。轻量可选的同步:默认继续本地优先,但可以把 SQLite 文件放到自建的 WebDAV 或网盘目录,实现多设备共用队列。引入简单的全文检索:给excerpt和title建 FTS5 虚拟表,以后搜索历史阅读记录会比LIKE快得多。还有一个小方向:给reading_minutes加上统计反馈。实际阅读速度不是恒定 220 字/分钟,可以根据用户完成done的时间反推真实速度,再调整估算值。这一步不急,但对长期体验有意义。最后分享一个我在实际使用中最受用的技巧:不要追求“把队列清空”。稍后读工具的价值不是所有链接都被读完,而是让重要内容有机会浮到表面。每周固定抽出十分钟,把队列里优先级低于 0.3 的任务直接归档,你会发现可维护性比“全部读完”高得多。rea 不是又一个收藏夹,它是一个帮我把“收藏”和“已读”之间的距离缩短的工具,而这段距离,恰好是阅读真正的价值所在。