
前段时间整理硬盘翻出一个 2016 年存下来的 Markdown 笔记文件夹里面三百多篇草稿到今天还能用文本编辑器直接打开。这件事让我彻底下定了决心与其继续被越来越重的笔记软件绑架不如自己做一个小到不能再小的工具。于是就有了 caveman——一个刻意保持“原始人”水准的本地笔记与静态站点生成器。它没有常驻进程、不依赖云端、不锁数据格式核心代码只有几百行唯一的外部依赖是 Python 的 markdown 解析库。用它写的每一篇笔记都是一个普通的 .md 文件发布站点只是跑一条命令。如果你和我一样受够了“打开编辑器还要等三秒加载”“导出数据像搬家”这种破事这篇完整复盘应该能给你一些思路甚至是一个反方向的新起点。1. 为什么叫 caveman从“工具太重”到“原始人哲学”1.1 被笔记工具反复折腾的那几年在动手之前我先盘点了一下自己过去用过的工具每一款都不是不能用但每一款都让我在某一天动了换掉的念头。用 Notion 的时候离线基本是摆设有一次在高铁上想改一篇笔记页面转了半天圈直接放弃。后来想导出做备份Markdown 倒是能导但里面的图片附件路径全乱换一台设备根本对不上。用 Obsidian 的时候插件越装越多库越来越大启动从秒开变成要盯两秒进度条最怕的是升级一个插件之后配置报错你还得去论坛翻半天 issue。更早还用过大象笔记数据全在私有格式里想走的时候才知道什么叫被绑架。我不是否定这些工具的价值它们都很强大只是我的真实需求可能连它们功能的 1% 都用不到。我需要的就是写点东西存下来找得到能变成网页给其他人看。就这四件事犯不着养一个全家桶。1.2 三条原则数据永不锁死、单点依赖、秒开秒用caveman 这个名字直译是“穴居人”听着很原始但我要的恰恰就是那种原始感。我给自己定了三条硬性原则后面所有设计决策都围着它们转数据永不锁死。所有笔记都是纯文本 Markdown任何一个编辑器都能打开任何脚本都能处理。哪天我不想用 caveman 了直接换个文件夹用就行不存在“迁移”。单点依赖最小化。整个工具的核心就是一个 build.py外加一个 templates 目录。环境坏了重建成本是五分钟的事而不是重新搭一套插件体系。秒开、秒写、秒发布。没有常驻进程没有数据库服务打开就是文本编辑器发布就是跑一条命令。整个过程不允许出现“等下”“转圈”“加载中”这类词。我把这三点贴在终端配置里每次想给项目加东西先过一遍这三条过不去就不加。1.3 “原始人”到底简化掉了什么原始人不需要电视、冰箱、洗衣机但靠火、石头、木棍照样活了十几万年。工具链也一样很多功能看着炫实际用不上。我在规划阶段就明确砍掉的东西包括独立的编辑器、手机 App、自建同步服务、插件系统、富文本粘贴、双链关系图谱。尤其是图谱说实话我用 Obsidian 那阵子从来没靠它检索过任何东西那些连线除了截图发推好看一点实际作用为零。砍完之后剩下的功能少得可怜但每一件都能稳稳跑起来。这也是 caveman 到现在还在用的原因它小到几乎没有坏的地方也就几乎没有需要维护的时候。2. 存储层设计凭什么敢用文件名当数据库2.1 整个知识库就是一个目录caveman 的知识库结构非常简单就是一个 caveman 目录下面分四个子目录caveman/ ├── build.py ├── config.py ├── notes/ # 所有笔记每一篇都是一个 .md 文件 ├── assets/ # 图片、PDF 等附件 ├── templates/ # 站点的 HTML 模板 └── output/ # 生成的静态站点可以扔给任何 Hosting笔记文件名统一用YYYYMMDD_slug.md的格式日期开头后面跟一个全小写、用连字符连接的关键词。举例notes/ ├── 20241201_ideas.md ├── 20241208_meeting_notes.md ├── 20250103_reading_list.md └── 20250215_caveman_build.md为什么日期放最前因为文件管理器默认按文件名排序日期在前等于自动按时间排序我翻目录的时候就是一条时间线。而且将来做增量备份、归档按日期前缀切割都特别顺手。slug 全小写加连字符是为了让文件名天然对 URL 友好不会冒出一个My File (1).md这种链接里没法看的东西。2.2 不要数据库也不要 JSON 索引很多笔记工具喜欢用一个中心化数据库存元数据再加一个文件存正文。caveman 反着来所有信息都在文件自己身上正文在文件里元数据放在文件开头的 front matter 里。我对比过三种方案最终选了文件名加 front matter维度文件名 front matterSQLite独立 JSON 索引数据可读性文本直接打开需要客户端可读但不直观版本追踪git diff 友好二进制没法 diffdiff 一团乱损坏容错单篇坏不影响全局整库损坏后果严重索引损坏要重建人工维护基本为零需要工具偶尔要修核心逻辑是数据库一旦损坏整个知识库就变成了哑巴而一个纯文本文件坏了顶多损失这一篇而且通常还能用文本编辑器抢救回大半内容。对我这种把笔记当十年以上档案来写的人这种“笨”反而最可靠。front matter 我只留三个字段多一个都觉得多余--- title: caveman 渲染管线复盘 date: 2025-02-15 tags: 工具, python, 本地优先 ---标题就是给网页标题和列表用的日期决定排序tags 用逗号分隔后面构建时自动做聚合。那些“属性系统”“嵌套标签”“颜色标签”我全都不碰——它们解决的是企业知识库的问题不是个人笔记的问题。2.3 双链与断链检查做一半就够很多人离不开双链我也给 caveman 加了一个最小版本在正文里写[[目标笔记关键词]]构建的时候自动解析成指向对应 HTML 的链接。解析逻辑用一行正则就能说清楚WIKILINK re.compile(r\[\[([^\]|])(?:\|([^\]]))?\]\])命中之后把目标转成文件名规则检查对应 .md 是否存在。存在就生成正常链接不存在就生成一个带missing样式的链接并在构建结束时输出断链报告。这个机制的价值不在于画出一张关系图谱而在于让我知道哪些笔记是“承诺过要写还没写”的以及哪些链接已经指向不存在的文件了。双链这东西核心价值是引用不是动画。我宁可用最笨的字符串匹配把这件事跑通也不引入一整套图数据库。2.4 标签就一行聚合自己来标签系统是构建时顺带做的。解析 front matter 里的 tags 行按逗号切开维护一个标签到文章列表的映射最后批量生成output/tags/xxx.html页面。不搞层级不搞颜色不搞“智能推荐”。这样做的直接收益是写笔记的时候完全不需要维护标签结构想到什么写什么甚至留空也行构建时自动归到“未分类”。个人笔记的标签是搜索引擎不是分类法别把它做成第二份目录。3. 渲染管线一个脚本把 Markdown 变成可发布的站点3.1 为什么选 Python 标准库而不是 Hugo/Jekyll网上随便一搜都是 Hugo、Jekyll、Astro 这类静态站点生成器功能确实强但我发现它们的强是有代价的。每套都有自己的一套配置语言、主题结构、插件机制光是理解“如何写一个短代码”就要翻半天文档。caveman 选 Python 3 标准库加一个 markdown 解析库理由很朴素我的每台设备、每台服务器都自带 Python脚本短到可以直接读完全部代码改起来不存在“我不会写 Go 模板”这种障碍。在个人博客和笔记这个规模下构建性能根本不是瓶颈没必要为一个半年写一次的需求引入一整套工具链。当然如果你要做一个几百页的营销站点Hugo 依然是正确选择。caveman 的目标非常明确给自己用不加新知识就能发布页面。3.2 最小骨架核心代码也就这么长下面是我从 build.py 里抽出来的最小骨架能跑但不是完整版。完整版多加了标签聚合、RSS 生成、附件复制和时间排序主逻辑不超 180 行# build.py 的最小骨架 import glob import re import shutil from pathlib import Path import markdown NOTES Path(notes) ASSETS Path(assets) OUT Path(output) TEMPLATE Path(templates/base.html).read_text(encodingutf-8) WIKILINK re.compile(r\[\[([^\]|])(?:\|([^\]]))?\]\]) def strip_bom(text: str) - str: return text.lstrip(\ufeff) def parse_front_matter(raw: str): meta {title: , date: , tags: } if not raw.startswith(---): return meta, raw lines raw.split(\n) for i in range(1, len(lines)): if lines[i] ---: return meta, \n.join(lines[i 1 :]) if : in lines[i]: key, _, val lines[i].partition(:) meta[key.strip()] val.strip() return meta, def resolve_wikilinks(body: str) - str: def replace_link(m): target m.group(1).strip() title m.group(2).strip() if m.group(2) else target slug target.replace( , -).lower() page slug .html exists (NOTES / (slug .md)).exists() css if exists else classmissing return fa href{page}{css}{title}/a return WIKILINK.sub(replace_link, body) def render(title: str, content: str) - str: return TEMPLATE.replace({{ title }}, title).replace({{ content }}, content) def build(): shutil.rmtree(OUT, ignore_errorsTrue) (OUT / assets).mkdir(parentsTrue, exist_okTrue) for md_file in sorted(glob.glob(str(NOTES / *.md))): raw strip_bom(Path(md_file).read_text(encodingutf-8)) meta, body parse_front_matter(raw) body_html markdown.markdown( body, extensions[fenced_code, tables, footnotes] ) body_html resolve_wikilinks(body_html) page render(meta[title], body_html) Path(OUT / (Path(md_file).stem .html)).write_text(page, encodingutf-8)整个流程就是读文件、剥 BOM、解析头部、Markdown 转 HTML、解析双链、套模板、写文件。清楚到不需要注释就能看懂。3.3 模板与样式没有 JavaScript 的“洞穴壁画”模板就一个base.html占位符只有标题、正文、导航三处。我不用 Jinja2 那一套直接用字符串替换少一个依赖就少一层故障。!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title{{ title }} · caveman/title /head body main h1{{ title }}/h1 {{ content }} /main /body /htmlCSS 我手写了 40 行故意做成“洞穴壁画”风格米色背景、棕黑色文字、衬线字体、最大阅读宽度 720px、没有 JavaScript、没有动画。看着老土但换来的东西很实在任何浏览器打开都秒出打印干净得像打印一本书离线断网也能完整阅读。3.4 实测表现700 篇笔记只需要 0.4 秒我在 MacBook Air M1 上做了个简单测试笔记目录里有 700 篇 Markdown 文件外加 60 个图片附件。time ./build.py的实测结果real 0m0.384s user 0m0.301s sys 0m0.082s对比我用 Hugo 搭过的一个类似规模站点构建大概 0.3 秒左右差距基本体现在启动和模板编译上。结论很直接在个人知识库这个量级构建框架带来的性能优势毫无意义省下的那 0.1 秒不够弥补学习成本和依赖负担。4. 同步与备份没有云端的“云”——U盘都能救急4.1 几种本地优先同步方案的取舍本地优先不等于没有同步。caveman 的目录结构决定了同步方案可以很灵活我把常见的几种拉出来比了一遍方案实时性冲突处理需要服务器上手成本git Gitea手动 push / pull手动解决可选私有仓库中Syncthing准实时同时改会生成 conflict 文件不需要中rsync手动单向覆盖可选 NAS低U盘拷贝手动人工避免不需要极低我的最终组合是主力笔记本负责写入git 提交到私有仓库做版本回滚桌面机开 Syncthing 单向接收NAS 每天晚上用 rsync 拉一份完整目录移动端不加入同步只读访问发布出来的站点。四个角色各干各的互不打扰。4.2 “同一个时间只有一台设备负责写”这个规则是我用真实教训换来的。项目早期我在两台设备之间开了 Syncthing 双向同步一周后就看到了熟悉的conflicted copy文件两个版本的笔记同时存在你还得人工判断哪个是新的。更麻烦的是有时候你以为改了 A其实同步已经把 B 覆盖上去了。后来我定了一条死规矩同一时间只有一台设备负责写入其他设备一律只读。笔记不是多人协作的代码它不需要并发写。“准实时双设备编辑”听上去高级可对我这种一周写不了几篇的人来说它的价值远远抵不上冲突处理的成本。4.3 定时备份tar 一把梭加一点纪律我见过太多人把同步当备份这是很危险的习惯。同步会把误删也同步过去你这边删一个文件夹NAS 那边跟着清空等你反应过来已经晚了。我自己就干过这事一次误删了整个 assets 目录双向同步把另一台机器上的副本也带没了最后靠七天前的 tar 包才抢救回来。所以 caveman 的备份策略非常简单直接就是一个每天跑的定时脚本#!/usr/bin/env bash # backup_notes.sh —— 放进 cron 每天凌晨执行 BACKUP_ROOT$HOME/backups/caveman mkdir -p $BACKUP_ROOT # 每天打一个完整快照 tar czf $BACKUP_ROOT/notes_$(date %Y%m%d).tar.gz -C $HOME caveman # 只保留最近 14 天 find $BACKUP_ROOT -name notes_*.tar.gz -mtime 14 -delete不搞增量不搞去重每天一个完整快照保留两周。笔记目录撑死几百 MB全量压缩也就几分钟的事省心比省空间重要得多。4.4 移动端不开发 App一个 SFTP 就够移动端是很多人离不开主流笔记软件的原因但我认真想过之后决定不为 caveman 开发任何 App。原因很简单App 是复杂度的大头而我的移动端使用频率极低。真要写手机上用能连 SFTP 的编辑器直接改文件想读把 output 目录发布到一个静态托管手机浏览器随时看。因为是纯文本任何支持 Markdown 的编辑器都能打开根本不挑工具。等到哪天真需要移动端了我大概率会先调整生活方式而不是加代码。5. 踩过的坑中文搜索、路径空格和文件编码5.1 中文检索别再被“分词”唬住做搜索之前我认真调研过 jieba、倒排索引、Whoosh 这些方案差点就把搜索模块写成一个小型搜索引擎。后来我停下来问了自己一个问题我到底需要什么样的搜索个人笔记的搜索绝大多数是精确关键词匹配。搜“季度计划”我要的是含这个词的文章不是语义相近的文章。caveman 直接对 UTF-8 文本做子串匹配用 ripgrep 跑一遍rg 季度计划 notes --json700 篇笔记全部扫一遍返回结果基本在 0.05 秒以内。中文在这里不需要分词因为子串匹配天然就能命中顶多多配几个不相关的结果手动扫一眼就筛掉了。至于语义搜索、AI 问答那是另一个量级的需求。等我的笔记多到需要它的时候再考虑而不是现在提前造轮子。5.2 文件名规范下划线、全小写、禁 emoji这是整个项目里最有味道的一组坑。第一个坑是空格。一开始懒文件名写成20250103 会议记录.md空格在 Shell 里被当分隔符在 URL 里要转成%20在 markdown 链接里更是各种截断。后来统一把空格换成了下划线。第二个坑是 emoji。我在 macOS 上给一篇笔记起过20250104 拉面清单.md结果发现 macOS 的文件系统会把 emoji 存成 NFD 规范分解形式Linux 上读出来的名字却不一样同步过去直接变成“文件不存在”。这种问题极难排查最后只能手动重命名。教训就是文件名里绝不用 emoji再可爱也不用。第三个坑是大小写。macOS 默认的文件系统大小写不敏感Linux 严格大小写敏感。我在 Mac 上写[[Readme]]构建出来链接指向readme.html在本机一切正常推到 Linux 服务器上就断链。后来 caveman 内部统一约定所有链接目标先转小写再去匹配文件。你再想在文件名里玩驼峰先过一遍这个约定再说。迁移脚本也很朴素一句循环搞定大半问题cd notes for f in *.md; do nf$(echo $f | tr [:upper:] [:lower:] | tr _) [ $f ! $nf ] mv $f $nf done跑完记得重新构建一次把断链报告里那些死活匹配不上的链接手工处理掉。5.3 UTF-8 BOM 与编辑器的“自动修正”Windows 记事本和不少国产编辑器保存 UTF-8 文件时会偷偷加一个 BOM 头也就是在文件最前面塞一个\ufeff字符。这个字符肉眼看不见但会让 front matter 解析直接翻车——第一个字段的值会变成\ufefftitle生成的页面标题前面挂一个乱码符号。解决方式是在读文件的时候剥掉它一行代码的事raw Path(md_file).read_text(encodingutf-8).lstrip(\ufeff)另一个更隐蔽的坑是编辑器的“自动修正引号”。有些编辑器会把英文引号自动替换成中文引号听起来贴心但你在代码块里写 Python 字符串的时候这个替换会直接改坏你的代码。我的建议是写 Markdown 的编辑器别开任何自动格式化功能包括自动转换引号、自动补全括号、自动缩进。Markdown 是纯文本不是 IDE 项目。5.4 把断链报告当成构建的一部分构建结束时的断链报告是 caveman 最有用的“售后提醒”。我每次跑完 build.py都会看到类似这样的输出[warning] 2 unresolved wikilinks: notes/20250103_meeting.md - [[季度计划]] notes/20241210_idea.md - [[API 设计 v2]]处理逻辑很简单链接指向的内容确实存在但文件名不匹配改链接里的名字内容还没写要么现在补一篇要么把链接删掉。这种强制暴露问题的设计比静默生成一堆 404 链接好太多了。这也是我坚持把断链检查写进构建流程而不是做成一个“高级功能”的原因——巡检要么是默认动作要么迟早不会去点。6. 什么人不该用 caveman边界与坦诚6.1 先看清自己的场景写完这篇项目复盘我得诚实地说caveman 不适合所有人。它的设计前提是“一个人、一台主力设备、文本为主、长期积累”。不符合这个前提的场景选择它会很痛苦。使用场景是否适合原因个人长期档案、读书笔记强烈推荐格式开放、十年后还能打开团队协作知识库不合适没有权限、并发编辑机制重富文本内容复杂表格、画布不合适图片和排版管理很原始快速灵感捕捉推荐一条命令开始写零干扰科研笔记、含公式可以markdown 库支持公式扩展开箱即用的“现代工具”需求不合适它要求你先适应命令行6.2 和 Obsidian、Notion 这类工具放在一起看我不打算把 caveman 包装成“XX 工具替代品”因为论功能它确实打不过。但你可以从数据格式这个维度重新审视这些工具工具数据格式离线可用同步方式学习成本扩展性ObsidianMarkdown 插件数据好靠你自己中高但插件复杂度随之上涨Notion私有格式差官方云低高但导出想保住格式不容易JoplinMarkdown 数据库好官方/自建云中中纯文件夹 grepMarkdown很好靠你自己极低极低cavemanMarkdown很好靠你自己低按需加克制Obsidian 的双链生态确实强Notion 的数据库视图我也没法替代。但我真正高频的需求只有写和找两件事在这两件事上一个目录加一个渲染脚本已经做到足够好了。6.3 我用下来的边界工具越小越敢长跑项目用了八个月我新增了四百多篇笔记发布了两百多篇博客中途没有因为工具本身折腾过一次。我把构建命令挂进了 git hook每次提交自动生成站点并推送到静态托管整个过程已经自然到感觉不到它的存在。我自己定的维护节奏是每天想记就记不管格式每周抽十分钟跑一次构建顺手看一遍断链报告处理掉几个待创建链接每个月看一次备份是否正常。这种节奏下工具本身几乎从我的意识里消失了剩下的只有内容本身。这大概就是“工具越小越敢长跑”的真正含义。最后再分享一个小技巧我在 shell 配置里放了一行别名想记点什么的时候直接敲newnote它会用当天日期生成一个空白笔记并打开编辑。写完顺手跑./build.py站点就更新了。工具做得越简单你越不会惦记工具本身最后剩下的只有你真正想记录的事。这大概就是 caveman 想回到的原始状态。