深夜翻到自己一年前写的日志,那种感觉很难形容。明明是自己的字迹,却像在和一个熟悉的陌生人对话。这件事让我意识到一个尴尬的事实:记录本身意义不大,真正有用的是在恰当的时机重新看到这些记录。所以我写了这个小工具,名字就叫 hindsight——事后聪明,回看过去。
hindsight 是一个跑在本地的小程序,每天定时扫一遍我的 Markdown 日志目录,把“去年的今天”或者“几周前”的记录挑出来,推送到手机或邮箱。它不折腾、不花哨,做的事情很简单:让过去的记录在正确的时刻重新出现。适合有写日志、开发笔记、工作周记习惯,但很少翻旧账的人。
1. 整体设计与思路拆解
1.1 需求来源:记了很多,但从来不回看
先说痛点。我用 Markdown 写日志大概有三年多,文件按日期命名,规规矩矩放在~/logs下。但说实话,写完基本就沉底了。偶尔需要查某个决定当时为什么这么做,得靠回忆“大概是什么时候”,然后用grep去翻,效率极低。更重要的是,很多随手记下的片段,当时觉得没用,过几个月回头看反而是宝贝。
比如有一次,我翻到一个多月前记的“今天修了一个诡异 bug:缓存键漏了用户维度”,这句话本身很简单,但如果你恰好正被同样的 bug 折磨,这条日志简直就是救命稻草。问题在于,没人提醒你去看它。
所以我需要的不是又一个笔记软件,而是一个机制:把已经写过的内容,按照合理的时间间隔重新摆到眼前。这就是 hindsight 的初衷。
1.2 设计原则:简单、本地、被动触发
我对这个工具有几条硬性要求。
第一,不改变现有的记录习惯。我不想为了迎合工具去换格式、换平台。Markdown 日志是我多年习惯,工具必须直接兼容,而不是要求我以后用某种特定格式记录。
第二,必须本地运行。日志是私密东西,藏着我的决策过程、踩坑记录、甚至一些不成熟的想法。上传到第三方服务实在不放心,所以整个工具纯本地,数据库用 SQLite,数据不出机器。
第三,被动触发,不打扰。hindsight 不能像一个闹钟一样在通知栏跳出来说“该写日志了”,它只在我设定的时间(比如早上九点)悄悄跑一下,把该回顾的内容推送出去。看了就看了,不想看划掉即可,没有任何打卡压力。
这三条原则直接决定了后面所有技术选型,也是它和市面上各种“习惯养成”“日记打卡”类应用最大的区别。
1.3 技术选型:Node.js 就够了
运行时选了 Node.js。理由很实在:解析 Markdown、处理日期、写 SQLite,这些用 Node 都顺手,而且几乎不用装额外依赖。整个项目最终只有一个package.json、一个主程序文件和一个 SQLite 数据库文件,清爽得很。
定时任务方面,我一开始打算用node-cron,但后来发现直接用系统自带的任务计划就行,反而少了一层依赖。程序本身做成每次启动跑一轮,跑完退出,由系统调度器负责定时拉起。这种“单次执行”的模式比“常驻进程”简单得多,不用担心内存泄漏,也不用做守护。
数据库选 SQLite 而不是直接读 JSON 文件,主要是考虑到回顾记录会越来越多。我得记住“哪年哪月哪日的日志已经推过”,如果存成 JSON,每次读写要整体加载,数据量大了必然卡。SQLite 按主键查一行,毫秒级,而且并发写入也安全。
2. 核心功能与数据设计
2.1 数据来源:从现有 Markdown 日志里解析
先看一下我的日志目录长什么样:
logs/ 2024-01-15.md 2024-01-18.md 2024-02-02.md ...文件名本身就是标准 ISO 日期格式。这是很关键的设计决策——文件名里带日期,让解析工作变得异常简单。如果你像我一样用 Obsidian、Typora 或 VS Code 写日志,目录组织方式基本类似。
hindsight 启动时读取目录下所有.md文件,把文件名中的日期解析出来,存入内存中的索引。每份日志的内容全文保留在内存里,等待后续匹配。
有个细节需要注意:有些人的日志文件名可能是2024-01-15-随手记.md这种带后缀的形式。我的处理办法是取文件名前 10 个字符,尝试new Date(str)解析,解析成功才加入索引,失败直接跳过。这样即使混入了其他类型的 Markdown 文件也不会出问题。
日志内容首行通常是标题,我把它提取出来作为推送的展示文本。剩下内容截取前 200 字作为摘要,完整内容不推到手机上,只有想看时才打开文件。
2.2 回顾触发机制:核心的“距离计算”
这是 hindsight 最核心的逻辑。所谓“回顾”,本质是回答一个问题:今天应该看过去的哪几篇日志?
我的策略是:轮询四个固定时间点——1 天前、7 天前、30 天前、365 天前。
const OFFSETS = [1, 7, 30, 365];但这里有个问题:如果每天都轮询这四个时间点,一年之后,365 天前的那条会变成每天覆盖前一天,实际上就是“去年的今天”这一条。好处是稳定,坏处是单调——每天都只看那一天的记录,很快会厌倦。
所以我加了随机偏移量,让回顾的内容既有规律又有惊喜:
function offsetsForToday() { const base = [1, 7, 30, 365]; return base.map(days => days + Math.floor(Math.random() * 3) - 1); }也就是说,“7 天前”可能是 6 天前、7 天前或 8 天前。这种微小的扰动让回顾不再机械。实际用下来,偶尔翻到“6 天前”的记录往往比精准“7 天前”更有意思。
匹配过程很简单:取当天日期,往前数 N 天,到日志索引里找对应文件是否存在。存在就记录,不存在就跳过。四轮执行完,得到一个候选列表。
2.3 去重与“已读”状态:避免反复推送同一条
如果每天都推送“1 天前”的记录,那么一条日志会在第一天被推一次,在第七天又被推一次(作为“7 天前”),在第三十天又被推一次。这算合理——不同时间间隔去看同一条内容,感受完全不同。但如果 2024-01-15 这条日志既被当作“7 天前”推过,又被当作“8 天前”推过,那就是重复了。
解决方法是 SQLite 里建一张推送记录表:
CREATE TABLE sent_log ( date TEXT, target TEXT, sent_at TEXT, PRIMARY KEY (date, target) );date是日志的日期,target是“回顾类型”,比如d1、d7、d30、d365。每次推送前先查一下,如果这一对组合已经存在,就跳过。这样既允许同一条日志在不同时间间隔下被多次回顾,又避免了相同间隔下的重复推送。
多设备场景下,可能需要在手机和电脑上都装这个工具。那初始化时直接拷贝 SQLite 文件即可,同步逻辑不需要做,因为每个设备推送过的记录会各自独立增长。
3. 实操过程与核心实现
3.1 项目骨架与依赖
初始化一个 Node.js 项目,依赖极少:
npm init -y npm install better-sqlite3 node-fetch@2better-sqlite3是同步 API,比sqlite3的异步回调写起来顺滑得多,适合这种轻量场景。node-fetch用来发 HTTP 请求(推送到微信机器人等)。如果你只想发邮件,也可以换成nodemailer。
目录结构如下:
hindsight/ index.js config.json package.json data/ hindsight.db3.2 核心代码实现
主程序逻辑分四块:读取配置、解析日志、匹配回顾、推送结果。我把关键代码列出来,并逐段说明。
读取配置:
const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8')); const db = new Database(path.join(__dirname, 'data', 'hindsight.db')); db.exec(` CREATE TABLE IF NOT EXISTS sent_log ( date TEXT, target TEXT, sent_at TEXT, PRIMARY KEY (date, target) ); `);这里有个实践细节:数据库目录data/必须确保存在,否则better-sqlite3不会自动创建父目录。我在初始化脚本里加了一句fs.mkdirSync(config.dataDir, { recursive: true }),省得第一次跑直接报错。
解析日志索引:
function buildIndex(logDir) { const files = fs.readdirSync(logDir).filter(f => f.endsWith('.md')); const index = new Map(); for (const file of files) { const m = file.match(/^(\d{4}-\d{2}-\d{2})/); if (!m) continue; const content = fs.readFileSync(path.join(logDir, file), 'utf8'); const lines = content.split('\n').filter(l => l.trim().length > 0); const title = lines[0].replace(/^#\s*/, ''); const snippet = content.slice(0, 200); index.set(m[1], { file, title, snippet, full: content }); } return index; }关键在于正则/^(\d{4}-\d{2}-\d{2})/。它从文件名头部取出前 10 个字符,校验是不是日期格式。这要求日志文件名的日期部分必须在开头,实践下来这是最省事的约定。
匹配回顾目标:
function getTargetDates(today) { const days = [1, 7, 30, 365]; const result = []; for (const d of days) { const drift = Math.floor(Math.random() * 3) - 1; const target = new Date(today); target.setDate(target.getDate() - (d + drift)); result.push({ target: 'd' + (d + drift), date: fmt(target) }); } return result; } function fmt(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; }fmt函数没有用toISOString().slice(0, 10),是因为toISOString会把日期转成 UTC 表示,在中国时区晚 8 点以后就会变成“明天”。这是个很坑的细节,必须手动格式化本地日期。
推送逻辑:
function buildMessage(hits) { const lines = hits.map(h => { return `【${h.target}】${h.title}\n${h.snippet}`; }); return `Hindsight 今日回顾\n\n${lines.join('\n\n---\n\n')}`; }推送方式我用过两种。最简单的是通过企业微信/钉钉机器人 webhook,往群里发一条消息;后来改用 Server 酱微信推送。代码上都是发一个 HTTP POST,区别只是 URL 和 body 格式。为了不把配置写死在代码里,我在config.json里放了一个pushUrl和一个payloadBuilder标识,按标识组装数据。
3.3 定时任务与部署
程序写完后,定时任务配置反而最简单。Linux 下用 crontab:
0 9 * * * cd /home/user/hindsight && /usr/bin/node index.js >> hindsight.log 2>&1每天上午九点跑一次。为什么选九点?因为早上的心态适合回看过去的记录,而且这时候人还没被工作淹没,愿意花十几秒扫一眼推送内容。
Windows 环境下可以用“任务计划程序”,配置触发器为每天 09:00,操作指向node index.js。
有个部署层面的优化建议:把日志目录和数据库目录都放到配置项里,不要写死绝对路径。这样换机器、迁移目录都不用动代码,只改config.json。我的配置长这样:
{ "logDir": "/home/user/logs", "dataDir": "/home/user/hindsight/data", "pushUrl": "https://example.com/push", "payloadType": "wecom", "timezone": "Asia/Shanghai" }timezone字段是个后加的保险。程序启动时检查当前时区,如果和配置不符就直接退出并报错,避免在服务器上因为时区没配对而把“今天”算成了“昨天”。
4. 常见问题与排查手记
4.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 一条回顾都不推送 | 日志目录路径错误 | 检查config.json中的logDir,手动ls确认存在 |
| 推送的是昨天而不是今天日期 | 时区问题 | 启动时打印new Date().toString()对比,手动格式化本地日期 |
| 同一条内容多次重复推送 | 推送记录表缺失或主键冲突 | 检查sent_log表是否存在,主键必须是(date, target)组合 |
| 某一天日志没找到 | 文件名开头不是标准日期 | 检查日志文件名是否带前缀,如Draft-2024-01-15.md |
| 多条日志都匹配上,但只推了一条 | 候选列表没有遍历完整 | 检查buildMessage里是否覆盖了全部hits |
| 抠细节:推送内容里标题为空 | 日志首行不是标题 | 自动降级取内容前 20 字作为标题 |
这些坑我基本都踩过一遍。最典型的时区问题,第一次部署到境外服务器时才暴露,本地跑得好好的,一到线上日期全乱了。排查日志里new Date()输出才会发现是 UTC 时间和本地时间对不上。
4.2 实测体验:连续跑了一个月之后
工具跑了一个月之后,我统计了一下推送情况:总共推送了 30 次,每次 1~3 条,点开率大概 70%。这里的“点开率”指的是我真正去看了完整日志的比例,不是邮件打开率。
最有价值的一条回顾,是一个深夜写下的抱怨式记录:“今天上线了 A 功能,但数据库字段名起得太随意,明天一定要改。”这本身很无聊,但 30 天后系统把这条日志推给我的时候,正好 A 功能出现了线上报错,我下意识去查那个字段,瞬间想起来当时埋的坑。如果没有 hindsight,我大概会花半小时从代码里把所有字段名都翻一遍,最后才能定位到问题。
30 天时间差带来的“刚刚好”感,是随机漂移的功劳。如果我每天看到的都是“1 天前”,不会产生这种效果。这个体验告诉我:回顾的价值不是线性的,30 天、365 天这种“时间跨度差”,往往比“昨天”更有冲击力。
4.3 使用技巧与习惯养成
几个小技巧,让这个工具用得更好。
第一,日志结尾写“下一步”。写日记时顺手写一句“明天/下周要做什么”,30 天后被推回来,能顺带完成一次“计划校验”——当时定的计划做到了没有。
第二,不要每天都设置多个回顾点。我试过把 1 天、7 天、30 天、365 天全开,最开始几天很新鲜,一周后就麻木了。后来改成只在周一和周四跑,回顾间隔也调成了 7 和 30,效果反而更好。原因是:频率太高,人会自动过滤推送。
第三,保留旧版本代码。hindsight 的价值在于“时间积累”,数据库里的推送记录和日志索引结构会随着版本演变而变化。给每个版本打 tag,升级时先备份hindsight.db,这样出了问题还能回滚。
我个人现在把 hindsight 扩展到了两个场景:一个是工作上的技术周报,另一个是个人碎片记录。技术上完全没变,只需要把logDir指向不同的目录即可。如果你也有写记录的习惯,且这些记录长期沉睡在硬盘里,这个方向值得参考——代码量不大,但时间越长,收益越明显。