牛客榜单追踪器与每日一题打卡:备赛效率自动化工具
GCPC总决赛备赛那阵子我整个人被牛客的榜单折磨得够呛。手机刷一遍网页电脑刷一遍网页十分钟不看就心慌生怕漏了哪个队的过题变化结果真正有效的训练时间反而被切得稀碎。后来我动手写了一个挂在牛客上的小工具名字就叫“GCPC总决赛【牛客tracker 每日一题】”核心功能就两个自动追踪比赛实时榜单变化外加把每日一题做成带打卡记录的刷题任务流。这个项目解决的实际问题说起来其实很朴素一是信息获取二是练习执行。榜单tracker把“盯排名”这件事自动化你不需要一直开着网页每日一题模块把备赛节奏固定下来每天至少做一道题做完打卡记录时间久了进度一目了然。适合的人群也很明确——正在备战系列赛、校赛、区域赛的个人选手或小队尤其是那种需要一边上课/上班、一边准备比赛的人。这篇文章我会把整个项目的设计思路、核心模块实现、踩过的坑都摊开讲代码能直接抄作业的就直接给。1. 项目定位与整体设计思路1.1 备赛过程中的真实痛点先说痛点。我在GCPC总决赛前一个月进入高强度备赛状态当时每天要做的功课有三个部分刷知识点、补薄弱专题、盯队伍排名。前两件事还好麻烦的是第三件。牛客这类竞赛平台的榜单页面是动态刷新的想看最新排名就得不停手动刷新高峰期经常刷出缓存数据要等好几秒才能看到真实排名。这中间浪费的时间看似不多但每天十几次、二十几次地刷累积起来非常可观。更麻烦的是榜单不是一个“安静”的信息源。有些队伍会在某个时间段集中过题有些队伍长时间不动你需要区分“暂时卡住”和“彻底放弃”两种情况。单纯靠肉眼盯很难捕捉到这种微妙变化。我当时的念头很简单写一个程序定时抓取榜单数据记录每次抓取时的排名、过题数量、罚时然后对比两次数据把变化自动标出来。这样我可以只在自己需要的时候看一眼汇总不需要一直盯着页面。1.2 为什么把tracker和每日一题组合在一起最开始我只想做一个榜单追踪器后来加每日一题模块是因为一个很现实的发现备赛群里每天都在转发牛客的每日一题链接但真正每天做的人寥寥无几。不是大家不想做而是缺少一个“记录机制”。做过的题没有留下痕迹今天做没做、连续做了几天、哪个专题做得少全靠脑子记记不住。于是我把每日一题做成了一个带打卡功能的模块。每天从牛客拿到当天的每日一题做完之后在工具里标记完成工具自动记录日期和题目信息。这样tracker负责回答“我们队伍现在排第几、谁在追赶我们”每日一题负责回答“我今天的训练任务完成没有”。一个管外部信息一个管内部执行两个模块合在一起才算是把备赛的闭环补完整了。1.3 整体架构一个轻量到不需要服务器的方案架构上没有搞复杂。整个项目是Python写的分三层数据层榜单数据用requests抓接口每日一题数据从牛客页面解析本地用SQLite存历史记录。业务层包含榜单对比逻辑、状态变化计算、每日一题打卡逻辑、进度统计逻辑。展示层最开始只有命令行输出后来加了一个本地网页面板用Flask起一个小服务手机上也能打开看。部署上我建议直接用一台云服务器或者就在自己电脑上定时跑脚本。项目本身不重CPU和内存占用可以忽略。之所以这样设计是因为我不想把这个工具做成一个“重系统”备赛期间时间有限工具应该花最少的时间维护用最少的心智负担去操作而不是反过来增加负担。提示如果你只是自己一个人用别一上来就写前端页面。先用命令行版本跑通逻辑后面再加面板这是最不容易烂尾的做法。2. 牛客榜单追踪器的核心设计与实现2.1 榜单数据是怎么拿到的牛客的竞赛榜单页面正常浏览器打开之后会看到很多实时数据但如果你直接去看网页源码会发现排行榜其实是通过接口动态加载的。找到这个接口是第一步。我用的方法很简单打开浏览器开发者工具切到Network面板刷新榜单页面找返回JSON数据的请求。牛客的榜单接口一般会返回一个包含rankData字段的JSON里面有每个队伍的排名、学校、过题数、罚时、每道题的提交状态和通过时间。拿到这个接口之后Python里用requests直接请求加上和浏览器一致的User-Agent就能稳定拿到数据。核心代码大概长这样import requests import json def fetch_rank_data(contest_id): url https://example-nowcoder-api.com/contest/rank params { contestId: contest_id, page: 1, pageSize: 300, type: 0, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) } resp requests.get(url, paramsparams, headersheaders, timeout10) data resp.json() return data[data][rankData]拿到JSON之后要做的就是字段映射。我自己建了一个小的数据类来统一表示一支队伍的榜单状态class TeamRank: def __init__(self, rank, team_name, school, solved, penalty, detail): self.rank rank self.team_name team_name self.school school self.solved solved self.penalty penalty self.detail detail # 各题提交情况这里有个值得提醒的点牛客的接口偶尔会变字段名比如有些版本里过题数叫solved有些版本里叫acNum。写代码的时候不要硬编码字符串满天飞最好在一开始定义一个常量映射表后续万一接口变了改一处就行。2.2 增量更新与状态变化计算榜单数据本身没有太大价值真正有价值的是“变化”。我每次抓取榜单之后会和上一次抓取的数据做对比计算出几个关键字段rankChange排名相比上次变化了多少。正数代表上升负数代表下降。solvedChange过题数量增量。penaltyChange罚时增量。最近过题本次抓取比上次多出来的题目编号。这个状态变化计算是tracker最核心的部分。实现思路不复杂用字典把队伍名映射到上次的榜单对象然后逐一对比def compute_changes(old_rank_map, new_rank_map): changes [] for team_name, new_rank in new_rank_map.items(): old_rank old_rank_map.get(team_name) if old_rank is None: changes.append({ team_name: team_name, rank_change: 0, solved_change: new_rank.solved, status: new_team }) else: changes.append({ team_name: team_name, rank_change: old_rank.rank - new_rank.rank, solved_change: new_rank.solved - old_rank.solved, penalty_change: new_rank.penalty - old_rank.penalty, new_solved_problem: find_new_problem(old_rank.detail, new_rank.detail), status: normal }) return changes有了这些变化数据就可以做可视化输出了。我当时的命令行输出长这样[12:30:01] 某队 rank 3 - 2 (↑1) 过题 5 - 6 [新过题: C] [12:30:01] 某队 rank 15 - 18 (↓3) 罚时 20你别小看这个输出它带来的信息密度比肉眼看榜单高得多。榜单每一秒都在变但你只需要关心那些“相对上一次抓取发生了变化”的队伍其他队伍不动就不管过滤掉了大量无意义信息。2.3 关注列表只关心自己和自己附近的队伍比赛队伍几十上百支如果每轮抓取都把所有队伍的变化打印出来信息量依然很大。后来我加了一个关注列表机制用户可以配置一组队伍名tracker只对这些队伍做详细变化展示其他队伍的变化只统计数量不打印细节。这个关注列表非常实用。我的用法是把自己队伍放进去再把排名在自己前后五名内的队伍放进去这样赛场上谁在追我、谁在被我校超越一目了然。实现上就是一个配置文件用JSON存关注队伍名单每次程序启动时读取。{ watch_list: [某队, 某队, 某队], watch_range: 5 }2.4 封榜时间的处理GCPC总决赛这类比赛有一个很常见的规则最后一段时间内榜单冻结不再对外显示实时排名。牛客的榜单接口在封榜后通常不会报错而是直接返回封榜时刻的数据。如果不做处理tracker会把这些“不变的数据”当作“没有变化”看起来一切正常实际上信息已经失真。解决方案是在tracker里加一个“封榜检测”逻辑。当队伍的关注列表里出现连续多次抓取数据完全一致、并且赛场已经临近官方公布的最后提交时间时自动标记为“疑似封榜”提示用户接下来的数据仅供参考。这个逻辑不复杂但很救命。我第一次用的时候就是没注意到这个赛后面试复盘时发现自己盯了一个“假榜单”还好没影响实际决策。注意封榜期间不要再用tracker的数据来调整策略这时候最该做的是把精力放在自己队伍还没过的那几道题上。3. 每日一题模块与打卡机制3.1 每日一题从哪里获取牛客的每日一题一般有固定的入口每天会更新一道题题面会附上难度标签和知识点标签。获取方式有两种一种是直接爬接口另一种是解析页面。优先用接口因为页面解析受HTML结构影响大牛客改版一次你的代码就要跟着改一次。我用的方案是每天定时请求牛客每日一题的接口拿到当天题目的标题、题号、难度、知识点插入本地数据库。如果当天接口暂时没返回数据就等待一段时间再试因为每日一题的更新时间有时候会在凌晨附近不一定是零点整就更新。3.2 本地SQLite设计与打卡流程本地数据库表结构设计得很简单CREATE TABLE daily_problem ( id INTEGER PRIMARY KEY AUTOINCREMENT, problem_date TEXT NOT NULL, problem_id TEXT NOT NULL, title TEXT NOT NULL, difficulty TEXT, tags TEXT, solved INTEGER DEFAULT 0, solve_time TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP );每天拿到题目后插入一条记录solved默认0。做完题之后运行打卡命令python tracker.py daily-check --problem-id 12345这个命令会把对应题目的solved更新为1记录完成时间。兑完打卡之后每日一题记录的连续天数就会更新。连续天数我单独建了一张表来记录因为连续打卡统计用单独的字段存更清晰。3.3 进度统计与可视化统计功能一开始只是打印一个简单表格后来发现光有表格不够直观。我就加了一个小面板本地网页上显示本月打卡日历类似GitHub贡献图每一天一个格子打了卡就变深色。专题分布每个知识点的做题数量条形图。难度分布简单、中等、困难各做了多少题。当前连续打卡天数。这个面板是锦上添花的东西但它的价值在于“看得见的正反馈”。坚持刷题最难的不是做题本身而是“坚持”这个状态看不见摸不着。一旦有了日历格子每天打卡就多了一点仪式感人是有这种心理机制的你看到连续天数在涨就不想断。3.4 每日一题和tracker如何联动两个模块不是孤立的。我设计了一个简单的联动规则每日一题打卡时如果当天有GCPC的比赛或者模拟赛tracker会把当日赛题数据里涉及的知识点自动标注到每日一题的tags里。这样一来赛后看打卡记录时能直观看到“我们今天练了图论结果赛场上图论题就卡住了”方便做赛题复盘。更实际的一个联动是状态提醒。每日一题打卡完成之后tracker会更新本地状态文件如果你部署了推送机器人还可以在手机收到一条打卡成功的通知。这个功能实现成本不高但非常提气尤其是你白天有事差点忘了刷题的时候机器人一句话能把你拉回来。4. 实操实录从0搭建一个可用版本4.1 环境准备与项目结构我在写这个项目的时候用的是Python 3.10需要安装的库只有requests、Flask和APScheduler。SQLite是Python自带的不需要额外装。整个项目的文件结构大致如下gcpc-tracker/ ├── config.json # 配置文件 ├── tracker.py # 主入口 ├── rank_fetcher.py # 榜单抓取 ├── daily_problem.py # 每日一题模块 ├── storage.py # SQLite操作 ├── panel.py # 本地网页面板 └── templates/ └── index.html # 面板模板4.2 榜单追踪器的关键实现榜单追踪器核心是一个死循环定时任务用APScheduler控制抓取频率。抓取频率我建议60秒一次别太频繁牛客的接口虽然没有严格的频率限制但人家是公开平台抓太频繁既不礼貌也可能被临时限流。主流程代码如下from apscheduler.schedulers.blocking import BlockingScheduler scheduler BlockingScheduler() scheduler.scheduled_job(interval, minutes1) def scheduled_fetch(): fetch_and_update() if __name__ __main__: scheduler.start()fetch_and_update函数负责完成一次完整的抓取-计算-存储-输出流程。存储时要注意历史数据不要丢至少要保存最近50次抓取的完整榜单这样赛后可以复盘整场比赛的名次变化曲线。4.3 每日一题定时检查与打卡命令每日一题模块我设置了三个定时任务每天7点检查当天每日一题是否已经入库没有就自动抓取。每天21点检查当天题目是否已打卡如果没打卡推送一条提醒。每周日21点生成本周刷题小结。打卡命令我上面已经写了这里不重复。提醒推送我用的是通用webhook机器人一条JSON POST就能发消息不需要额外引入重量级依赖。4.4 部署与长期运行的经验部署方面如果你有自己的云服务器直接把项目放到服务器上跑就行用nohub或者systemd管理进程。如果是本地电脑建议设置开机自启或者干脆像我一样在服务器上跑。这里有一个值得说的细节跑定时任务的时候要特别注意脚本报错后是否会静默退出。我的做法是写一个简单的错误捕获每次任务执行完记录一条日志一旦连续三次任务失败就发一条告警消息。这种“监控的监控”在备赛期很实用因为这个阶段你没有精力天天检查工具是否还在正常跑。5. 常见问题排查与避坑实录5.1 榜单接口请求失败或超时牛客的接口整体来说很稳定但高峰期偶尔会出现响应变慢的情况。我自己遇到过几次请求超时一开始以为是代码写错了后来发现是比赛开始时很多人同时刷接口服务器响应速度变慢。解决方案有两个层面一是代码层面给requests设置合理的timeout并捕获超时异常后等待下一轮再抓二是抓取频率适当降低从60秒改成90秒或者120秒比赛过程中没必要做到秒级追踪一两分钟内的变化完全够用。5.2 榜单数据字段缺失问题有一次接口返回的某支队伍数据里少了罚时字段直接导致程序抛KeyError。排查后发现这支队伍可能因为某些原因没有有效提交记录接口返回的数据结构就不完整。解决办法是写一个兜底函数所有字段都用get(x, 默认值)的方式读取缺失字段给一个安全的默认值。这个坑虽然不大但一旦崩了就是整个程序崩所以必须防。5.3 封榜检测逻辑误报封榜检测我最初是写死“连续3次数据不变就判定封榜”结果训练赛时出现过误报——有两次抓取间隔内场上所有队伍都没过题数据自然没变化脚本就误判为封榜了。改进后的逻辑加入了时间判断只有在比赛进行到最后30分钟时才启用封榜检测并且至少连续5次抓取数据完全一致才触发提示。这样基本不会误报了。5.4 每日一题“今日题目”获取不到每日一题接口偶尔会返回空数据因为牛客后台还没更新当天的题目。我的处理方案很朴素每天7点先试抓一次如果抓到空数据就等30分钟后再试最多试到中午12点。实测下来绝大多数时候9点前都能拿到当天题目只有极少数情况会拖到上午。5.5 本地网页面板手机打不开Flask面板跑起来之后手机浏览器访问不了通常是防火墙或者监听地址的问题。Flask默认监听127.0.0.1只能本机访问改成0.0.0.0之后局域网内就能访问了。另外如果你用的是云服务器记得在安全组放行对应的端口。我在本地跑的时候还遇到过一次端口被占用后来在启动命令里指定了一个不常用的端口问题就解决了。6. 实战复盘这个工具到底带来了什么整个GCPC总决赛备赛周期我用了这个工具大概四周时间。从数据上看连续打卡天数最高到过19天中途断了一次是因为比赛当天太晚忘记打卡第二天反应过来已经断了。排名追踪方面tracker记录了我们队伍从热身赛的第20名左右到总决赛正式比赛前半段的第9名整个变化曲线清晰可查。但我觉得更重要的是另一个变化我不再“频繁地刷榜单了”。以前每10分钟看一次现在每天打开面板两次上午看一次榜单变化小结晚上看一次打卡状态中间的时间全部用来做题。工具的作用是帮你把注意力从“盯排名”转移到“写代码”上这比排名数字本身的上涨更值钱。用的时候我也总结出几条经验不一定适合所有人但你可以参考关注列表不要设太多队伍5到8个足够。超过这个数量输出的信息又变成噪音了。每日一题打卡时间尽量固定固定时间容易形成习惯不容易断。tracker和每日一题分开用也可以但合在一起更容易坚持。因为两个模块互相提醒你对这个工具的依赖度会更高使用意愿也会更强。这类工具后续还有很多可以扩展的方向比如接入更多平台、把队友的提交记录也纳入分析、赛前自动生成模拟题单。但作为一次备赛工具我现在的版本已经足够满足需求了。如果你也在备战类似的比赛不妨从榜单追踪器开始动手写一个不一定非要做得功能齐全先把最困扰你的那个“信息焦虑”解决掉剩下的自然会有动力去完善。