把项目叫 caveman,其实是给自己立的一条规矩:写代码别太花哨。
这是一个零依赖的命令行笔记工具,纯 Python 标准库实现,用来管理每天的工作日志和碎片想法。没有框架、没有第三方包、不需要 npm install,一个脚本丢到任何装得上 Python 3.8+ 的机器上就能跑。名字听起来很原始,做的事情也确实原始——只在终端里做四件事:新建笔记、按日期查看、全文搜索、字数统计。数据落在纯 Markdown 文件里,一个日期一个文件,任何编辑器都能直接打开。
之所以自己造这个轮子,是因为我在本地折腾过一圈笔记方案。应用商店里的笔记软件越做越重,启动要等转圈,同步要登录账号,格式还锁得死死;命令行方案我也试过几个热门的,功能确实强大,可光配置文件就够我研究半天。最后我悟了:我对笔记工具的真实需求就四个字,快、稳、存、搜。于是就有了 caveman。这篇文章就把整个项目的设计思路、核心实现、踩坑记录完整拆开讲,适合想自己动手写命令行小工具的开发者,也适合想摆脱"工具选择困难症"、回归纯文本记录的人。
1. 先想明白:caveman 到底该做什么
1.1 一句话定位
caveman 是一个单文件、零依赖的 Markdown 日记/笔记命令行工具。核心命令只有四个:new追加当天笔记,list按日期列出笔记,search全文搜索,stats统计字数。整个项目就是一个caveman.py,加上一份 README 和一个可选的 shell 补全脚本,没有数据库、没有配置文件、没有插件系统。
有人可能会问,这跟直接打开一个文本文件敲字有什么区别?区别在于:它把"打开文件、定位到正确位置、追加内容、格式化时间戳"这一串重复劳动变成了一个命令。你只需要敲caveman new 今天把登录模块重构完了,剩下的交给程序。这种工具的价值不在功能丰富,而在把使用成本压到最低,低到你愿意每天用它。
1.2 为什么取名 caveman 而不是 notes 或 diary
选这个名字有两层意思。第一层是调侃自己"原始人式编程"——不引包、不做复杂抽象、不做配置化,所有逻辑平铺直叙,像原始人拿石头砸坚果一样直接。现在很多项目的问题恰恰是太不原始了:为了一个简单的功能引入一整套框架,最后光摸清依赖关系就要花一天。caveman 故意把姿态放低。第二层是提醒自己别手痒:名字都叫原始人了,就别给它加什么插件机制、云同步、标签体系了。
这个命名还有一个实际好处:在终端里敲caveman四个字母非常顺手,不会跟其他命令冲突。我查过,系统里没有同名高频命令,包管理器里撞名的概率也低。工具命名这件事看起来很随意,其实很影响使用频率——命令越长,你越不想打。
1.3 适合谁用、谁不建议用
先泼一盆冷水。如果你需要的是多人协作的团队知识库、富文本排版、图片附件管理、跨设备实时同步,caveman 完全不适合你。这些需求超出它的边界,硬要加进去只会把项目变成一个四不像。
适合用的人有三类。第一类是常年在终端里工作的开发者,写代码间隙随手记一笔"待办""想法""bug 线索",零切换成本。第二类是刚开始学 Python、想做点真实项目练手的人,caveman 的代码量在一个周末能读完,但麻雀虽小五脏俱全,参数解析、文件 IO、编码处理、测试思路都有。第三类是厌倦了各种笔记格式绑架、想把数据主动权拿回自己手里的人。我自己就是第三类。
2. 设计取舍:零依赖不是炫技,而是可维护性
2.1 为什么坚持零依赖:三个理由加一个教训
第一个理由是交付简单。单个.py文件拷走即用,不需要处理 virtualenv、requirements.txt、lock 文件,也不需要担心目标机器上有没有装某个第三方库。第二个理由是审计容易。全部代码就几千行,任何一行有问题翻两下就能找到。第三个理由是升级成本为零。Python 标准库的接口稳定得可怕,pathlib、argparse、datetime这些模块很多年都没有破坏性变更,不会因为某个第三方包弃坑而被迫迁移。
这三条理由听着像说教,但我是真吃过亏的。以前维护过一个稍微大点的脚本,用了某流行日期处理库,三年后它大版本 API 改动,我花了两整天改调用代码。那个项目用的核心功能其实就两三个日期格式化函数,标准库完全干得了。从那以后我给自己定了个规矩:新项目的第一版,一律只用标准库,只有当标准库确实痛到不行了,再考虑引入外部依赖。caveman 就是这个规矩的产物。
当然,零依赖也有代价。搜索高亮、ANSI 颜色处理、跨平台编码检测这些在第三方库里一个函数搞定的事,在这里都得自己写。但这恰恰是这个项目最大的乐趣——把轮子重新造一遍,你才知道轮子为什么长这样。比如 ANSI 转义序列为什么要在非交互输出时去掉,不理解 isatty 的人永远想不到。
2.2 功能边界:只做四件事,其他的一律砍掉
新增、查看、搜索、统计。为什么只有四件事?因为每多一个功能,维护面就呈非线性增长。举一个具体例子:标签功能。听起来简单,就是给笔记打个#工作标而已。但一旦引入,搜索语法要考虑"按标签搜"还是"按全文搜",统计口径要区分标签,文件内部要定标签的存放格式,老笔记还要写迁移脚本。我在 v0.2 版本里加过标签,用了一个月就砍掉了。实际使用下来发现:我需要按项目找笔记时,直接caveman search 项目名就够用了,标签纯属多余。
所以 caveman 的设计原则很明确:如果一个需求能用现有功能组合出来,就绝不新增命令。比如"看上周的笔记"用什么?用list加一个--range参数就够了,不需要单独的 calendar 命令。再比如"统计我这周写了多少字",stats支持组合时间参数也能覆盖。功能少的好处是:每个命令我都敢拍胸脯说它稳定,因为每次改动的影响面是可控的。
2.3 存储格式:一个日期一个 Markdown 文件,为什么不用 SQLite
数据格式决定了工具的生死。我选"一个日期一个 Markdown 文件"的方案,有三条核心理由。
第一,可读性。数据离开了程序还是人眼能读的文本,即使 caveman 某天坏了、丢了、被我删了,笔记本身依然在。第二,diff 友好。文本文件可以用git diff看每次记录的变化,这是任何数据库都做不到的。第三,可组合性。系统级的grep、rg、备份工具、同步工具都能直接处理这些文件。
有人会问,SQLite 不也是 Python 标准库吗?用 SQLite 不是更利于搜索?技术上确实如此,SQLite 搜索性能吊打逐文件扫描。我最终放弃它还是因为"可读性"这条第一原则:个人日记的数据量撑死在几千个文件以内,纯文件扫描的速度完全够用,何必给日记工具引入一个数据库状态。另外一个细节是文件命名必须排序友好。我采用YYYY-MM-DD.md的格式,因为字符串排序结果正好等于时间排序,这让list命令的实现省了一大截逻辑,也让用户在文件管理器里按名称排序时看到的就是时间顺序。
3. 核心实现:用标准库把四件事做扎实
3.1 命令入口与参数解析:用 argparse 而不是手写 sys.argv
argparse 是标准库里最成熟的命令行解析器,caveman 用它来定义子命令。这里说明一下为什么不手写sys.argv:早期原型里我确实手写过,但命令一多,参数校验就变成一堆 if-else,还要自己处理--help,得不偿失。argparse 免费送你自动生成的帮助文本、错误提示、子命令聚合,又不会引入任何外部依赖。
import argparse def build_parser(): parser = argparse.ArgumentParser( prog="caveman", description="a zero-dependency markdown journal tool", ) sub = parser.add_subparsers(dest="command", required=True) p_new = sub.add_parser("new", help="append a note to today's file") p_new.add_argument("text", nargs="*", help="note content; empty enters stdin mode") p_new.add_argument("-t", "--tag", action="append", help="add a tag (repeatable)") p_list = sub.add_parser("list", help="list notes by date") p_list.add_argument("--date", help="specific date, e.g. 2026-01-08") p_list.add_argument("--range", nargs=2, metavar=("FROM", "TO"), help="date range") p_search = sub.add_parser("search", help="plain text search") p_search.add_argument("keyword", help="keyword to search") p_search.add_argument("--date", help="search within one date only") p_stats = sub.add_parser("stats", help="word count stats") p_stats.add_argument("--date", default="all", help="specific date or 'all'") return parser几个容易忽略的细节。required=True强制要求必须提供子命令,防止你手滑只敲一个caveman得到一堆晦涩报错。nargs="*"允许一次传多个词,在 shell 里会被拼成一个列表。文本参数我设计成位置参数而不是-m这种选项,因为你每次使用都要打它,位置靠前能少敲几个字符。别小看这几个字符——命令行工具的使用频率和敲键盘的成本是直接挂钩的。
3.2 新建与追加:处理好 stdin 多行模式和文件结构
new命令有两个输入路径:直接传文本,或者不带文本进入 stdin 逐行输入模式。后者适合在终端里粘贴一段较长的内容。
from pathlib import Path from datetime import date, datetime def note_path(base: Path, d: date) -> Path: return base / f"{d.isoformat()}.md" def cmd_new(args): root = Path.home() / "caveman-notes" root.mkdir(parents=True, exist_ok=True) today = date.today() path = note_path(root, today) if args.text: content = " ".join(args.text) if args.tag: content = " ".join(f"#{t}" for t in args.tag) + "\n" + content else: print("input note (Ctrl-D on empty line to finish):") lines = [] while True: try: line = input() except EOFError: break lines.append(line) content = "\n".join(lines).strip() lines = [] if path.exists(): lines = path.read_text(encoding="utf-8").rstrip("\n").split("\n") timestamp = datetime.now().strftime("%H:%M") lines.append(f"\n## {timestamp}") lines.append(content) path.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"appended to {path}")这里有几个实操出来的教训。第一,读文件写文件必须显式传encoding="utf-8",绝对不能依赖系统默认编码,否则在 Windows 上稳挂。第二,追加内容时我没有用open(..., "a")直接追加,而是先读整个文件再整体写回。为什么?因为追加模式下你很难控制空行结构,很容易出现两条记录之间挤了三四个空行的情况。读改写虽然多了一点开销,但每次都能确保文件格式是整洁统一的。第三,每条记录用## 时间作为二级标题,这样 Markdown 渲染出来分段清晰,同时搜索时能一眼定位时间。
3.3 查看与搜索:朴素实现,把 grep 的习惯搬进来
list命令的核心逻辑就是按文件名排序,然后逐个读取并输出。日期范围过滤本质上就是比较文件名这两段 ISO 字符串,因为YYYY-MM-DD的字典序就是时间序。
def cmd_list(args): root = Path.home() / "caveman-notes" files = sorted(root.glob("*.md")) if args.date: files = [root / f"{args.date}.md" if (root / f"{args.date}.md").exists()] elif args.range: lo, hi = args.range files = [f for f in files if lo <= f.stem <= hi] for f in files: print(f"\n### {f.stem}") print(f.read_text(encoding="utf-8"))搜索命令采用最朴素的实现:遍历所有.md文件,逐行做子串匹配。数据量小,暴力方案就是最优方案。
def cmd_search(args): root = Path.home() / "caveman-notes" keyword = args.keyword.lower() hits = [] files = sorted(root.glob("*.md")) if args.date: target = root / f"{args.date}.md" files = [target] if target.exists() else [] for f in files: for i, line in enumerate(f.read_text(encoding="utf-8").split("\n"), 1): if keyword in line.lower(): hits.append((f.name, i, line.strip())) for name, lineno, line in hits: print(f"{name}:{lineno}: {line}") print(f"\n{len(hits)} hit(s)")搜索结果的输出格式我刻意模仿了 grep:文件名、行号、内容一行一个。这样做的原因很实在:我拿到结果后在编辑器中按行号跳转的习惯早就形成了,caveman 的输出能无缝对接我的工作流。大小写处理上默认不区分——用.lower()把关键词和文本都转小写再比较,符合大多数人的直觉。搜索命中着色我放在下一节说,因为颜色处理是个独立的小坑。
3.4 让终端输出带颜色:一个函数解决,但要先看 isatty
零依赖不等于零体验。给日期、命中行加颜色,不需要第三方库,用 ANSI 转义序列就行。当时也考虑过colorama,但为了一个颜色函数引入依赖太亏了,标准库能搞定的事没必要外包。
import sys def colorize(text: str, code: str) -> str: if not sys.stdout.isatty(): return text return f"\033[{code}m{text}\033[0m"code传"1;32"表示粗体绿色,给命中关键词用;传"1;34"表示粗体蓝色,给文件名用。这个函数里最关键的判断是isatty()——当输出被重定向到文件或管道时,必须去掉颜色转义,否则重定向结果里会混入一串\033[垃圾字符。我在 v0.3 版本之前没意识到这个问题,有次把搜索结果的彩色输出直接重定向到文件,结果那文件里全是转义码,数据直接污染了。从那以后,凡是带颜色的输出函数,第一步永远是 isatty 检查。
4. 实操复盘:从零到能用的完整跑通
4.1 迭代顺序:先做最窄可用版,用一周再决定下一步
如果照着做,我建议按这个顺序迭代:第一版只写new,而且只支持直接传文本;第二版加 stdin 多行模式;第三版写list,先只做按日期查看,不加 range;第四版写search;第五版写stats;最后再加颜色和 tag。每一步都保持"能正常使用"的状态,而不是憋一个大版本。
这个节奏我强烈建议你试一下。我在实际迭代中发现,很多功能是你"以为需要"但实际根本不用的。比如 tag,我加了觉得挺好,可真用起来,搜索一个关键词两步就完成了,tag 反而多了一个维护动作。功能只有在真实使用中被反复用到,才有资格留在代码里。
4.2 目录结构与关键参数
推荐存储目录:
~/caveman-notes/ ├── 2026-01-08.md ├── 2026-01-09.md └── 2026-01-10.md目录默认是~/caveman-notes,用环境变量CAVEMAN_HOME可以覆盖。为什么不用配置文件?因为配置文件本身又变成一个需要维护的接口:格式、解析、默认值、异常处理,全都要写。环境变量只有一行读取逻辑,已经足够覆盖"笔记目录不在默认位置"这种低频需求。至于"按项目分目录"的诉求,我的答案是:不做。笔记里写个#项目名,搜索时搜项目名,效果一样,完全没必要在工具层面加一层目录逻辑。
4.3 手工回归验证清单
每次改完代码,我会跑一组固定命令确认没有回归。这条验证路径覆盖了主要分支,五分钟跑完:
| 命令 | 预期结果 |
|---|---|
python caveman.py new 测试一下 | 生成当天文件,无报错 |
python caveman.py new(进入 stdin 后 Ctrl-D) | 内容追加到当天文件 |
python caveman.py list --date 2026-01-08 | 只显示该日期文件内容 |
python caveman.py search 测试 | 找到匹配行,带文件号和行号 |
python caveman.py stats --date 2026-01-08 | 输出单文件字数 |
python caveman.py xxx(不存在的命令) | argparse 报错,退出码为 2 |
这套清单看着简单,但已经把参数解析、文件读写、编码、输入输出、错误路径都覆盖了。真正的问题往往不在这五条路径上,而在边界:空文件内容、多行输入结尾有没有空行、搜索词大小写、日期文件不存在。这些边界问题,我在下一节详细讲。
5. 常见问题与避坑经验实录
5.1 Windows 下中文乱码与 UnicodeDecodeError
这是新手最容易踩的坑。在 Windows 控制台跑 Python,如果没有显式加encoding="utf-8",读写文件会用系统默认的 GBK 编码。读一个 UTF-8 编码的中文笔记,大概率直接抛UnicodeDecodeError;就算不抛错,写进去的文件也可能在别处打开乱码。
解法分三层。第一层,所有read_text、write_text调用都显式传encoding="utf-8",这是绝对不能省的。第二层,源码文件开头写# -*- coding: utf-8 -*-。虽然 Python 3 默认源码就是 UTF-8,但养成这个习惯没有坏处,尤其是在团队协作或换环境时。第三层,如果输出本身在 Windows 控制台显示乱码,那是控制台代码页问题,执行chcp 65001切换到 UTF-8 代码页即可。这个坑的折磨之处在于它只在你部署到 Windows 时才出现,开发环境永远是好的,所以排查时要有意识地往编码上想。
5.2 路径拼接与反斜杠的历史包袱
路径处理一律用pathlib.Path,不要用字符串手动拼,这一点大家应该没有异议。但做跨平台工具时还有几个隐蔽的坑。Path.home()在 Windows 下指向用户目录,很多人喜欢把笔记放在 D 盘,那就要用CAVEMAN_HOME显式指定。指定时要注意:Windows 路径字符串里反斜杠是转义字符,Path("D:\notes\")看起来正常,实际解析结果可能出乎意料。解决办法是使用原始字符串Path(r"D:\notes"),或者干脆让pathlib从环境变量读取时自动处理,不要自己拼。
还有一个我踩过的细节:用户提供的--date参数,一定要先验证格式再拼路径。我当时只做了exists()检查,没做格式校验,结果有人传了2026/01/08,直接拼出一个不存在的多级路径,glob静默返回空列表,排查了半天才发现是日期格式问题。现在list、search的 date 参数进来后第一件事就是用datetime.strptime验证格式。
5.3 搜索性能边界:实测数据与两个优化预案
我把"几千个文件"作为 caveman 的性能预期,实测数据是:5000 个文件、每个约 50 行,一次全量search大约 1.5 秒。能接受,但不爽。如果你在意这个速度,有两个低成本优化路径。
第一个是按年份建子目录,比如2026/2026-01-08.md,这样全局搜索时可以减少单次glob的扫描范围。代价是文件路径逻辑复杂一点,以及需要处理历史文件的迁移。第二个是建索引缓存——把文件名、行内容、mtime 存成一个 JSON 缓存文件,搜索时先比对各文件的修改时间,只有变过的文件才重新读取。体量再大,索引缓存几乎是唯一解。
我目前两个优化都没做,因为我的笔记数据量还远没到影响使用的程度。但我会在面对大规模数据前想清楚方案,而不是等卡了才临时抱佛脚。README 里我如实写了"超过两万个文件请自行加索引",边界诚实比假装完美重要得多。
5.4 没有类型注解和第三方测试库,怎么保证代码不烂
caveman 用实践说明了一个道理:小项目的可维护性不靠工具链,靠的是结构和自律。整个项目函数都不长,每个命令对应一个cmd_*函数,私有辅助函数统一前缀_,变量名禁止缩写。代码 review 时我重点看的是"命令行为是否符合直觉",而不是类型是否完备。
我也不是完全反对静态检查。pyright 这个工具可以只做纯静态检查,不增加任何运行期依赖,符合零依赖项目的精神。如果你想给 caveman 这样的项目加上一层安全网,pyright 是比 mypy 更顺手的那个。我用下来最大的感受是:类型注解在跨模块交互时才真正值钱,像 caveman 这种单文件、命令间几乎不共享状态的结构,保持肉眼可读反而更重要。
这个项目我实际用到现在已经半年多,最大的体会是:工具的价值不在于功能数量,而在于你愿不愿意每天都打开它。caveman 至今也只有四个命令、一个文件、几千行代码,但它把我从"不断挑选笔记工具"的循环里彻底解放了出来。如果你也想做类似的小工具,我的建议是:先砍掉一半你觉得"应该要"的功能,把剩下的一半做得足够顺手,然后立刻用起来。等哪天真正遇到痛点,再谈加功能的事——很多你以为需要的功能,到那时你会发现根本用不上。