“买橘子”这个项目代号第一次看到的朋友多半会愣一下刷题和买橘子有什么关系其实这是我给一个刷题追踪工具起的名字。简单说它就是围绕牛客平台的每日一题和日常刷题记录做的一个 tracker用来解决我自己“刷了就忘、忘了再刷、回头一看啥都没留下”的问题。项目本身不复杂但用了一段时间之后我发现它对刷题节奏和复盘习惯的帮助非常大所以想着把整个思路和实现过程整理成文。想做刷题学习记录、又不想用现成表格式模板的人可以参考这里面的玩法。我会从需求拆解、方案选型、核心模块、实操过程到常见问题一步步拆开讲尽量把“为什么这么做”也说明白。1. 需求拆解为什么刷题还要一个 tracker先聊一个很常见的场景。我在牛客上刷题最开始是凭感觉来今天心情好就多做两道明天忙起来就完全放空更别说什么 Python、数据库、操作系统专题的系统规划。结果刷了三个月代码确实敲了不少但复盘的时候根本说不出来这个月解决了哪些类型的题、哪些知识点是反复错的。更扎心的是牛客平台的“每日一题”功能每天都会推送题目但一忙起来就错过事后又懒得翻回来补白白浪费了这条固定输入。所以这个项目最初的诉求就三条把每天的刷题动作变成一个可落地的记录最好能自动拉取每日一题不用手动粘贴题目内容。把自己手动补充的提交记录、思路笔记、错误原因保存在一个稳定的地方方便后面统计。每天或每周生成一个简单报表哪怕就是几行数字也能让我直观地看到“最近进步了还是退步了”。听起来很简单但实际操作时坑不少。市面上有在线表格、刷题社区自带的数据统计但要么不方便离线保存要么统计维度和我自己的需求对不上。后来我决定自己动手做一个轻量的本地 tracker同时把“每日一题”从页面里解析出来存进同一个数据库。“买橘子”这个名字也确实是从日常里蹦出来的。某天连挂了几道题室友在旁边开玩笑说“别刷了下楼买斤橘子吧至少甜的比题多”。后来这个项目就一直叫买橘子也算提醒自己别忘了生活的甜味。名字随意但做的时候我还是把它当正经的小产品来做。2. 整体设计与技术选型这个 tracker 的技术栈走的是极简路线Python 做数据处理和脚本入口SQLite 做存储APScheduler 或系统 cron 做定时触发输出模块负责生成 Markdown 报表。没有引入 Flask、Django 这类 Web 后端因为单人本地工具根本用不上 Web 界面命令行和脚本足够反而更稳。选 SQLite 而不是 MySQL 或 PostgreSQL主要原因有两点数据量很小。每天最多记几十条刷题记录连续用一年也就一万多条SQLite 完全扛得住。单文件部署备份直接复制一个文件就行。我用网盘同步这个文件换了电脑也能恢复统计记录不用搭服务。Python 是顺手选的因为牛客的每日一题页面解析、文本清洗、统计输出这些任务用 Python 写起来最顺手尤其是配合 BeautifulSoup 和 sqlite3 标准库几乎不用额外引入什么重量级依赖。实际上如果你对 Python 不熟也可以用 Node.js 或 Go 代替核心思路都一样只是代码写法不同。整体数据流是这样的定时任务每天触发一次抓取脚本从牛客每日一题页面拿到题目名称、链接、所属专题和难度信息然后写入数据库。刷题过程中我通过命令行工具手动记录“这道题当前状态是 AC、思路卡壳还是没做出来”再加上一句简短的备注。统计脚本在每周日跑一次把所有记录按标签、难度、日期分组统计输出成一张 Markdown 表格方便我贴到笔记里。2.1 为什么选择定时任务而不是实时拉取每日一题每天零点左右更新但平台的接口和页面结构有可能调整如果我让脚本挂一个长时间运行的进程就得多处理连接超时、页面结构异常这类问题。用定时任务每天只跑一次就算失败了也只是当天没有记录第二天补跑一下就好。这种运维思路放到个人项目里非常实用不该为了 99.9% 的可用性引入复杂度。每日一题的关键字段就那几个日期、题目标题、题目链接、难度、所属模块。页面虽然有反爬逻辑但正常浏览器能打开时requests 模拟一个 User-Agent 通常也能拿到内容。万一被限制了就退回到手动添加模式用命令行把题目信息填进去兜底方案不丢数据。3. 核心模块实现下面是我实际在用的几个核心模块完整度到“能抄作业”的程度。代码我做了简化去掉了个人账号相关的信息核心逻辑都在。3.1 数据库表结构先把存储结构定义清楚。我的表叫problems字段如下CREATE TABLE IF NOT EXISTS problems ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_date TEXT NOT NULL, platform TEXT DEFAULT nowcoder, title TEXT NOT NULL, link TEXT DEFAULT , difficulty TEXT DEFAULT , category TEXT DEFAULT , status TEXT DEFAULT todo, note TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP );解释一下几个主要字段。record_date记录日期不是提交时间。因为每日一题是按天推送的我把“今天刷的是哪天的题”放在这个字段里统计时按它分组。status我定义了几个取值todo表示还没做ac表示 AC 通过suspend表示做到一半卡住了failed表示提交没过或者思路完全不对。没有用更细的状态因为每个人的阶段不同太细的反而影响输入效率。note这是最有价值的一个字段。我一开始只记“AC”后来发现没有思路记录等于没刷所以强制自己每次至少写半行备注比如“双指针right左移条件没想清楚”“边界值导致 index out of range”之类。category用来标识题目类型比如数组、字符串、动态规划、数据库、操作系统方便后续统计薄弱点。3.2 每日一题拉取模块这一块我写了一个fetch_daily.py核心逻辑是用 requests 请求牛客的每日一题页面然后用正则或 BeautifulSoup 解析出题目信息。import re import sqlite3 import requests from datetime import date from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_daily(): url https://www.nowcoder.com/daily-practice resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) # 这里以正则或 DOM 选择器提取标题、链接、难度 title_tag soup.select_one(h3.question-title) link_tag soup.select_one(a.question-link) diff_tag soup.select_one(span.difficulty) title title_tag.get_text(stripTrue) if title_tag else 未知题目 link link_tag.get(href, ) if link_tag else difficulty diff_tag.get_text(stripTrue) if diff_tag else 未知 category 每日一题 today date.today().isoformat() save_to_db(today, title, link, difficulty, category, statustodo) def save_to_db(record_date, title, link, difficulty, category, status): conn sqlite3.connect(buy_orange.db) cur conn.cursor() cur.execute( INSERT INTO problems (record_date, title, link, difficulty, category, status) VALUES (?, ?, ?, ?, ?, ?), (record_date, title, link, difficulty, category, status) ) conn.commit() conn.close() if __name__ __main__: fetch_daily()说几个实际遇到的细节。牛客每日一题页面的 DOM 结构有过几次调整所以我用select_one加备用正则方案。解析不到时就返回“未知题目”至少保证当天有个占位记录不会漏掉天数。反爬方面我确实是靠模拟 User-Agent 和降低请求频率来应对但从不做高频抓取。个人脚本一天只跑一次影响很小。如果再强一点就上浏览器自动化但没必要。有些页面需要登录以后才会展示具体的解题链接我这边只记录题目入口和基本信息具体解题链接靠后续手动补充问题不大。3.3 命令行记录工具每日一题是自动拉进来的但平时刷的专题题就得手动记录。我写了一个record.py用 argparse 接收参数。import argparse import sqlite3 from datetime import date def add_record(title, difficulty, category, status, note, link): today date.today().isoformat() conn sqlite3.connect(buy_orange.db) cur conn.cursor() cur.execute( INSERT INTO problems (record_date, title, link, difficulty, category, status, note) VALUES (?, ?, ?, ?, ?, ?, ?), (today, title, link, difficulty, category, status, note) ) conn.commit() conn.close() print(f已记录{title} | {status} | {note}) if __name__ __main__: parser argparse.ArgumentParser(description牛客刷题记录) parser.add_argument(--title, requiredTrue, help题目名称) parser.add_argument(--link, default, help题目链接) parser.add_argument(--difficulty, default中等, help难度) parser.add_argument(--category, default未分类, help专题分类) parser.add_argument(--status, defaultac, help状态: ac / suspend / failed / todo) parser.add_argument(--note, default, help备注) args parser.parse_args() add_record(args.title, args.difficulty, args.category, args.status, args.note, args.link)用起来就是这样python record.py --title 反转链表 --difficulty 简单 --category 链表 --status ac --note 递归终止条件要注意最后一个节点其实这里也可以直接改成交互式输入但对经常在终端里干活的人命令行参数反而更快。我偶尔也遇到过标题带空格的问题所以用了--title这种显式参数避免sys.argv拼字符串时出错。3.4 周报统计模块这个模块是让我坚持用下去的关键。每周日我跑一次weekly_report.py它会生成一份 Markdown 格式的周报内容包括本周刷题总数、通过数、各专题分布、每日一题完成情况。import sqlite3 from datetime import date, timedelta def weekly_report(days7): conn sqlite3.connect(buy_orange.db) cur conn.cursor() end_date date.today().isoformat() start_date (date.today() - timedelta(daysdays-1)).isoformat() # 本周总数 cur.execute(SELECT COUNT(*) FROM problems WHERE record_date BETWEEN ? AND ?, (start_date, end_date)) total cur.fetchone()[0] # 本周已通过数 cur.execute(SELECT COUNT(*) FROM problems WHERE statusac AND record_date BETWEEN ? AND ?, (start_date, end_date)) ac cur.fetchone()[0] # 专题分布 cur.execute( SELECT category, COUNT(*) FROM problems WHERE record_date BETWEEN ? AND ? GROUP BY category ORDER BY COUNT(*) DESC, (start_date, end_date) ) cats cur.fetchall() conn.close() lines [ f## 本周刷题报告{start_date} ~ {end_date}, , f- 刷题总数{total}, f- 通过数量{ac}, f- 通过率{ac/total * 100:.1f}%, , ### 专题分布, , | 专题 | 数量 |, | --- | --- |, ] for cat, cnt in cats: lines.append(f| {cat} | {cnt} |) print(\n.join(lines)) if __name__ __main__: weekly_report()统计口径上有一点要注意我的total统计的是“记录条数”不是“独立题目数”。如果一道题第一次卡住、第二次 AC就会产生两条记录统计出来总数偏大。后来我调整了一下用title link组合去重只保留最新状态。这个细节很重要不然周报里的通过率会失真。4. 实操过程从初始化到跑起来光贴代码不够我把完整上手流程写一遍方便你直接照着做。4.1 初始化数据库在项目目录下创建init_db.py把建表语句放进去运行一次python init_db.py如果没有报错同目录下会多出一个buy_orange.db文件。这就是全部存储了后续所有模块都连它。4.2 配置每日一题自动抓取我用的是系统cron做定时任务每天 8 点跑一次。打开 crontabcrontab -e加入这一行0 8 * * * cd /your/path/buy_orange /usr/bin/python3 fetch_daily.py fetch.log 21这里建议把 Python 路径写全避免 cron 环境变量里找不到解释器。fetch.log的作用是留痕第二天如果发现没有新记录先去翻这个日志八成能发现问题。如果你是 Windows 环境可以用“任务计划程序”或直接写一个.bat脚本效果类似。我的建议是先手动跑一次python fetch_daily.py确认能拿到数据以后再去配置定时任务别一上来就挂后台。4.3 手动记录刷题状态每天刷完题以后我用下面这个命令补充状态和备注python record.py --title 和为 K 的子数组 \ --category 前缀和 \ --status ac \ --note 用哈希表优化前缀和计数否则超时备注里我会尽量写关键点不写废话。比如“注意数组越界”比“这题难”有用一百倍。4.4 生成周报周日跑一次python weekly_report.py输出结果可以直接复制到自己的笔记软件里也可以加一个写入文件python weekly_report.py weekly_report.md我有段时间嫌麻烦直接在脚本里加了个 email 推送但后来发现效果一般因为我根本不会细看推送邮件。反而是手动跑一下再看一遍报告能起到强制的“自我盘问”作用。5. 常见问题与排查技巧实录这个项目看上去简单但实际用了几个月踩过的坑还是不少。下面按“现象 - 原因 - 解决”来整理方便你直接照着排雷。现象原因解决每日一题抓取后记录日期不对脚本用了服务器时区和本地时区差一天统一用datetime.date.today()或指定ZoneInfo别用localtime的默认值同一题重复记录多条每天跑一次抓取脚本重复插入没有做幂等控制在title record_date上建唯一索引或用INSERT OR IGNORE周报通过率虚高重复记录会导致分母和分子同时被撑大记录状态前先查最新状态或者统计时不累计同一道题的多个状态页面结构变化导致解析失败牛客前端更新了选择器解析失败时不报崩溃而是生成占位记录同时把异常写入日志方便人工修复数据库文件损坏同步工具在多台设备上同时读写同一个.db文件改成单一设备写入其他设备只读或者换用 SQLite 官方推荐的 WAL 模式定时任务没触发crontab 里 Python 路径不对或目录权限不足先手动执行完整命令验证再配置 cron必要时用绝对路径备注没有及时写人一懒就跳过备注命令行工具加参数校验没写备注时不拒绝记录但会弹一个黄色警告第二个问题最典型。我刚开始直接INSERT同一道每日一题因为 cron 重试被插了三条统计报表直接没法看。后来我在建表语句里加了一行约束CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_unique ON problems(record_date, title);这样同一日期、同一题只会保留一条记录。但注意这个索引对不同类型的题目也有效比如同一天刷了不同书里的两道同名题可以稍微调整索引字段比如把category加进去或者把约束改成只针对platformnowcoder的记录。关于数据库同步我也栽过跟头。我一开始把buy_orange.db放在网盘目录里想着家里和公司都能接着写。结果有一次同一个.db文件在两台设备上被同时打开加上网盘同步冲突直接产生了损坏记录。后来我的方案是只有一台主力机能写入其余设备只读或者干脆用导出的 Markdown 周报代替实时同步。个人项目千万不要为了“实时”给自己加太重的负担。6. 我对这个项目的一些实际体会项目真正跑起来以后我发现它改变的不是“刷题数量”而是“刷题节奏”。以前每日一题推送过来我经常刷一眼标题就想“今天算了”。现在每天 8 点它会自动出现在记录里状态是todo不刷掉就一直躺在那像我给自己种了一个需要定期除草的院子。最实用的是周报里的专题分布。有一周我发现自己频繁在动态规划上卡壳而数组、哈希表类的通过率明显高。这个统计以前只能靠“感觉”现在我能在数据里看到差距后面再去补专题时就有了明确顺序。我还给每个失败记录写了一句“下次注意”把带着情绪的失败变成了可执行的备忘。这个习惯也间接提高了刷题质量。以前标记 AC 就完事现在被迫写备注反而逼着自己去把题做得更明白。有时候一道题 AC 了很久回头看备注才发现当时的解法很绕。我还会把某些经典题的链接和备注内容汇总到一篇知识笔记里时间久了就是一份非常个人化的题库地图。如果再往后做我可能会给这个项目加两个功能一个是把每日一题的拉取改成一个浏览器插件一键把当前题目信息存进数据库免去解析页面的麻烦另一个是把每周的报表生成一张趋势图观察通过率随周变化的情况。对一个个人 tracker 来说这些功能已经不逼着自己追求“功能全面”只要它能在每天打开终端的那一刻提醒我“今天的题还在等你”就已经达到目的了。