最近我花了一个周末,用纯 Python 标准库写了一个叫caveman的命令行笔记工具。这个名字起得很诚实——它就是要让你像个穴居人一样管理笔记:不建数据库、不整云端、不搞复杂的双向链接,只靠一堆 Markdown 文件和几个朴素命令过日子。做完之后试用了两周,出乎意料地顺手。这篇博文就把这个项目的完整设计思路、核心代码和踩坑过程全部摊开,从为什么做、怎么做到如何用得舒服,一次性讲清楚。如果你也想做类似的极简工具,或者正被功能繁重的笔记软件折腾得够呛,这篇内容值得花几分钟看完。
1. 为什么要做一个“原始人”笔记工具
先说动机。市面上主流的笔记软件我都用过几轮:有的界面漂亮,但启动就要几秒;有的插件生态强大,可一旦格式升级,老笔记直接变成乱码场;有的号称云端同步,结果一次网络冲突就丢了我两周的记录。最让我难受的不是功能少,而是数据被锁在一个不透明的黑盒里。你想把自己写的东西导出来,要么导成残缺的 HTML,要么得到一堆谁也打不开的私有数据库文件。
笔记这个场景,说来说去核心就三件事:快速记录、事后能搜、长期不丢。大部分工具把这三点做得不错,但代价是巨大的运行时、复杂的文件格式和不可控的数据迁移路径。于是我开始认真思考一个极端方案:干脆不要数据库,不要独立 App,让每一篇笔记就是一个普通的 Markdown 文件,直接躺在文件系统里。想读的时候用任何文本编辑器打开,想备份的时候直接复制目录,想同步时甚至可以交给 Git 管理。
caveman 就是在这种思路下诞生的。它只依赖 Python 3.6 以上版本自带的标准库,没有第三方包,没有后台服务,没有配置文件。你在终端敲几个命令,它负责把文字变成一个个 .md 文件,仅此而已。项目的“原始感”正是它的卖点:回到一切透明的石器时代,用最简单的方式保住笔记这个基本需求。
1.1 重笔记工具的核心问题
不是所有笔记软件都不好,而是它们的“重”和笔记行为不匹配。我们大多数人每天产生的笔记量,撑死几十条文本,却要为一个几百 MB 的应用长期买单。更麻烦的是,很多工具把笔记内容藏在缓存目录、数据库 blob 或者私有云存储里。你感觉自己是主人,可一旦应用崩溃、账号被封或者厂商改版,你的数字记忆就悬了。
还有一种痛点是“过度设计”。标签体系、双向链接、模板引擎、图表嵌入、看板视图,这些功能听起来强大,但实际使用频率并不高。功能越多,界面越复杂,记录的摩擦就越大。我想记录一句话,却要先建一个文档、选一个分类、填一堆属性。久而久之,记录这个动作本身变成了负担,反而不如直接打开备忘录敲两下。
caveman 的立场很明确:拒绝过度组织。你不需要在建笔记前想清楚它属于哪个项目、该打什么标签,你只需要写下来。分类的事交给目录,搜索的事交给命令,一切都发生在毫秒级。它故意做得“不够聪明”,就是为了不打扰你的记录过程。
1.2 纯文本优先与零依赖的思路
纯文本优先不是技术洁癖,而是长期主义。一个 .md 文件,十年前能打开,二十年后大概率也能打开。它不依赖任何特定厂商的渲染引擎,不绑定任何操作系统的私有格式。相比之下,一个被深度编码进数据库的笔记,未来的打开方式完全取决于软件厂商是否继续维护。
零依赖则保证了工具随处可跑。在一台刚装好系统的机器上,只要 Python 解释器存在,整个 caveman 脚本复制过去就能用。不需要 pip install,不需要虚拟环境,不需要网络。这种“拷贝即用”的特性,对需要在多台机器间流动的人来说非常实用。你甚至可以把它放进 U 盘,当作一个随身笔记管理员。
2. caveman 的存储约定:目录结构与 frontmatter
工具再简单,也得有稳定的规则。caveman 只有一套文件组织约定,搞定这套约定,你就能完全掌控自己的笔记库。
2.1 按日期分层的目录结构
caveman 的笔记存放在用户主目录下的~/.caveman/notes/中,内部按“年 / 月 / 日”分层。实际结构长这样:
~/.caveman/ notes/ 2024/ 05/ 12/ 20240512-221530-买菜.md 13/ 20240513-092011-项目启动.md 09/ 01/ 20240901-183300-周计划.md文件名统一为YYYYMMDD-HHMMSS-标题.md。这样设计有几个很现实的好处:第一,文件在磁盘上的顺序天然就是时间顺序,不需要额外维护索引字段;第二,就算 frontmatter 损坏,文件名本身仍然保留了“什么时候记的”这个关键信息;第三,按目录浏览时,你会看到清晰的时间线,而不是一长串无意义的 UUID。
生成文件名时,标题会经过一次“清理”,把不适合做文件名的字符替换成减号。比如标题“买菜/清单”会变成“买菜-清单”,最终文件名是20240512-221530-买菜-清单.md。这里要特别注意,Windows 文件系统不允许文件名包含\/:*?"<>|,所以清理过程不是可选项,而是必须项。我会在后面的编码小节里专门讲这个坑。
2.2 frontmatter:用最直白的方式给笔记加元信息
每篇笔记的顶部有一段简单的 frontmatter,格式如下:
--- title: 买菜 tags: [生活, 清单] created: 2024-05-12 22:15:30 --- 牛奶、鸡蛋、面包、卷纸。三行信息含义很直白:title 是标题,tags 是标签列表,created 是创建时间。为什么不直接用文件系统的时间戳?因为文件一旦被复制、移动、从 Git 检出,mtime 就变了,只有把时间写进文件内部才可靠。frontmatter 放在文件头部,即使不用工具,直接用文本编辑器打开这份笔记,你也能一眼看出这些元信息。
解析 frontmatter 不需要引入 YAML 库。因为格式自己说了算,只处理三行就够:
def read_frontmatter(path): title = path.stem created = "" try: lines = path.read_text(encoding="utf-8").splitlines() except UnicodeDecodeError: return title, created if lines and lines[0].strip() == "---": for line in lines[1:]: if line.strip() == "---": break if line.startswith("title:"): title = line.split(":", 1)[1].strip() elif line.startswith("created:"): created = line.split(":", 1)[1].strip() return title, created这段代码没有用到任何第三方解析器,核心逻辑就是读前几行、按冒号切分。够用,也足够稳固。这里还想强调一点:caveman 的标签不是强约束,它只是 frontmatter 里一个可选项。你完全可以一篇笔记都不打标签,全凭 search 命令靠全文检索找内容。
3. caveman 核心代码:命令分发与增删查改
caveman 的命令行面只有五个子命令:add、list、show、search、tag。其中 tag 本质上也是一种搜索,只是入口更聚焦。下面我按实现顺序逐个拆。
3.1 手写命令行解析
很多教程一上来就推荐 argparse,但 caveman 刻意不用它。理由有两个:一是命令形态太简单,手写解析反而更轻;二是手写能完全控制错误提示,让“原始”风格贯穿始终。入口函数大致是:
import sys import re from pathlib import Path from datetime import datetime BASE = Path.home() / ".caveman" NOTES_DIR = BASE / "notes" def main(): args = sys.argv[1:] if not args: print("usage: caveman <add|list|show|search|tag> [args]") return cmd = args[0] if cmd == "add": cmd_add(args[1:]) elif cmd == "list": cmd_list() elif cmd == "show": cmd_show(args[1:]) elif cmd == "search": cmd_search(args[1:]) elif cmd == "tag": cmd_tag(args[1:]) else: print("unknown command:", cmd) print("usage: caveman <add|list|show|search|tag> [args]")如果未来命令数量变多,这个字典式分发会膨胀,但就目前五个子命令来说,这种写法最直白。你读代码的时候一眼就知道每个命令对应哪个函数,没有任何隐藏的魔法。
3.2 add:从输入到 Markdown 文件
add的命令格式是:
caveman add "标题" "正文内容" --tags 生活,清单标题和正文之间用空格分隔,可选参数--tags指定标签。因为没引入 argparse,参数解析要自己处理:
def cmd_add(args): if len(args) < 2: print("usage: caveman add <title> <body> [--tags a,b]") return tags = [] if "--tags" in args: idx = args.index("--tags") if idx + 1 < len(args): tags = [t.strip() for t in args[idx + 1].split(",") if t.strip()] args = args[:idx] + args[idx + 2:] title = args[0] body = " ".join(args[1:]) now = datetime.now() title_slug = re.sub(r"[^\w\u4e00-\u9fff]+", "-", title).strip("-") filename = now.strftime("%Y%m%d-%H%M%S") + "-" + title_slug + ".md" dirpath = NOTES_DIR / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}" dirpath.mkdir(parents=True, exist_ok=True) filepath = dirpath / filename created = now.strftime("%Y-%m-%d %H:%M:%S") tag_str = ", ".join(tags) content = f"""--- title: {title} tags: [{tag_str}] created: {created} --- {body} """ filepath.write_text(content, encoding="utf-8") print("saved:", filepath)几个细节值得说明。f"{now.month:02d}"这种格式化是为了让月份和日期始终是两位数,避免目录排序时出现5/12排在10/15后面的情况。标签存入 frontmatter 时,我统一转成了[生活, 清单]这种列表形式,方便后面 tag 命令解析。写入文件时必须显式指定encoding="utf-8",否则在中文 Windows 环境下大概率会用 GBK 写文件,后续用 UTF-8 读就会乱码,这个问题我会在踩坑章节展开。
3.3 list/show:时间倒序浏览与内容回看
list命令负责遍历整个 notes 目录,按时间倒序列出所有笔记:
def cmd_list(): files = list(NOTES_DIR.rglob("*.md")) files.sort(key=lambda p: p.name, reverse=True) for idx, p in enumerate(files, 1): title, created = read_frontmatter(p) rel = p.relative_to(NOTES_DIR) print(f"[{idx}] {created or p.name[:15]} {title} {rel}")排序时没有直接用路径,而是按p.name排序。因为目录层级是年月日,路径字符串排序在跨年跨月时不一定靠得住,文件名前缀本身就是完整的时间戳,按它排序绝对可靠。enumerate生成的序号可以直接交给show命令使用,比如caveman show 1就是查看最新一篇笔记。
show命令的逻辑很宽松:如果参数是纯数字,就当作序号;否则当作相对路径:
def cmd_show(args): if not args: print("usage: caveman show <number|relative-path>") return target = args[0] if target.isdigit(): files = list(NOTES_DIR.rglob("*.md")) files.sort(key=lambda p: p.name, reverse=True) idx = int(target) - 1 if idx < 0 or idx >= len(files): print("no note at number:", target) return path = files[idx] else: path = NOTES_DIR / target if not path.exists(): matches = list(NOTES_DIR.rglob(target)) if not matches: print("not found:", target) return path = matches[0] print(path.read_text(encoding="utf-8"))这里的“宽容设计”是为了减少记忆负担。你不必记住笔记的完整路径,只要记得大概的标题关键词,搜一下,看列表里的序号,然后 show 一个数字就够了。
3.4 search/tag:让笔记重新被找到
search是全文检索,遍历每个文件找关键字:
def cmd_search(args): if not args: print("usage: caveman search <keyword>") return keyword = " ".join(args) files = list(NOTES_DIR.rglob("*.md")) files.sort(key=lambda p: p.name, reverse=True) for p in files: text = p.read_text(encoding="utf-8", errors="ignore") if keyword.lower() in text.lower(): title, created = read_frontmatter(p) rel = p.relative_to(NOTES_DIR) print(f"{created or p.name[:15]} {title} {rel}")实现是全量扫描,没有索引。对几千篇文本笔记来说,扫描一遍也就是几十毫秒的事。这里有个取舍:如果笔记量增长到数万篇,全量扫描开始变慢,但那时候依然有办法——直接调用 ripgrep 这种高效外部工具,或者干脆升级到 SQLite。caveman 作为极简工具,不提前做复杂度管理,等到真遇到瓶颈再说也不迟。
tag命令则是在 frontmatter 中找标签:
def cmd_tag(args): if not args: print("usage: caveman tag <tag-name>") return tag = args[0].strip().lstrip("#") files = list(NOTES_DIR.rglob("*.md")) files.sort(key=lambda p: p.name, reverse=True) for p in files: text = p.read_text(encoding="utf-8", errors="ignore") if f"tags: [{tag}" in text or f"tags: [{tag}," in text: title, created = read_frontmatter(p) rel = p.relative_to(NOTES_DIR) print(f"{created or p.name[:15]} {title} {rel}")实际用下来,tag 命令用得非常少。我发现自己更依赖 search,因为脑中记住的往往是几个零散关键词,而不是一套精心维护的标签体系。但保留 tag 命令也有价值,它可以快速回答“我打上‘工作’标签的都有什么”这类问题。
4. 踩坑记录:排序、编码与性能边界
任何工具真正做完,都是从坑里爬出来的。caveman 看起来简单,但几个问题不处理,日常使用会非常膈应。
4.1 Rglob 序列不可靠:排序到底该按什么
第一个坑来自Path.rglob。它的返回顺序本质上取决于底层文件系统的遍历顺序,在 ext4 和 NTFS 上结果完全不同,而且常常不是理想的时间顺序。我最初的实现是sorted(NOTES_DIR.rglob("*.md"), reverse=True),以为路径字符串排序能恰好像时间倒序。结果在跨日、跨月目录一多,就开始出现 9 月 1 日的笔记排在 10 月 2 日之前的情况。
原因很好理解:路径排序比较的是字符串,比如2024/09/01/和2024/10/02/比较时,逐字符比较到"0"和"1",因为"0" < "1",所以 9 月会被排到 10 月前面,这看起来是对的;但如果是2024/5/1这种一位数的月日,排序就会彻底乱掉。所以根本解法是按文件名排序,因为文件名前缀YYYYMMDD-HHMMSS摆脱了目录层级的干扰,是稳定可靠的时间表达。
files.sort(key=lambda p: p.name, reverse=True)就这一行,解决了我头大的排序问题。
4.2 中文与 Windows 编码:write_text 的隐性 bug
第二个坑非常隐蔽。Python 的Path.write_text如果不指定编码参数,在 Linux 上默认 UTF-8,但在中文 Windows 上默认可能是 GBK。caveman 写文件时生成的内容包含中文标题和正文,如果默认 GBK 写入,等你在另一台机器或者现代编辑器里用 UTF-8 打开,就会看到一片乱码,而且乱码不可逆,只能重新录入。
更麻烦的是 filename 里的中文。re.sub(r"[^\w\u4e00-\u9fff]+", "-", title)同时保留中文字符与英文字母数字。这里的\u4e00-\u9fff就是给中文留的专属空间。在 Windows 下,文件名末尾的点和空格会被系统静默删除,如果标题清理后变成纯英文,恰好生成了类似xxx.的文件名,就会触发这个行为。因此我加了一步.strip("-"),并且避免用.作为文件名结尾。
我的教训是要在代码里形成肌肉记忆:凡是往磁盘写文本,一律显式encoding="utf-8";凡是用户输入的中文,一律考虑 Windows 文件名兼容。不要指望环境默认值,默认值才是最大的坑。
4.3 caveman 不适合做什么
工具要说明自己能做什么,也要诚实地说明自己不能做什么。caveman 不适合需要多人实时协作的笔记场景,它没有冲突处理机制,两个人同时改一个文件,后写的人会覆盖先写的内容。它也不适合重度多媒体笔记,你不可能把一张大图片塞进 Markdown 文件还指望工具帮你管理。如果你需要富文本、表格渲染、PDF 批注这些东西,caveman 满足不了你。
性能边界大概是单目录一万篇纯文本笔记以内。超过这个量,list时需要更聪明地扫描,比如用修改时间做增量缓存,或者干脆引入一个简单的索引文件。但绝大多数个人笔记场景根本摸不到这个上限,所以我不打算在 v0.1 版本里提前优化。
5. 让 caveman 融入日常:别名、编辑器与 Git 备份
写完核心代码只是第一步,真正让工具好用的是周边配套。我花了不少时间研究怎么把它嵌入到自己已有的工作流里,下面这几个改动带来的价值远远超过了工具本身。
5.1 终端别名:把记录变成零成本操作
在~/.bashrc或~/.zshrc里加这几行:
alias n='caveman add' alias nl='caveman list' alias ns='caveman search' alias nt='caveman tag' alias n1='caveman show 1'加完之后,记录一条笔记就变成了n "想法标题" "想法正文"。在这个状态下,记录几乎没有摩擦。我实测过,从冒出一个念头到按下回车,整个过程不超过五秒。这种速度足够和手机备忘录抗衡,而且内容最终落在自己可控的纯文本文件里。
5.2 编辑器集成:像写日志一样写笔记
命令行直接写正文的体验毕竟有限,遇到长内容还是需要编辑器。caveman 本身可以保持简单,但我在 shell 层做了一个小函数:
nedit() { latest=$(ls -t ~/.caveman/notes/**/*.md 2>/dev/null | head -1) ${EDITOR:-vim} "$latest" }这个函数会找到最新一篇笔记并用默认编辑器打开,保存后直接回到命令行。对于写日志类的场景非常舒服:caveman add "日志" "今天开始记录",然后nedit就能立刻续写。如果你更喜欢创建新笔记时直接进入编辑,也可以在cmd_add检测到正文为空时自动调用$VISUAL,这个就留给个人定制了。
5.3 Git 版本库备份:给笔记上保险
纯文本目录与 Git 是天然搭档。我直接把~/.caveman/notes做成一个 Git 仓库,然后写了一个提交脚本:
cd ~/.caveman/notes && git add -A && git commit -m "backup $(date +%F_%T)" --quiet配合 cron 或者简单的手动执行,就可以实现笔记的版本管理。好处是巨大的:误删内容可以恢复,每次修改都留下痕迹,而且完全不需要第三方云服务。你可以在自己的 U 盘、家里的 NAS 上添加一个远程仓库,实现多机同步而不泄露隐私。
实际使用中我还发现,把笔记库放进 Git 之后,心理负担小了很多。以前用某款笔记软件,总怕它同步卡死甚至丢数据;现在我可以随时回滚到任意一天,安心感完全不一样。这也正是 caveman 想解决的问题——不是功能多,而是数据稳、心里踏实。
最后再分享一点个人体会。写这个工具之前,我以为笔记效率的瓶颈在于工具不够智能,做完之后才意识到,真正的瓶颈是“记录这个动作太重了”。caveman 用最朴素的方式把重量降到最低,反而让我更愿意记录。有时候原始的设计,才最接近本质。你自己写的工具也一样,先跑通一个最小可用版本,然后不断打磨,它就会慢慢变成真正适合你的形状。