1. 项目缘起:我给自己的 python 学习笔记起名叫 daydayup
“daydayup”这个名字听起来有点土,但我确实找不到更贴切的词。它是我从零散笔记走向一个小工具的分水岭——在很长一段时间里,我的 python 学习笔记就是一堆散落在各种文档、聊天窗口和代码注释里的碎片。今天记一个列表推导式,明天抄一段装饰器,过两周回头看,完全想不起来自己学到哪儿、断了几天、哪些知识点反复栽跟头。所以我给自己定了个很朴素的规则:每天至少留下一条可回溯的记录,哪怕只有一行代码。围绕这个规则,我写了一套很小的 Python 脚本,负责打卡、存记录、算连续天数、每周自动生一份周报。它解决的问题不是“学 Python”,而是“让学 Python 这件事变得可测量”。适合谁看?如果你也在自学某个东西,想知道怎么用几十行代码把坚持这件事变成看得见的数字,这套东西可以直接抄;如果你只是单纯想看看一份真实的学习笔记长什么样,里面的踩坑记录也有参考价值。
这套东西后来的实际形态,比我最初设想的要精简得多:一个不到两百行的脚本文件,一个 JSON 数据文件,加上一份由脚本自动拼出来的 Markdown 周报。我一开始也想过搞个界面、接个数据库、做个图表,但试了两周之后全部砍掉了。原因很简单——任何增加启动成本的环节,都会在第三周被自己绕过。这是我的第一个重要判断:工具的价值不在于功能多,而在于它足够轻,轻到你加班到十一点半还愿意敲一行命令。
1.1 打卡工具满地都是,为什么还要自己写
市面上做打卡、做学习记录的工具不算少,我也试过几个。问题在于它们大多有“仪式感负担”:打开应用、选项目、写心得、点提交,一套流程下来三分钟没了,而这三分钟里我可能连一条代码都没写。更麻烦的是数据不在自己手里,想按自己的维度统计,比如“过去三十天里,哪些知识点被重复记录了三次以上”,往往导不出来,或者导出后还得再清洗一遍。
自己写的好处,是把“记录”和“学习”缝在一起。我的打卡命令长这样:
python daydayup.py log -m 45 -t "生成器表达式" -n "对比了()和[]在内存占用上的差别"五分钟之内就能完成,而且这条记录天然带着日期、时长、主题三个维度。之后不管是算 streak(连续天数)还是做主题词频统计,数据都是现成的结构。这就是我说的“省事来自结构”,而不是来自功能。你在设计笔记的时候多想一层“这条数据以后会被怎么用”,后面就能少写一堆解析代码。
还有一点是我后来才意识到的:自己写的东西,你会有修改它的动力。当我发现 streak 算法在“今天还没打卡”这个场景下会误判成断签,我当天就改了。换成现成工具,我大概率只会吐槽两句然后忍着。对自己写的工具,容忍度会低很多,而这恰恰是它能一直活下来的原因。
1.2 整体设计:一个脚本、一个数据文件、一份周报
把结构摊开看,这套东西只有三个部分,彼此职责非常清晰:
| 模块 | 职责 | 实现方式 | 大概行数 |
|---|---|---|---|
| 记录层 | 写入/读取每日打卡 | 单文件 JSON | 约 60 行 |
| 统计层 | 计算连续天数、汇总周期数据 | 纯函数,不碰 IO | 约 50 行 |
| 输出层 | 生成周报、打印摘要 | 字符串拼接 + Markdown | 约 60 行 |
这个分法不是为了好看,是为了好调试。统计层做成纯函数,输入一个字典、输出一个数字,意味着我可以在 REPL 里随手验证,不需要真的去改文件。输出层只负责组装文本,出问题了看一眼就知道是数据不对还是模板不对。真正容易出错的只有一个地方——记录层的文件写入。后面我会专门讲这个。
我特意没有把统计层和输出层揉进记录层,是因为吃过亏。最早版本里,log命令写完文件之后顺手就算了一遍 streak 打印出来,结果某次我在测试里连续调用了十几次,每次都往文件里写数据,把一周的记录搅成了一锅粥。读写分离听起来像大词,落到实处就是:写文件的函数里不要夹带任何分析逻辑,分析的函数里不要出现open()。
2. 数据结构定生死:先想清楚每天存什么
很多人在做这类小工具时会犯同一个错误:先写界面或者先写命令,数据格式边写边改,改到第三版发现旧数据全废了。我第一版的 JSON 长这样:
{ "logs": [ {"time": "2024-05-01 22:13:05", "min": 45, "topic": "生成器"}, {"time": "2024-05-01 23:40:11", "min": 20, "topic": "生成器"} ] }用了三天就发现两个问题。第一,同一天可能记两条,算 streak 的时候要先去重,每次都得写一遍这逻辑。第二,按天查找要遍历整个列表,数据一多就慢——虽然一年也才几百条,慢不到哪去,但写起来烦。所以第二版我直接把日期当成键:
{ "2024-05-01": { "minutes": 45, "topic": "生成器表达式", "note": "对比了 () 和 [] 的内存占用", "updated": "2024-05-01T22:13:05" } }这个改动最大的价值是把“一天一条”这个业务规则直接编码进了数据结构。之后所有按天查询都变成data.get("2024-05-01"),O(1),一行搞定,不需要去重、不需要遍历。业务约束交给数据结构来保证,往往比写在代码里更可靠。
2.1 为什么最后没用 SQLite
这个问题我被问过不止一次。SQLite 是 Python 自带的,import sqlite3就能用,理论上比 JSON 更“正规”。我评估过,最后一句话总结:我这个场景的数据量,用不上数据库的能力,却要承担它的仪式感。
具体算一笔账。一天一条记录,一年 365 条,五年不到两千条。一条记录大概两百字节,五年数据加起来不到 400KB。这种规模下,JSON 一次全量读进内存、算完再全量写回,耗时在毫秒级。而 SQLite 带来的好处——索引、事务、并发控制、SQL 查询——在我这里一个都用不上:单人单机、顺序写入、没有复杂查询。反过来,它带来的成本是实打实的:表结构要设计、字段变更要迁移、调试的时候不能直接打开文件看一眼。
我给自己的切换阈值写在了笔记里:当出现以下任意一条时,才考虑换 SQLite——(1) 数据文件超过 5MB;(2) 需要按多个维度做模糊查询,比如“找出所有包含‘装饰器’且时长超过 60 分钟的记录”;(3) 需要多设备同步写入。这三条至今一条都没触发。
如果你正在纠结选型,我建议你先把上面三条阈值写下来,贴在你的项目说明里。没有阈值的“以后再说”,实际上就是永远不会做决定。
2.2 原子写入:断电那一秒能救你的命
这是整个项目里我最想强调的一个细节。JSON 全量重写的写法很直接:
with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)问题在于,"w"模式打开文件的瞬间,原文件就已经被清空了。如果这个时候进程被干掉(Ctrl+C、断电、系统休眠),你得到的是一个零字节的空文件,所有历史记录一次性归零。我真实经历过一次——手滑按了两次 Ctrl+C,一周的记录没了,当时整个人是懵的。
正确做法是写临时文件再原子替换:
def save(data, path): os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.flush() os.fsync(f.fileno()) # 让数据真正落到磁盘 os.replace(tmp, path) # 同分区内是原子操作几个点解释一下。os.fsync是在告诉操作系统“别缓存了,立刻写盘”,不加的话断电时可能还是丢数据,虽然对个人工具来说概率很低,但代码只多两行。os.replace在同一个文件系统内是原子操作,要么替换成功,要么完全没发生,不会出现“写了一半”的中间态。os.makedirs是为了兼容首次运行——数据目录还不存在的时候,open会直接抛FileNotFoundError,加这一行省掉一次空指针式的崩溃。
注意:
os.replace的原子性只在同一个文件系统内成立。如果你的临时文件放在了/tmp,而数据文件在另一块盘上,替换就可能不是原子的。所以临时文件一定要和数据文件放在同一目录,用path + ".tmp"是最省心的写法。
3. 打卡脚本的三块核心逻辑
脚本入口用argparse做子命令,这个选择我犹豫过——简单场景用sys.argv直接取也能跑,但子命令结构让我后面加功能时不用推倒重来。目前只有三个子命令:log记一条、stat看统计、report出周报。加起来不到四十行解析代码,换来的是清晰的扩展位。
3.1 命令行长什么样
import argparse, json, os, sys from datetime import date, datetime, timedelta def build_parser(): p = argparse.ArgumentParser(prog="daydayup", description="每日学习打卡") sub = p.add_subparsers(dest="cmd", required=True) lg = sub.add_parser("log", help="记录今天的学习") lg.add_argument("-m", "--minutes", type=int, default=0, help="学习时长(分钟)") lg.add_argument("-t", "--topic", default="", help="主题") lg.add_argument("-n", "--note", default="", help="补充说明") lg.add_argument("-d", "--date", default=None, help="补记某天,格式 YYYY-MM-DD") st = sub.add_parser("stat", help="查看统计") st.add_argument("--days", type=int, default=30) rp = sub.add_parser("report", help="生成周报") rp.add_argument("--end", default=None, help="周期结束日,默认今天") rp.add_argument("-o", "--out", default=None, help="输出文件路径") return p几个参数设计上的考虑值得说一下。-m的默认值是 0 而不是必填,因为有些日子我确实只看了十分钟文档,填个数字反而有心理负担,允许留空能降低放弃的概率。-d支持补记,是因为周末回顾时经常发现周三忘了记,如果只能记当天,那条数据就永远缺失了,人会因为“反正断了”而彻底放弃。给用户留一条补救的路,比要求他每天完美履约更现实。
-n的 note 字段里允许出现换行吗?我在命令行里没法直接输入换行,但通过管道或脚本调用时可以传进来,所以存 JSON 时没做任何转义处理,json.dump会自己处理。这也是选 JSON 而不选 CSV 的一个隐藏原因:CSV 里字段含换行要加引号转义,手写数据文件时特别容易搞坏。
3.2 重复打卡:覆盖还是累加
同一天记两次,应该覆盖还是相加?我一开始选了相加,理由是“我今天上午学了一小时,晚上又学了半小时,总时长应该是九十分钟”。用起来才发现不对:如果我记错了想改正,比如把 45 分钟改成 30 分钟,相加的逻辑下我必须先算出差值,非常别扭。
改成覆盖之后,语义就清楚了:一天一条,后写的赢,想累加就自己加好再写。为了让覆盖不至于悄悄发生,我在log里加了一个提示:
def cmd_log(data, args): d = args.date or date.today().isoformat() # 简单校验日期格式,避免写成 "2024-5-1" 这种导致排序错乱 try: datetime.strptime(d, "%Y-%m-%d") except ValueError: sys.exit(f"日期格式不对:{d},应该是 YYYY-MM-DD") if d in data: old = data[d] print(f"[覆盖] {d} 原记录:{old['minutes']} 分钟 / {old['topic'] or '无主题'}") data[d] = { "minutes": args.minutes, "topic": args.topic, "note": args.note, "updated": datetime.now().isoformat(timespec="seconds"), } save(data, DATA_FILE) print(f"已记录 {d},当前连续 {streak(data)} 天")日期格式的校验看起来多余,但确实救过我。有一次我用-d 2024-5-1补记,字符串排序时它会排在2024-05-01之后、2024-12-31之前,导致 streak 计算出现莫名其妙的断裂,排查了快二十分钟才发现是格式不统一。凡是会被当键使用的日期字符串,一定要在入口处强制成零填充格式,strptime校验是成本最低的做法。
3.3 streak 的边界:今天没打卡,算不算断
这是整个项目里唯一一个“纯逻辑但有争议”的地方。最直觉的算法是从今天往前数,遇到没记录的就停。问题是,早上八点打开看统计,今天当然还没记,streak 显示 0,一天的心情就毁了。实际体验里这非常劝退。
所以我采用的规则是:如果今天还没记录,就从昨天开始往回数。这样一来,早上看到的数字是“截至昨天我坚持了多少天”,晚上打完卡后数字 +1,符合直觉。
def streak(data, today=None): today = today or date.today() d = today if d.isoformat() not in data: d -= timedelta(days=1) n = 0 while d.isoformat() in data: n += 1 d -= timedelta(days=1) return n这个写法有个隐含前提:数据是“连续日期”才有意义,如果中间缺了一天,循环自然停住,不需要额外判断。复杂度是 O(streak 长度),一周岁数也就几百次循环,性能上完全不用考虑。
但它的代价也要说清楚:如果连续三天没记录,streak 会归零,而且归零之后没有任何“历史最长”的记录。我后来在数据文件旁边单独存了一个best.json,每次log之后更新历史最长值:
def update_best(data, path): best = {} if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: best = json.load(f) cur = streak(data) if cur > best.get("longest", 0): best["longest"] = cur best["achieved_on"] = date.today().isoformat() save(best, path) return best历史最长值这个东西,在你断签之后的价值会被放大十倍。它让你知道自己曾经做到过,而不是“我从来没有坚持下来”。这个心理层面的设计,比任何功能都重要。
4. 从流水账到周报:聚合与输出
数据存下来不看,等于没存。我要求自己每周日晚上跑一次report,把这一周的记录聚合成一份 Markdown 文件,顺手扫一眼,能看出很多平时察觉不到的东西——比如某个知识点反复出现了四次,说明我一直没真弄懂;比如这一周总时长不到两小时,说明我该调整节奏了。这个“看”的动作,是数据产生价值的唯一环节。
4.1 按周期切片
聚合函数的关键是边界要清楚。我的定义是“包含结束日在内的连续 N 天”,默认 N=7:
def slice_range(data, end=None, days=7): end = end or date.today() start = end - timedelta(days=days - 1) items = [] for k, v in data.items(): # 用字符串比较就够,因为格式统一是 YYYY-MM-DD if start.isoformat() <= k <= end.isoformat(): items.append((k, v)) items.sort(key=lambda x: x[0]) return start, end, items这里用字符串比较而不是解析成date再比较,是因为格式已经被log命令强制成零填充了,字典序和日期序一致。少一次解析,代码也短。但如果哪天有人手工改了数据文件写成了2024-5-1,这段就会失效,所以前面那个格式校验不仅仅是体验问题,还是正确性问题。
顺便说一句,items.sort不能省。JSON 序列化时键的顺序虽然 Python 3.7 之后是插入顺序,但经过几轮覆盖写入后,顺序会变得很奇怪,直接输出会得到一份乱七八糟的周报。凡是给人看的东西,一定要显式排序,别依赖数据结构的隐式顺序。
4.2 生成 Markdown 周报
输出层我尽量写得直白,不用模板引擎,字符串拼接就够了:
def render_report(start, end, items, best): total_min = sum(v["minutes"] for _, v in items) active = len(items) span = (end - start).days + 1 lines = [ f"# daydayup 周报 {start.isoformat()} ~ {end.isoformat()}", "", f"- 记录天数:{active} / {span}", f"- 总时长:{total_min} 分钟(约 {total_min/60:.1f} 小时)", f"- 日均(按记录日算):{total_min/active:.0f} 分钟" if active else "- 日均:暂无", f"- 当前连续:{streak_from(items, end)} 天,历史最长:{best.get('longest', 0)} 天", "", "## 明细", "", "| 日期 | 时长 | 主题 | 备注 |", "| --- | --- | --- | --- |", ] for k, v in items: note = (v["note"] or "").replace("|", "\\|").replace("\n", " ") lines.append(f"| {k} | {v['minutes']} | {v['topic'] or '-'} | {note or '-'} |") return "\n".join(lines)表格里那个replace("|", "\\|")是必须的。我第一版没做转义,某次在备注里写了a|b这种带竖线的文字,整张表格直接错位,渲染出来一片混乱。同样要处理的是换行,Markdown 表格单元格里不能有真实换行,必须替换成空格。这类小细节看着琐碎,但它决定了周报是“能看”还是“能恶心你一整天”。
还有一处if active else的三元表达式,是为了避免除零。第一周跑的时候就撞上了——那个周末我出差没记录,total_min是 0,active也是 0,直接ZeroDivisionError,脚本崩在中途,连半份报告都没生成。凡是做除法的地方,先问一句分母可能为 0 吗,这个习惯能省掉大量低级崩溃。
4.3 让它自己跑起来
手动跑周报的问题很明显:忙起来就忘了。我试过两种自动化方式,都记录一下适用场景。
第一种是系统自带的任务调度。Linux/macOS 下用 crontab,每周日 21:00 执行:
0 21 * * 0 cd /path/to/daydayup && /usr/bin/python3 daydayup.py report -o ~/notes/weekly.md这里有个坑:cron 的环境变量极其精简,python3可能不在 PATH 里,必须写绝对路径;工作目录也不是你想的那个目录,所以要显式cd。我第一次配的时候没写cd,脚本按相对路径找数据文件,结果生成了一个空周报,还覆盖了原来那份,白丢一周。cron 里的每一条命令,都要假设它在一个陌生的空目录里执行。
第二种是 Windows 的任务计划程序,交互界面配置,不需要记语法,但要注意“起始于”那一栏要填工作目录,同样的问题换个地方踩了一遍。触发条件选“每周”,重复间隔按需设,我目前设的是每周日晚上,有时候电脑关着就错过了,所以后来加了“错过计划后尽快运行”这个选项,第二天开机也能补上。
两种方式我都保留了,因为换过设备。真正的经验是:自动化脚本第一次上线后的一周,每天都要去检查一下它有没有产出,别等到月底才发现它一直在报错。那次空周报事故之后,我在脚本里加了一行最简单的自检——如果聚合结果为空,就打印一行警告并且不覆盖输出文件,宁可不出,也不能出错的。
5. 笔记里反复出现的那几个 Python 坑
这套工具写了三版,改动过程中撞到的问题基本都集中在 Python 语言本身。我把它们单独记在自己的 python 学习笔记里,因为这些坑不是这个项目特有的,换个场景照样会踩。这里挑三个印象最深的展开说。
5.1 可变对象做默认参数
这个问题我在写slice_range的时候差点栽进去。最早的签名是这样的:
def slice_range(data, end=None, days=7, acc=[]): # 错的 ...如果函数里对acc做了append,那么下次调用时它已经不是空列表了——默认参数在函数定义时求值一次,之后所有调用共享同一个对象。正确写法是把默认值设成None,函数体里再初始化:
def slice_range(data, end=None, days=7, acc=None): acc = [] if acc is None else acc为什么这个坑这么难发现?因为它不会报错,只是结果悄悄不对。我在测试里连续调用了两次slice_range,第二次的结果里莫名其妙多出了第一次的数据,盯着屏幕看了半天才反应过来。判断标准很简单:默认值是 list、dict、set 这类可变对象时,一律改成 None。这条规则没有例外,不需要理解透彻,照做就行。
5.2 闭包里的晚绑定
我原本想给每个子命令挂一个处理函数,用字典映射:
handlers = {} for name in ["log", "stat", "report"]: handlers[name] = lambda args: print(f"执行 {name}") # 错的跑起来发现,无论调用哪个,打印出来的name都是report。原因是lambda里的name是自由变量,在调用时才去外层作用域找值,而那时循环已经结束,name停在了最后一个值上。这个现象叫晚绑定(late binding)。
修法有两种,我用了更直观的那种——默认参数在定义时就求值:
handlers[name] = lambda args, n=name: print(f"执行 {n}")另一种是functools.partial(print, name),效果一样但可读性对我个人来说稍差。这个坑的迷惑性在于它在循环里才出现,平时写单个 lambda 完全正常。凡是 lambda 里引用了循环变量,加个默认参数绑一下,几乎不会错。
5.3 字典的浅拷贝与合并
data从文件读进来之后,我在统计层做了一些“临时修改”,比如把某天的 minutes 临时归零好做对比。最早图省事写的是tmp = data,结果改tmp的时候原数据也跟着变了——因为这只是给同一个对象起了个新名字,压根没拷贝。
正确的浅拷贝是tmp = dict(data)或者data.copy()。但浅拷贝只复制了最外层,里面每个值还是同一个字典对象,如果你修改的是tmp["2024-05-01"]["minutes"] = 0,原数据照样被改。这种情况下要copy.deepcopy。
后来我发现更省事的做法是根本不改,用生成新字典的方式:
zeroed = {k: {**v, "minutes": 0} for k, v in data.items()}{**v, "minutes": 0}把原字典展开再覆盖一个键,得到的是全新的字典对象,原数据原封不动。这个写法比deepcopy快,语义也更明确。至于合并两个字典,Python 3.9 之后可以直接用a | b,但要注意它也是浅合并,嵌套结构仍然是共享的。
6. 常见问题与排查实录
工具跑了一年多,遇到的问题大致分三类:编码、时间、路径。每一类我都写了一份排查思路,后面再遇到类似现象能少走弯路。
6.1 编码问题:Windows 上最容易翻车
最典型的现象是:在 Linux 上好好的,换到 Windows 一跑,json.load直接抛UnicodeDecodeError,或者读出来的中文全变成乱码。原因不复杂——Windows 上open()不指定编码时的默认值可能是 GBK,而文件是用 UTF-8 写的。解决方案只有一个:所有open()调用都显式写encoding="utf-8",一个都不能漏。
# 读 with open(path, "r", encoding="utf-8") as f: data = json.load(f) # 写 with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)ensure_ascii=False是另一半,不加的话中文会被转成\uXXXX转义,文件能正常读,但你直接打开看会非常难受。我要求自己是“数据文件必须能人眼读懂”,因为手动修数据是这类小工具的必备技能。
还有一个更隐蔽的情况:文件开头带了 BOM。某些编辑器保存 UTF-8 时会加三个字节的 BOM,Python 用utf-8读会把它当成普通字符,导致json.loads报Expecting value: line 1 column 1。遇到这个报错,先拿十六进制看一下文件头是不是EF BB BF,如果是,改用encoding="utf-8-sig"读,写入时保持utf-8即可。
6.2 时间相关的两个坑
第一个是date.today()用的是本地时区。单人单机没问题,但我曾经在一台云服务器上跑过定时任务,服务器时区是 UTC,晚上十点做的记录被算成了第二天,streak 直接错乱。后来我统一了原则:存储用本地日期,因为使用者是按本地时间生活的;但如果涉及跨设备或跨时区,存储用带时区的 ISO 时间戳,展示时再转换。这个项目目前是单机,所以存的是本地日期,我在代码注释里明确写了这个前提。
第二个坑是strftime和strptime记反。strftime是 format time,把时间对象格式化成字符串;strptime是 parse time,把字符串解析成时间对象。这两个名字差一个字母,我至少见过五次写反导致的报错。记法很简单:f 在前,格式化输出;p 在后,解析输入。
6.3 问题速查表
| 现象 | 最可能的原因 | 排查动作 | 修法 |
|---|---|---|---|
UnicodeDecodeError | open没指定编码 | 检查所有open()调用 | 统一加encoding="utf-8" |
Expecting value: line 1 column 1 | 文件带 BOM 或内容为空 | hexdump -C看头部三字节 | 读用utf-8-sig,写入前检查文件大小 |
| 记录突然全丢 | 用了"w"直接重写 | 看文件大小是否为 0 | 改成临时文件 +os.replace |
| 连续天数异常归零 | 日期格式不统一 | 打印所有键排序看 | 入口用strptime强制校验 |
| 周报表格错位 | 备注里有竖线或换行 | 看源数据里的 note 字段 | 输出前转义并替换换行 |
| 定时任务没产出 | cron 环境变量缺 PATH | 把输出重定向到日志文件看 | 命令写绝对路径并显式cd |
| 统计结果多出无关记录 | 区间比较用了>=和<=的反向 | 打印 start/end 实际值 | 字符串比较前先确认零填充格式 |
| 关闭终端后进程被杀 | 用了普通&后台运行 | ps看进程是否还在 | 改用nohup或用系统调度 |
这张表是我实际排查中一条条攒下来的,每一条都真实发生过至少一次。建议你在做类似工具时也维护一份自己的速查表,出问题时不至于从零开始想。
7. 关于“坚持”这件事本身的一点经验
工具写完三个月,我发现一个反直觉的结论:真正让我坚持下来的不是 streak 那个数字,而是“记录”这个动作本身的低门槛。有一段时间我特别在意连续天数,结果某天出差断了一次,心态直接崩了,之后半个月一条没记。后来我把 streak 从主界面挪到了第二行,第一行只显示“本周记录了几天”,心态才恢复正常。
这个改动背后的逻辑是:连续的计数是脆弱的,断一次就归零;而周期内的计数是可以挽回的,这周记了两天,剩下五天还能补。人会为了“还能补救”的事情付出努力,而不会为了“已经毁了”的事情继续投入。这个道理放在任何习惯养成都成立,不只是学 Python。
7.1 降低启动成本比提升意志力管用
我做过一个粗糙的对照:把打卡命令从“打开笔记软件→新建文档→写内容→保存到固定目录”改成“终端敲一行命令”之后,完成率从大概三成提到了七成。中间隔着的不是意志力,而是六个多余的步骤。
所以如果你也在做类似的东西,我的建议是:先别想功能,先量一下从“我想记录”到“记录完成”需要几步、几秒。把这个数字压到最低,其他都好说。我这套工具目前的最短路径是:终端里按上箭头调出上次命令,改两个参数,回车,两秒。
7.2 后面还可以往哪儿长
这套东西目前停在一个我认为合适的复杂度上,但扩展方向我记了几个,暂时没动手。一个是主题词频统计,把 note 字段做简单分词,看看哪些知识点反复出现——出现三次以上,基本说明那个地方是真没懂。另一个是把周报里的明细按主题聚合,生成一份“知识点索引”,方便回头检索。
还有一个想法是加一个review子命令,随机抽取 30 天前的几条记录让我重新看一遍,顺便问自己“现在能讲清楚吗”。这个用random.sample十几行就能实现,但我不确定自己会不会真的用,所以先放在待办里晾着。
我个人的判断是:一个自用工具的功能数量,应该由“我真的会用”来决定,而不是“我能做出来”。写代码的成本很低,维护和使用的成本才高。这一点在我砍掉图表功能、砍掉 Web 界面、砍掉多设备同步之后,体会得特别深。