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, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)问题在于w模式打开文件的瞬间原文件就已经被清空了。如果这个时候进程被干掉CtrlC、断电、系统休眠你得到的是一个零字节的空文件所有历史记录一次性归零。我真实经历过一次——手滑按了两次 CtrlC一周的记录没了当时整个人是懵的。正确做法是写临时文件再原子替换def save(data, path): os.makedirs(os.path.dirname(path), exist_okTrue) tmp path .tmp with open(tmp, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) 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(progdaydayup, description每日学习打卡) sub p.add_subparsers(destcmd, requiredTrue) lg sub.add_parser(log, help记录今天的学习) lg.add_argument(-m, --minutes, typeint, default0, help学习时长分钟) lg.add_argument(-t, --topic, default, help主题) lg.add_argument(-n, --note, default, help补充说明) lg.add_argument(-d, --date, defaultNone, help补记某天格式 YYYY-MM-DD) st sub.add_parser(stat, help查看统计) st.add_argument(--days, typeint, default30) rp sub.add_parser(report, help生成周报) rp.add_argument(--end, defaultNone, help周期结束日默认今天) rp.add_argument(-o, --out, defaultNone, 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(timespecseconds), } 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, todayNone): today today or date.today() d today if d.isoformat() not in data: d - timedelta(days1) n 0 while d.isoformat() in data: n 1 d - timedelta(days1) return n这个写法有个隐含前提数据是“连续日期”才有意义如果中间缺了一天循环自然停住不需要额外判断。复杂度是 O(streak 长度)一周岁数也就几百次循环性能上完全不用考虑。但它的代价也要说清楚如果连续三天没记录streak 会归零而且归零之后没有任何“历史最长”的记录。我后来在数据文件旁边单独存了一个best.json每次log之后更新历史最长值def update_best(data, path): best {} if os.path.exists(path): with open(path, r, encodingutf-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 天”默认 N7def slice_range(data, endNone, days7): end end or date.today() start end - timedelta(daysdays - 1) items [] for k, v in data.items(): # 用字符串比较就够因为格式统一是 YYYY-MM-DD if start.isoformat() k end.isoformat(): items.append((k, v)) items.sort(keylambda 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是 0active也是 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, endNone, days7, acc[]): # 错的 ...如果函数里对acc做了append那么下次调用时它已经不是空列表了——默认参数在函数定义时求值一次之后所有调用共享同一个对象。正确写法是把默认值设成None函数体里再初始化def slice_range(data, endNone, days7, accNone): 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, nname: 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()调用都显式写encodingutf-8一个都不能漏。# 读 with open(path, r, encodingutf-8) as f: data json.load(f) # 写 with open(tmp, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)ensure_asciiFalse是另一半不加的话中文会被转成\uXXXX转义文件能正常读但你直接打开看会非常难受。我要求自己是“数据文件必须能人眼读懂”因为手动修数据是这类小工具的必备技能。还有一个更隐蔽的情况文件开头带了 BOM。某些编辑器保存 UTF-8 时会加三个字节的 BOMPython 用utf-8读会把它当成普通字符导致json.loads报Expecting value: line 1 column 1。遇到这个报错先拿十六进制看一下文件头是不是EF BB BF如果是改用encodingutf-8-sig读写入时保持utf-8即可。6.2 时间相关的两个坑第一个是date.today()用的是本地时区。单人单机没问题但我曾经在一台云服务器上跑过定时任务服务器时区是 UTC晚上十点做的记录被算成了第二天streak 直接错乱。后来我统一了原则存储用本地日期因为使用者是按本地时间生活的但如果涉及跨设备或跨时区存储用带时区的 ISO 时间戳展示时再转换。这个项目目前是单机所以存的是本地日期我在代码注释里明确写了这个前提。第二个坑是strftime和strptime记反。strftime是 format time把时间对象格式化成字符串strptime是 parse time把字符串解析成时间对象。这两个名字差一个字母我至少见过五次写反导致的报错。记法很简单f 在前格式化输出p 在后解析输入。6.3 问题速查表现象最可能的原因排查动作修法UnicodeDecodeErroropen没指定编码检查所有open()调用统一加encodingutf-8Expecting 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 界面、砍掉多设备同步之后体会得特别深。