
我平台上写东西不多但每个自己做的小工具都会想办法留一篇复盘4.3XUE 是我拖得最久的一个。先解释一下这名字怎么来的XUE 取“学”的拼音4.3 是当前版本号连在一起读就是“学到 4.3”算是个自我提醒——记录本身不值钱回看才算真正学过。这个项目不是一个面向大众的产品而是我自己用了两年多的本地笔记整理和复习工具最近一次重构停在 4.3。它解决的问题特别具体我每天往 Obsidian、备忘录和工作日志里扔各种碎片信息但到了月底根本不知道之前记了什么。4.3XUE 做的就是扫描本地 Markdown 文件和导出文件解析成统一结构去重、建索引然后每天用类似抽卡的方式让我回看 20 条旧笔记。这篇复盘里我会把它的架构、关键决策、踩坑记录和真实运行数据摊开讲适合正在做个人工具、本地数据处理或者任何“信息进去就再也不出来”这类场景的人。1. 为什么一个小工具会迭代到 4.3项目背景与两次推倒重来1.1 第一版只是一百行脚本而且只能服务一个文件夹2023 年初我受够了“记过等于学过”的状态。当时每天往笔记软件里粘贴文章、写想法、存工作记录但复盘时基本靠“翻最近修改”。我尝试过按修改时间排序把一个月前的老笔记找出来过一遍坚持了两周就放弃因为靠人眼根本判断不了哪些值得回看。于是动手写了个 Python 脚本逻辑非常直白读取某个固定目录下的所有 .md 文件从 YAML frontmatter 里提取 tags 和 created 字段筛出距离上次复习超过 N 天的文件打印一个列表。那个版本用的全是最土的办法路径写死成字符串时间格式只接受一种标签规则也绑定了我自己笔记的写法。脚本跑得很顺畅直到有一天我导入了 Kindle 金句和浏览器剪藏格式完全对不上脚本直接报错。我当时的反应是“给脚本加个适配”而不是重新想问题结果就是正则越加越多脚本从 100 行膨胀到 400 行。能跑但换个目录、换种导入源就翻车。这是个人工具最典型的死法在错误抽象上不断打补丁。1.2 第一次重构把“处理格式”和“业务逻辑”彻底分开2024 年初我做了第一次推倒重来核心就一句话先定义统一的数据模型再为每种来源写一个适配器所有后续逻辑只看统一模型不再关心原始格式长什么样。这个统一模型是一个叫 Note 的 dataclass字段包括字段含义示例source来源类型obsidian / kindle / worklogpath原始文件绝对路径/Users/me/notes/py.mdtitle标题Python 生成器笔记created_at创建时间归一化后2024-01-03T09:12:00updated_at最后修改时间2024-02-11T22:30:00content正文纯文本首次用 yield 的注意点……tags标签列表[python, 笔记]anchors原文定位信息文件第 12-18 行 / Kindle 位置 1024为什么必须做这一步因为不把格式差异挡在最前面后面的统计、去重、复习逻辑就会被各种 if-else 污染。比如 Kindle 导出的时间是 “Added on Monday, January 1, 2024”Obsidian 的 frontmatter 是 ISO 字符串如果让后续逻辑直接处理这两种格式代码里会到处都是分支判断。把复杂度集中在解析层之后其余代码就能一直保持简单。这次重构让我第一次有了“在设计工具”而不是“给脚本擦屁股”的感觉。但也是这次重构让我膨胀了后面几个月功能越加越多4.0 时代的配置项一度接近 80 个。1.3 第二次重构砍功能回到减法只留一条主链路80 个配置项的问题不是字面上的“复杂”而是我自己都记不住哪些参数需要配合使用。比如开启每周摘要需要同时设置 digest-days 和 digest-output漏一个就静默失效。那种体验非常糟糕工具是我亲手写的但用起来像在操作一个陌生人的半成品。所以 4.0 之后的 4.3 其实是一次瘦身重构。我把功能砍到只剩五个命令scan、index、review、export、doctor对应一条完整链路扫描文件 → 解析成 Note → 建立索引和去重 → 每天抽卡复习 → 按需导出。配置从 80 项砍到 21 项而且 90% 的情况下可以完全不碰配置文件默认值就够用。每次重构我都会把“这次删掉了什么”记在 CHANGELOG 里因为删除功能比添加功能更需要勇气。阶段形态规模最大的教训v12023单脚本约 400 行写死格式会让工具脆弱到换目录就崩v220244.0-4.2多模块小系统约 6000 行抽象到位后能扩展但功能膨胀也快v320254.3精简核心链路约 3500 行核心链路越窄日常使用越稳2. 4.3 的核心架构解析、索引、抽卡三层到底各干了什么2.1 解析层所有格式最终都变成同一个 Note 对象解析层是 4.3XUE 的地基。内置了三个解析器markdown-obsidian、kindle-clippings、plain-text其中 markdown-obsidian 最常用。一个解析器要做的不是“把字符串清洗得好看”而是把来源文件转成一个完整的 Note 结构。入口处会先做一层编码和格式归一化然后把正文拆成标题区和内容区再从 frontmatter 或首行提取 tags。写解析器时最花功夫的是让失败可见。4.3 里任何一个文件解析失败都不会影响其他文件处理失败路径会写进 .xue-error.log并记录原始路径、错误类型和大致行号。这一步我一开始不愿意做觉得个人工具没必要这么严谨但后来发现没有失败记录文件一旦解析失败就会被静默跳过你根本不知道数据少了最后统计出来的回看率是假的。4.4 之前我甚至计划做“失败文件自动再试一次”目前靠手动检查 log 也够用。解析器的另一个设计原则是“不改写原文件”。所有归一化后的结果只存在 SQLite 里原始 Markdown 保持原样。这样做的原因很实在解析规则可能会改错如果直接回写到原文件改错了想恢复都难。工具只读原文件、只写自己的数据库出问题最坏也就是重建索引不会伤害原始笔记。2.2 索引层去重和召回靠的是同一套本地索引解析完的 Note 会进入索引层。4.3 用 SQLite 存索引表结构大概分四张notes 存全部笔记字段sources 记录每个文件的上次扫描状态reviews 存每天的复习记录和评级settings 存配置和元数据。为什么用 SQLite 而不是继续用 JSON因为 4.0 之前我用 JSON 文件存索引2000 条笔记时还能忍到 5000 条以后每次全量读取和重写都明显卡顿。换 SQLite 后全量索引建立大约 6 秒增量扫描只需要处理新增和变更文件一般能控制在 1 秒内。去重逻辑也放在这层。实际使用中重复主要来自三个场景同一篇文章被浏览器剪藏和稍后读各存了一份同一段 Kindle 摘录在多个来源文件里重复出现软链接把同一个文件映射进了两个目录。我的方案是两步走先对所有 Note 做 simhash 粗筛相似度超过阈值再算编辑距离精确判断。重复项不会被直接删除而是标记为 duplicate只保留第一次索引到的版本原始文件一律不动。4.3 的核心原则就是工具可以有自己的判断但不替用户删任何东西。2.3 抽卡引擎为什么我把“复习”做得像背单词软件这个项目最有趣的部分其实是复习模块。每天运行 xue review 后程序会从索引里选候选笔记规则有三条已经到期的卡片优先没到期的卡片按随机抽样补充到 20 条如果指定了 tag当天只从该标签下选。到期时间用的是简化版 SM-2 间隔算法按你的评级调整下次出现时间。我最初试过完全按到期时间排结果因为某几天高强度复习后面连续好几天出现大量卡片压力很大。改成“到期优先 随机补充”之后每天的 20 条里通常有 14 到 17 条是真的该复习的老笔记剩下的是随机翻出来的零碎信息。后者经常能带来惊喜因为有些笔记记完就忘随机翻到反而会想起当时为什么记录它。整个交互在终端里完成先显示笔记的前两行按任意键展开全文再按四个评级键记录记忆质量。评级和间隔天数对应关系很简单评级含义下次间隔a完全忘了1 天后重来h有点印象但不完整3 天后g能复述大部分7 天后e太熟了不用再看16 天后为什么不做成网页两个原因终端冷启动不到 200 毫秒网页光开浏览器就好几秒数据全在本地所有私人内容不出这台电脑。这个决策在 4.3 上被验证是对的——我每天打开终端的频率比打开浏览器高得多复习动作基本无摩擦。3. 4.2 到 4.3 的重点变更格式归一化、多仓聚合和体检命令3.1 格式归一化一个函数终结了几个月的时间格式混乱4.2 时期虽然已经有了统一模型但真实文件里的脏数据还是防不胜防。最典型的就是时间格式。我统计过自己两年多的笔记里面出现过四种主流写法ISO 时间、中文日期、英文缩写、纯时间戳。同一个文件里甚至出现过三种混用按 updated_at 排序时结果完全离谱。4.3 在解析器入口加了一层 normalize_meta()把所有时间统一成 ISO-8601 字符串同时把 Windows 上常见的 CRLF 转成 LF把 UTF-8 BOM 去掉。这些细节说起来很小但不处理的话后面每一个依赖时间的环节——复习安排、最近更新排序、导出文件名——都会踩坑。我把这个函数放在所有解析器之前调用相当于给入站数据上了一道保险。除了时间标签格式也是重灾区。有人用 #python有人用 “python”有人用 “Python”4.3 会统一小写化并把逗号、中文逗号、分号都视为分隔符。标签统一后按标签筛选复习才能稳定工作否则你永远会怀疑“是不是有些笔记漏掉了”。3.2 多仓聚合一条命令扫描三个完全不相关的目录4.3XUE 的配置核心是一个 sources 数组。我目前配置了三个目录工作日志、学习笔记、个人 Obsidian vault。每个 source 有 roots 和 exclude 两个字段exclude 主要用来过滤 .obsidian、.git、node_modules 这类目录。4.3 新加了一个多仓冲突处理如果两个目录里出现相同标题但内容不同的笔记索引时不会简单合并而是保留两个版本自动给标题加 [1] 和 [2] 后缀在复习时同时展示。这个功能很小却很关键因为我以前为了“合并重复”误删过一条很重要的日记从此定下规矩工具永不合并内容不确定的笔记只负责把冲突展示出来让用户自己决定。多仓聚合带来的另一个好处是复习维度更丰富。同一套抽卡逻辑里可能这周复习的是工作日志里的决策记录下周抽到的是读书笔记知识交叉的感觉比单独整理某一个目录要舒服很多。配置示例大概长这样[sources] [[sources.work]] roots [/Users/me/worklog] exclude [.git, node_modules] [[sources.learn]] roots [/Users/me/notes] exclude [.obsidian, .trash] [[sources.vault]] roots [/Users/me/Documents/vault] exclude [.obsidian, .git]3.3 命令行体验五个命令加一个体检命令 doctor4.3 的 CLI 设计目标是“每天只需要用不想太多”。主要命令是这五个xue scan # 扫描配置的目录记录文件变动 xue index # 解析新文件去重重建本地索引 xue review # 开始今天的抽卡复习 xue export --format md # 把今天的复习列表导出成 Markdown xue doctor # 检查配置、编码、重复文件、索引健康状况xue doctor 是我自己最常用也最感谢的命令没有之一。它能做的事情很简单在你遇到问题之前先告诉你哪里可能有问题。比如它会检查“超过一百个没有被处理过的文件”“SQLite 索引超过 100MB”“存在带 BOM 的文件”“解析失败日志已经很久没有清理”。以前这些坑都是等数据出问题了才发现现在每周一早上跑一次 doctor大部分潜在问题都会暴露在小规模阶段。对个人工具来说这种“自我体检”比任何华丽的新功能都值钱。4. 本地文本处理最容易翻车的地方编码、路径、重复与幂等4.1 编码和 BOM一个看不见的 \ufeff 让我白折腾了一个下午有一段时间我发现部分笔记标题开头多了一个不可见字符导致按标题排序和去重都出现了偏差。排查过程是这样的先怀疑标签提取看代码看不出问题接着打印标题的 repr()终于看到一个 \ufeff 位于字符串最前面。这个字符是 UTF-8 BOM 在读取时没有被剥离导致的通常来自 Windows 上某些编辑器创建的 Markdown 文件。问题不难但要靠肉眼发现标题首字符不可见确实很折磨。4.3 在读取文件时显式检测 BOM 并剥掉写文件时统一成 UTF-8 无 BOM。这套规范对所有本地文本处理工具都适用。我现在的习惯是任何字符串进入系统后的第一件事先打印一下 repr()很多奇怪问题马上就能现原形。4.2 文件名和路径特殊字符、软链接与跨平台序列化本地文件名的坑比想象中多。中文空格是最常见的还有括号、emoji、非法符号。4.3 在内部把文件路径的完整哈希作为笔记 ID 的一部分这样即使文件名包含特殊字符内部标识符也仍然干净稳定。导出文件时则单独写了一个文件名清洗函数把所有不适合出现在文件名里的字符替换成下划线。软链接也是个大坑。同一个目录如果既被直接配置又被软链接指向扫描时会当成两个路径造成大量重复。4.3 在解析路径前统一调用 realpath先解开软链接再存库。跨平台序列化方面早期版本我直接存 Path 对象换系统后读取 JSON 索引各种报错后来统一转成纯字符串存库跨平台问题基本消失。这些都是踩过坑才会注意到的细节。4.3 幂等扫描为什么 scan 重复跑一万次都不会坏个人工具很容易犯一个错误为了省事扫描时先清空数据库再全量重建。4.0 之前我就这么干结果索引数据一旦解析失败旧数据也没了等于把鸡蛋全放到了一个篮子里。4.3 彻底改掉了这个设计scan 永远不改原文件只记录文件状态同一次文件如果 mtime、size、hash 都没变就直接跳过只有真正变化的内容才会触发重新解析。为了让这种设计更可靠4.3 还把 dry-run 设成了默认行为。你运行 xue scan 时它先告诉你“将新增多少条、更新多少条、跳过多少条”加 --apply 之后才会真正写入数据库。这样做的初衷就是为了惩罚我自己过去那种“没看清楚就全量重建”的冲动。现在不管是新配置目录还是怀疑数据丢了我都会先跑一次 dry-run确认结果符合预期再落地。下面这个表格是我整理出的本地文本工具高发问题清单每一条都来自实际翻车经历问题现象4.3 的应对文件编码不统一标题出现 \ufeff 或乱码读取时识别 BOM 并剥离CRLF/LF 混用正则以行为边界失效normalize_meta 统一换行符路径过长导出时创建文件失败internal ID 用路径 hash软链接重复扫描同一文件被索引两次realpath 归一化文件名特殊字符导出文件名解析错误sanitize 函数统一清洗重复内容多渠道进入复习时大量重复条目simhash 编辑距离标记 duplicate5. 用 4.3XUE 跑完 137 天的真实记录命令序列、数据和效果5.1 一次完整的运行流程长什么样我在 2025 年年初定了一个目标连续 137 天每天复习至少 20 条旧笔记。实际跑通的流程非常简单命令就这几行xue scan --apply xue index xue review --limit 20scan 执行时输出类似这样[scan] 3 sources configured [scan] work: 312 files, 4 changed [scan] learn: 886 files, 2 changed [scan] vault: 949 files, 1 changed [scan] dry-run: 7 to add, 3 to update, 2137 unchanged随后 xue index 解析新文件并增量更新索引xue review 进入复习界面。复习时终端先生成类似这样的卡片[学习笔记] Python 生成器笔记 来源: ~/notes/python-generator.md 更新: 2024-01-03 --- 第一次使用 yield 的注意点函数返回值变成生成器 循环一次消费一个值不能重复遍历。 --- (按空格展开全文然后按 a/h/g/e 评级)每天完整跑完 20 张卡片大概需要 7 到 9 分钟比刷短视频更短但带来的体感很不一样。5.2 137 天结束后的数据是这些我把数据从 SQLite 里导出来做了统计数据量对个人工具来说不算大但足够说明问题指标数值扫描文件总数2147解析成功2031解析失败16去重后保留1773索引文件大小8.4MB全量索引耗时约 6.2 秒每日常规增量耗时0.8 到 1.2 秒连续完成天数137 天平均每日复习条数20 条约 8 分钟大致回看率复习过至少一次的笔记比例63%解析失败的 16 个文件里12 个是“伪 Markdown”文件——从网上下载的文档被改成了 .md 后缀但本质是损坏的 docx 或 HTML另外 4 个是剪藏内容里带有非法编码字符。如果日志系统没建好这 16 条会直接人间蒸发你根本不知道它们被漏掉了。个人工具要对抗的是“黑盒感”失败可见性比失败率本身更重要。5.3 真正让效果变好的不是算法而是“固定时间出现”复盘这段时间我发现效果好主要不是因为 SM-2 算法有多准而是因为 4.3XUE 把复习放在了无法忽略的位置。我写了一个简单的系统计划任务每天 22:05 自动打开终端进入 xue review如果当天没完成第二天未完成卡片会叠加最多叠到 30 条超过后必须先清积压才能继续抽新卡。这个“熔断机制”是我最有成就感的设计。它保证了错过成本不会无限膨胀一天不复习第二天会看到 40 条有点压力如果连续三天不复习系统会直接显示“积压太多请先清空积压”相当于踩了刹车。这个机制让一个没有强制约束力的个人工具能够真正被坚持下来。实践经验是工具黏性不在功能多少而在它每天给你的“开始成本”低不低以及断掉之后重新接上是否容易。6. 4.3 之后为什么我不再加功能个人工具的功能阈值6.1 用户只有自己新功能必须过“每周一次”门槛每个做个人项目的人都容易陷入功能膨胀4.3 之后我给自己定了一个硬性标准如果一个功能预计一周内调用不到一次就不做。反例是 4.2 里加过的“自动摘要统计图”我做了半年只打开过三次最后发现最常用的还是终端里的纯文本列表。砍掉它之后我没有任何不习惯。给个人工具加功能就像给房间堆家具。每次买新家具前都该问一句这件东西每周用一次吗如果答案是否定的它大概率会变成堆在角落的杂物。4.3XUE 能走到今天不是因为功能多而是因为每个保留下来的命令都经得起高频使用。6.2 接下来我只想补的两个小缺口第一个是解析失败文件的兜底修复。目前 .xue-error.log 里那些规则搞不定的文件我打算用离线模型做一次结构化提取然后把结果交给人工确认再入库。确认机制已经在设计里模型输出只生成候选 Note必须经过一条人工确认命令才能进入索引避免垃圾数据悄悄混进来。第二个是手机端随手记录入口。我不打算做 App只想做一个本地网页在手机上把 JSON 粘贴进去通过本地接口入队等电脑端 scan 时统一处理。核心是缩短“收集”和“入库”之间的路径而不是做一个新的移动端产品。6.3 给想抄作业的人一个最小方案20 行脚本起步如果你也面临“记了一堆但从不回看”的问题我建议不要直接模仿 4.3XUE 做一个完整系统而是先写一个 20 行的脚本扫目录、按修改时间输出昨天和上个月的文件列表就行。代码可以非常粗糙不用做数据模型也不用做去重from pathlib import Path from datetime import datetime, timedelta root Path(~/notes).expanduser() cutoff datetime.now() - timedelta(days30) for p in root.rglob(*.md): mtime datetime.fromtimestamp(p.stat().st_mtime) if mtime cutoff: print(f{mtime:%Y-%m-%d} {p})跑起来只要几分钟。坚持两周后如果你发现自己愿意每天打开这个列表再考虑加解析、去重和索引。所有工具都始于脚本脚本的核心价值不是技术含量而是帮你先建立“回看”这个习惯。没有这个习惯任何强大的系统都只是另一种信息囤积。4.3XUE 的 4.3 并不是一个多炫目的版本号我也没有把它开源因为配置里全是我的私人路径和日记内容。但它教会我的事情很具体所有整理工具的最佳状态不是把信息完美归档而是让你每次打开它都觉得“看两条也不亏”。这个标准成了我评估个人工具的核心指标。如果你也在维护一个只有自己使用的项目不妨也给自己定一个类似的标准——版本号走到几点几不重要重要的是你还愿意每天打开它。