那天晚上的场景我记得很清楚:晚上十一点半打开牛客网的每日一题,题目还没读完,手机弹出来一条消息,我顺手回完消息再回到电脑前,已经过了零点。当天的格子永远空掉了——我坚持了六十多天的连续记录,就此断签。断签之后的那一周,我一次都没有再打开过每日一题。明明每天只需要花半小时,却像多米诺骨牌一样,第一块倒下之后,后面连着塌了一大片。
“多米诺骨牌”这个小项目就是在那之后动手做的:一个围绕牛客每日一题做的个人tracker,记录每天完成状态、计算连续天数、按时提醒当天题目,用最轻的方式把刷题习惯重新拉回正轨。这篇文章会完整拆解这个tracker的设计思路、连续天数算法、打卡数据入口,以及实测中踩到的几个坑,给同样在刷每日一题、或者想为自己做一个习惯追踪工具的人当参考。
1. 为什么叫“多米诺骨牌”:从一次断签开始的连锁反应
1.1 刷题最难的不是题目,是“连续”
每天一道题这个设计本身很轻量,难的是“每天都做”。断签之后我更清楚地意识到一种连锁反应:第一天断了,第二天你会有一种“反正已经断了”的心态,不再有维护连续记录的动机;第三天你连打开页面的欲望都在下降;等到第四五天,之前顺手就能做完的题突然变得很重。
这种感觉其实和认知负荷有关。维持一个未完成的链条时,大脑每天只需要执行一个很小的动作,就像按下一个开关;链条一旦断了,就需要额外决策“要不要重新开始”,每一道题都变成一个新的决定,消耗就大了。这个体验和“多米诺骨牌”最接近:一排立好的骨牌,推倒第一块很轻松,但它带倒的是后面所有已经进入预备状态的骨牌。你在断签后的第一反应不是“少做了一道”,而是“整条链都没了”。
1.2 Tracker真正要追踪的,不是“做了多少题”
如果只是记录“今天做了题”,手动写日历就够了。我需要的是把一个“连续链条”拆成可观测的指标:连续完成天数、周完成率、月度总完成数、断签事件。追踪和记录的区别在于,记录是被动的流水账,追踪会主动盯住链条上的薄弱点。
连续天数归零,是第一块骨牌倒下的信号,tracker的作用是把这个信号前置——在断签真正发生之前,用提醒和趋势图把它拦住。这也是我把它叫“多米诺骨牌”而不是“刷题日历”的原因:这个工具的核心对象不是题目,是那根连续不断的链条本身。
1.3 为什么选了Python+SQLite这套“土办法”
说实话一开始上头,想做一个完整的前后端项目:React页面、后端服务、用户系统。冷静下来之后觉得不对——个人工具就该用个人工具的规模。最终选型:Python 3做脚本、SQLite存数据、cron/计划任务跑提醒、终端输出看板。整套东西维护成本几乎为零,换电脑只需要拷贝一个db文件。
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| React + FastAPI + PostgreSQL | 界面好看、可扩展 | 服务器、部署、维护成本高 | 过度设计 |
| Python脚本 + SQLite + 计划任务 | 上手快、数据可移植 | 界面简陋 | 正合适 |
| 纯手写笔记本 | 零成本 | 无法统计趋势、容易漏记 | 只能当辅助 |
选“土办法”还有一个原因:值不值得用“新技术”取决于这个系统服务的对象。每天只写一条记录、每天只看一次状态,这个体量连SQLite的性能都不需要担心,更不需要上服务器。工具好不好,不看技术栈酷不酷,看它能不能稳定帮你解决每天的问题。
2. 连续天数是怎么算的:不被数据欺骗的算法细节
2.1 先定义什么算“完成”
写代码之前,我先把“完成”定义清楚,不然数据后面会打架。我给三种状态:
- done:当天独立写出并能讲清思路,完全计入连续链。
- partial:看了题解或参考后手写通过,但能复述核心思路,计入连续链,但月度“独立完成率”会打折。
- skip:没做或中断,直接断链。
为什么要保留partial?因为“每天都做”本身比“每天都完全独立做出”更值得坚持,但数据上不能自欺。partial只保证链条不断,它骗不了汇总统计。换句话讲,连续链衡量的是节奏,独立AC率衡量的是水平,这两个维度必须分开。
2.2 连续链怎么算:从今天往回回溯
确定了状态定义,算法其实很简单:从今天开始往前数,遇到非“done/partial”就停。关键不是做多复杂的计算,而是把边界情况处理干净。我给一段核心代码:
from datetime import date, timedelta ALLOWED = {"done", "partial"} def calc_streak(checkins, today=None): """返回 (当前连续天数, 断开日期)""" today = today or date.today() cur = today # 今天还没打卡时,链的延伸只看昨天,不能把今天算作断点 if checkins.get(cur) is None: cur -= timedelta(days=1) length = 0 while checkins.get(cur) in ALLOWED: length += 1 cur -= timedelta(days=1) broken_on = cur if not checkins.get(cur) else None return length, broken_on这里有个反直觉的点:今天没打卡,不该立刻把连续天数归零,因为一天还没结束。正确做法是把今天当作“未知”,从昨天开始数。等到今天结束时如果还是没有记录,明天的计算自然会从昨天往前数,链子就断了。用date对象当key还有一个好处:跨月、跨年都不需要特殊处理,日期本身是连续的自然维度。
2.3 补卡三档政策
补卡在tracker里是不可回避的需求,谁都有突发状况。我设计了三个档位:
- 7天内可补:链上标记“补”,连续天数正常计算但旁边显示星号。
- 超过7天、仍在当月内的:可补但只计入完成率,不计连续链。
- 跨月后不再允许补:数据永远锁定。
这么设计有几个原因:一是数据诚实,记录的是“真正发生”的习惯;二是防止把tracker玩成填表游戏;三是给意外留了余地,比如出差、生病。关键是在代码里做同一件事:允许修数据,但不允许把过去某一天从skip改成done来“复活”一条早就倒下的链。实现上也不复杂,写数据前判断一下目标日期和今天的时间差就行。
2.4 “当天”到底以哪个时区为准
这个坑很隐蔽,我建议所有人都提前注意。一开始我跑计划任务的服务器用的是UTC时间,脚本里datetime.now()默认返回UTC,而日期key用的是本地日期。于是发生了一件怪事:北京时间凌晨0点到1点打卡,会被标成“前一天”,连续链直接被误断。
修复方式很简单,显式指定时区再取日期:
from datetime import datetime from zoneinfo import ZoneInfo LOCAL = ZoneInfo("Asia/Shanghai") today = datetime.now(LOCAL).date()不要用绝对时间戳给日期归类,人类的“一天”是本地时区的自然日。不管脚本在哪个机器上跑,都必须在代码里锁死目标时区。这个坑后面我还踩过一次,等到第5章再详细展开。
3. 牛客每日一题的数据是怎么进tracker的
3.1 牛客每日一题的页面上到底有什么
先看看数据源有什么可用信息。牛客网的每日一题页面,通常能看到题目标题、题号、通过率、难度标签、推荐理由,还有你自己的提交状态。对tracker来说,只需要其中几条:题号、标题、难度、状态。
题号一定要带。我当时觉得有标题就够了,结果一个月后想回头找某道题,发现同名的题有好几个,还得去牛客网翻历史记录,非常麻烦。题号是唯一标识,有了它,回看和复习的成本都很低。
3.2 半自动录入不是偷懒,是防错
我一开始试过全自动抓取页面信息,后来放弃了:页面结构改一次脚本就要改一次,而且抓错状态比手动录错更隐蔽。全自动的录错是静默的,你根本不知道哪里脏了;手动录错你起码有印象。最终采用“手工为主、命令为辅”,每天花十秒把题号和难度复制进命令,其他信息自动生成:
python domino.py done --no=2656 --title="合并两个有序链表" --difficulty=中等 --tag=链表 python domino.py status为什么强调半自动?因为录入动作本身是一种“仪式感”,它让我在提交代码之后刻意回看一眼题目,比全自动静默记录更容易留下印象。这也是tracker跟数据采集系统的本质差异:它的产出不只是数据库里的记录,还包括每天一次主动的确认行为。这个确认行为本身就在强化习惯。
3.3 SQLite表结构和防重复
存储结构是整个项目的命根子。我用的表设计如下:
CREATE TABLE dailies ( date TEXT PRIMARY KEY, problem_no TEXT NOT NULL, title TEXT, difficulty TEXT, tag TEXT, status TEXT NOT NULL CHECK(status IN ('done', 'partial', 'skip')), note TEXT, updated_at TEXT DEFAULT CURRENT_TIMESTAMP );用date做主键是刻意的:天然唯一性,同一天多次打卡会触发约束错误,正好防重复。status字段加CHECK约束,防止手滑把状态写成乱七八糟的值。我还在note字段里写当天卡了多久,一个月后翻数据,能看到自己卡得最多的题目,这就是复习时的重点。
4. 让骨牌不倒下:提醒机制与趋势看板
4.1 三级哨兵:把“断签”拦在前面
提醒机制是“多米诺骨牌”项目里投入产出比最高的一部分。我设了三个时间点:
- 早上9点:脚本读出今天题目题号,拼接URL,用系统通知推送。这个动作把“找题”的成本清零了。
- 晚上21点:检查今天的记录,没有就再提醒一次,附一句“今天的骨牌还立着”。
- 23点:最后一道哨,提醒“距离今天结束还有1小时”。
实现上就是三个计划任务,调用同一个Python脚本,带不同参数。这里有个原则:提醒脚本本身不要做太多事,它只负责“判断+推送”,判断逻辑必须复用tracker核心函数。不要把判断逻辑散落在不同脚本里,否则提醒逻辑和数据统计对“完成”的定义会出现不一致。
4.2 可视化:只输出一张“多米诺棋盘”
可视化我没做网页,一个脚本把最近90天渲染成一行行的格子:“■”表示done,“◨”表示partial,“·”表示skip。每天开终端就能看到棋盘,哪块空了非常直观。输出大概长这样:
最近90天: ■■■·■■◨■■■■■■■■■·■■■■■■■◨■■■·■■■··■ 当前连续:14天 本月完成率:87.5% 最近断签:2025-01-04有人可能觉得这种形式太土,但我用了三个月,反而认为这是最合适的:它不用打开额外页面、不用登录任何平台,每天那个时刻自动出现。视觉上“棋盘缺一块”比数字变化更容易触发行动,因为你的眼睛会自动盯住断层。
4.3 用“提前量”对冲不可控事件
提醒归提醒,现实中的不可控还是要靠策略对冲。我总结出三个土办法:
- 早上看到题目后,即使没有完整时间,也先花10分钟把思路写进note,晚上回来再补代码。
- 如果有出差或长途出行计划,主动把当天题目的思考提前放到前一天的note里,第二天只需要提交代码就行。
- 实在要断,主动选一天断掉,而不是被动等23点的哨兵响。提前决定“我今天就是要断”会减少那种“不小心断了”之后的连带心理崩塌。
第三个办法尤其重要。“不小心断掉”和“计划内断掉”的心理成本完全不同,前者会引发“反正断了”的滑坡,后者只是一个普通的休息日。
5. 跑了三个月,踩过的坑和修复过程
5.1 断签误判:问题出在UTC时区上
完整的排查链路是这样的:
- 现象:周四、周五连续打卡,周六打开看板发现连续天数只有1。
- 第一反应:是不是代码bug?打印checkins字典,发现周四的date key变成了“前一天”。
- 根因:周四晚上11点多打卡,脚本运行环境是UTC,北京时间已经进入周五,但UTC还是周四。于是数据库里同时出现“周四(UTC)”和“周五(UTC)”两个键,而展示端把日期字符串转成“今天”时用的又是北京时间。两个时间源不一致,导致同一天的记录被劈成两天,链自然就断了。
修复只用了一行:所有日期key统一用本地时区生成,比较链时也直接用date对象的字符串比较,不再依赖运行环境的默认时区。这个坑花了我一个晚上,最后就是ZoneInfo的差别。
5.2 重复录入把tag覆盖了
另一个坑更隐蔽。某天我在两个终端窗口里各跑了一次done命令,第二次执行时SQLite的date主键冲突,写入没有报错,因为当时的写入函数写得太“聪明”:
INSERT INTO dailies(...) ON CONFLICT(date) DO UPDATE SET tag = ?结果第二次打卡时tag参数没传,旧tag被空值清掉了。等我要按标签筛选链表专题时,才发现好多记录的tag是空的。
这个教训很深:个人脚本也要分清“新增”和“修改”两条路径。后来我改了写入逻辑:date已存在时,只允许修改note字段,tag和difficulty不可被空值覆盖。数据污染这种事,一旦发生,后面的统计报表全是不可信的。
5.3 提醒脚本静默死亡
最头疼的问题是某段时间晚上21点的提醒再也没出现,但计划任务里却显示“上次运行成功”。
排查过程很有意思。我打开日志,发现脚本在启动阶段会初始化一个网络会话,在公司网络环境下这个初始化会卡住,直到超时崩溃。因为异常发生在主逻辑之前,所以连日志都没写——程序启动后直接挂在了第一行。
修复分两步:第一步,把网络发送放在真正需要发送消息的try块里,任何异常都被捕获写入domino.log;第二步,加心跳机制——每天0点写一行心跳记录,第二天如果没看到心跳,就知道脚本挂了。个人工具的可靠性不一定靠监控平台,一个小日志文件就够了。
6. 三个月的多米诺数据:它真的带来了连锁效应
6.1 真实数字
运行到现在,几个关键数字如下:
| 指标 | 数值 |
|---|---|
| 最长连续记录 | 87天 |
| 总完成率 | 91.7% |
| 断签次数 | 2次 |
| 断签后恢复间隔 | 第一次7天,第二次2天 |
两次断签都不是主观偷懒:一次是半夜急诊,一次是跨城市出差落地时已经过了零点。但有意思的是恢复成本的变化:第一次断签后隔了7天才回来,第二次只隔了2天。这就是把中断记录下来的意义——你能清楚地看到“恢复成本”在缩减。
6.2 真正的变化不是题量,是心态
数据上的变化其实没有特别夸张,每周多做了几道题而已。更明显的变化是心态:不再有“今天还没刷题”的悬而未决感。每晚9点左右完成追踪动作之后,一天的学习任务就清晰截止了,不会再带着“是不是该做题”的念头入睡。
坚持做一件事靠的不是意志力,而是“每天只有一个小任务”的系统。多米诺骨牌连锁反应的另一种理解:第一块骨牌并不比后面任何一块力量更大,是整排骨牌已经立好了,你只需要推一次。tracker帮我把“每天推一次”这件事固定成了不占决策带宽的例行程序。
6.3 允许“学过”之后,数据反而更诚实了
开头我定过严格策略:必须独立AC才算完成。后来实在坚持不住,而且发现焦虑感越来越重——很多题看题解能懂,但独立AC真的需要大量时间。于是我把规则调整成:读完题解、手写代码、能讲清思路,就可以记partial。
调整后完成率明显上升,连续链更稳定,而“独立AC数”在月度统计里单独保留。这样就清晰分开了“习惯链”和“熟练度”两个维度:连续链衡量的是节奏,独立AC率衡量的是水平,不该混在一个指标里。
这个调整对tracker设计很重要:指标要匹配目标。我的目标是保持每天接触算法,不是每天都要发明算法。如果你也打算做类似的工具,先想清楚你的目标是习惯还是水平,再决定数据怎么记。
整套东西到现在还在跑,每天维护成本不超过十分钟。前几天出差,飞机落地已经十一点半,我在酒店走廊里把当天的题看完,然后在手机Termius里敲下那行打卡命令——那天那种感觉很值。工具从来不会逼人自律,它能把“即将倒下”的那块骨牌提前指给你看,这就够了。
最后分享一个小技巧:把连续天数放到终端欢迎信息里,像我一样天天开终端的人,根本不用特意打开看板就能被提醒。项目不大,但连续87天那会儿,我是真的被自己的记录惊讶到了。