ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

牛客每日一题追踪系统:从刷题到间隔重复复习

牛客每日一题追踪系统:从刷题到间隔重复复习 每天点开牛客网的每日一题做完顺手点掉页面第二天再打开一个新题继续、循环、周而复始。做过几十道之后我回头看发现自己除了“做过”这个事实之外几乎什么都说不出来某道题的思路早忘了错误原因没记录更别说隔段时间回头复习。这个问题我想了很多天最后决定不再靠意志力硬扛而是花一个周末做了个轻量追踪系统也就是你现在看到的“显生之宙”。“显生之宙【牛客tracker 每日一题】”这套东西本质上是把牛客网每日一题和一套记录、复习、统计机制粘在一起。每天它帮我取回当天的题做完之后花半分钟记录状态到点提醒我复习月底还能看到自己到底在哪类知识点上花了多少时间。它适合所有正在准备校招、社招面试、或者单纯想把刷题坚持下去的人尤其是那些和我一样不喜欢把时间耗在整理表格上、但又希望刷题过程能留下点痕迹的人。这篇文章我直接把整套项目的设计思路、核心代码、踩坑记录都摊开来讲你可以照着复刻一版也可以只参考其中几个模块把它接进你现有的笔记系统里。1. 为什么做显生之宙一个刷题追踪系统的起点先说一下背景。牛客网的每日一题板块本身是个很好的功能每天固定推一道题题目难度适中还附带解析和讨论区。它的天然优势在于“不用我选”系统替我做了决策我只需要打开、做题、看解析。听起来很有节奏感但用久了你会发现一个问题每日一题是“日抛”的今天做完明天系统推新题旧题就沉下去了。我自己深有体会。刷题这种东西做的时候觉得都会过两周遇到同思路的题却完全没印象。问题出在“没有复习机制”上收藏夹里存了一堆链接但收藏不等于复习尤其到了面试冲刺期旧题堆积得越多越不知道从哪儿看起。我之前试过在备忘录里记录也试过用文档表格管理效果都不好原因很简单手动维护成本一高人就会偷懒偷懒之后整个系统就废了。于是我在做显生之宙之前给自己立了几条底线。1.1 每日一题的“点开即忘”困境每日一题的内容本身不复杂通常包括题目描述、输入输出示例、难度标签和讨论区。可它最大的问题恰恰在于“太流畅了”你从打开到做完可能只要二十分钟全程没有任何一个节点逼你停下来想一想这道题考的是什么、我卡在哪一步、跟以前做过的哪道题类似。没有这些记录刷题量就只是一个数字。我见到过不少人牛客刷了两三百题笔试还是挂因为笔试考的从来不是“你做没做过类似题”而是“你能不能在现场调动已有的知识”。要做到这一点就得在平时刷题时把“思路”和“错因”沉淀下来而不是让题目从眼前滑过去。显生之宙的第一个设计动机就来源于此把每日一题从“日抛任务”变成一个“可沉淀的知识卡片”。每道题做完之后在 tracker 里标记状态、选好知识点标签、写下最多一句话的错因这样它就不再是一道消失的题而是一条会定期回到你眼前的复习记录。1.2 我对 tracker 的三条底线动手之前我明确了几条原则之后的每个设计决策都没脱离过它们。第一低维护。tracker 是为了辅助刷题存在的不是反过来给自己添负担。如果每天要花十分钟去整理表格再新鲜的劲儿也会在一周内消退。所以整个系统必须做到“每天打开就能用打卡操作不超过三秒”。第二数据要可视化。人都是视觉动物连续打卡多少天、这周完成了多少道题、哪类标签的题积累得最多这些指标能直接形成正反馈。不能光有记录没有统计不然坚持不下去。第三要能容错。我的生活不可能每天都规律地刷题加班、出差、临时有事都会打断节奏。所以这个 tracker 不能设计成“断一天就全毁了”那样只会加剧焦虑让人更想放弃。漏卡可以有复习队列可以堆积但要有一个宽限机制让系统能容忍人的不完美。这三条底线直接影响了我后来所有的技术选型。我放弃了搭建后端服务、放弃了实时多端同步这些看起来很酷的方案最终选择了一个“本地优先、半自动采集、全手动确认”的轻量架构。2. 显生之宙的设计思路与模块拆解这套系统我拆成了四层采集层、存储层、逻辑层、展示层。每层各司其职尽量做到单一职责。下面我从整体到细节逐个讲清楚。2.1 整体架构采集、存储、逻辑、展示四层采集层的任务是“把今天的题带进来”。每天早上我打开显生之宙首页它先展示今天的日期和一张空的今日卡片我点击“抓取每日一题”按钮它就会从牛客网的每日一题页面提取出题目标题和链接。这里我用了一个浏览器脚本辅助直接读取当前页面的 DOM 节点把标题和 URL 拿到后填入表单我再确认一下这条题目记录就进入存储层。存储层我选的是浏览器自带的 IndexedDB没有上后端。理由很直接一个每天只做一道题的 tracker没必要搞账号系统和服务端数据库数据全在本地打开网页就用不依赖网络也不用担心服务挂了丢数据。它会存两个主要数据表problems 存所有题目记录dailyLog 存每天的打卡日志。逻辑层是整个系统最核心的部分负责两件事状态流转和复习排期。状态流转是指每道题会经历“待做、已做、复习中、已掌握”四个阶段复习排期则是在题目进入复习中之后给它计算下一次复习日期。这套逻辑我采用了类似 Anki 的间隔重复思路但做了极大的简化后面详细展开。展示层则是一个纯静态页面包含今天的待办卡片、复习队列、连续打卡天数、热力图和标签统计。所有统计都是基于 IndexedDB 里的数据实时算出来的不需要额外维护。每一层之间通过清晰的数据结构衔接。采集层只负责产出题目对象存储层只管读写逻辑层不关心数据是从哪儿来的展示层只消费最终结果。好处是每一块都能单独替换比如以后如果牛客页面结构大变采集脚本废了我还可以用手动录入顶上其余部分不受影响。2.2 数据结构与标签体系数据结构是整个 tracker 的地基。我把题目记录设计成下面这样{ id: 2025-06-14-1852, date: 2025-06-14, title: 最长公共子序列(一), url: https://www.nowcoder.com/practice/xxxx, tags: [动态规划], difficulty: 中等, status: review, box: 2, dueAt: 2025-06-17, wrongCount: 1, notes: 二维 dp 初始化那行写错了边界是 dp[0][i] 0 }我给你逐字段解释一下为什么这么设计。id 我用“日期-题目编号”拼接牛客每日一题的 URL 末尾通常会带一串数字 ID这个组合能保证同一道题即使重复出现在不同日期也能被识别为同一题。date 记录这道题首次采集的日期用于按天归档和统计。tags 是知识点标签这是整个设计里最重要的东西。我建立了两级标签体系第一级是数据结构数组、链表、树、栈与队列、哈希表、堆、图第二级是算法思想双指针、二分查找、动态规划、回溯、贪心、排序、搜索。每道题最多选两个标签少了归到“模拟/其他”因为我发现标签超过两个就开始糊弄选三个以上等于没选。status 一共四个值todo、done、review、mastered。todo 是今天还没动过的题done 表示当天做完并记录了review 表示已经进入复习队列mastered 表示连续多轮复习都通过已经出栏不用再管。四态流转不搞复杂状态越少越容易坚持。dueAt 是下一次复习日期由逻辑层计算得出。notes 是真正的沉淀点我要求自己必须写点什么哪怕只有一句话。如果你懒得写字也可以填错原因总之不能让它空着。dailyLog 表长这样只存日期和一个完成标记{ date: 2025-06-14, done: true }后期统计连续打卡天数时只需要把这个表的数据取出来逐天比对就行。2.3 状态流转与复习排期算法复习是显生之宙和普通待办列表最大的区别。我实现了一个极简的 Leitner 盒子算法。Leitner 原本是一种用实体卡片盒复习的方法把卡片按照熟悉程度放进不同盒子越熟的盒子复习间隔越长答错了就退回更低的盒子重新来。我设定了五个盒子复习间隔分别是 1 天、3 天、7 天、14 天、30 天。规则是这样的一道题当天做完后自动进入盒子 1也就是明天要复习复习时如果这道题写对了就升到下一个盒子如果做错了直接掉回盒子 1同时错误次数加一当它从盒子 5 再升一级时状态变为 mastered之后不再进入复习队列。这个算法的好处是它不需要预测记忆曲线也不需要你给每道题打分只需要一个“对/错”的二元判断操作成本极低。代价是它不够精细但对我这种需求来说粗糙的正确远好过精细的放弃。核心代码我写成了这样const BOX_INTERVALS [1, 3, 7, 14, 30] export function nextReview(problem, pass) { if (!pass) { problem.box 1 problem.wrongCount 1 } else if (problem.box BOX_INTERVALS.length) { problem.box 1 } const days BOX_INTERVALS[problem.box - 1] const due new Date() due.setDate(due.getDate() days) problem.dueAt due.toISOString().slice(0, 10) problem.status problem.box BOX_INTERVALS.length ? mastered : review return problem }你仔细看pass 为 true 时盒子加一为 false 时盒子归 1。最后判断如果当前盒子已经等于数组长度 5说明这道题已经通过了盒子 5 的考核状态置为 mastered。我把 box 的初始值设为 0每天做完时调用一次 nextReview 把它推进盒子 1之后每次复习再做判断。这套机制我实测跑了两个月最大的感受是真正有效的不是盒子本身而是“每天打开 tracker 会看到今天的复习列表里有旧题”这个事实——它逼着我正视那些我以为会了、其实早就忘干净的东西。3. 核心实操从零搭一个牛客 tracker理论讲完说实操。我做这套东西的技术栈不复杂如果你对前端有一定基础照着搭一遍不会超过一个下午。3.1 技术选型与取舍过程我最初的方案其实是拿 Notion 建一个数据库模板加上公式字段算复习日期再用日历提醒。试了三天放弃了原因有二一是牛客每日一题和 Notion 之间隔了一层手动搬运每天把标题、链接、标签复制过去三次之后就烦了二是公式字段维护起来腰疼改一个逻辑得把所有属性捋一遍。之后我转向自己写页面。技术栈是 Vite Vue 3 IndexedDB没有任何后端依赖。之所以用 IndexedDB 而不是 localStorage是因为题目数据都是对象结构IndexedDB 有原生索引可以按日期、状态、dueAt 快速查询localStorage 只能存字符串看数据还得序列化和反序列化而且还有 5MB 容量的隐性上限刷两个月题加上统计历史就可能不够。我还配了一个小的采集方案在浏览器里装一个用户脚本访问牛客网题目页时脚本尝试读取页面的标题和当前 URL然后复制到剪贴板。回到 tracker 页面后点“新建题目”标题和链接就自动填入我只需要确认标签和难度。这一步从一开始我就没想做得全自动因为全自动意味着页面结构一变就挂手动确认虽然多花几秒但稳定得多。关于 PWA我顺手加了个 manifest让 tracker 可以“添加到主屏幕”在手机上打开时全屏显示就像一个原生 App。日常我并不会长期坐在电脑前很多时候是用手机刷题所以这个细节对我来说很关键。3.2 数据层与初始化代码IndexedDB 的初始化代码并不复杂但有几个坑值得提前说。我在 openDB 里顺手创建了 problems 表和 dailyLog 表并给 problems 表加了 date 和 status 两个索引方便后面按条件查询。const DB_NAME xiansheng-yuzhou const DB_VERSION 1 let db null export function openDB() { return new Promise((resolve, reject) { const req indexedDB.open(DB_NAME, DB_VERSION) req.onupgradeneeded (event) { const d event.target.result if (!d.objectStoreNames.contains(problems)) { const store d.createObjectStore(problems, { keyPath: id }) store.createIndex(date, date, { unique: false }) store.createIndex(status, status, { unique: false }) store.createIndex(dueAt, dueAt, { unique: false }) } if (!d.objectStoreNames.contains(dailyLog)) { d.createObjectStore(dailyLog, { keyPath: date }) } } req.onsuccess (event) { db event.target.result resolve(db) } req.onerror (event) { reject(event.target.error) } }) }这里有一个很容易踩的坑IndexedDB 的版本升级只能在 onupgradeneeded 里调整表结构。如果你已经用 DB_VERSION 1 建过库之后想加索引必须把版本号改成 2否则升级事件不会触发。我最初就是因为没改版本号加了新索引始终不生效排查了半小时才反应过来。写入一条题目记录也很直接export function addProblem(problem) { return new Promise((resolve, reject) { const tx db.transaction(problems, readwrite) tx.objectStore(problems).put(problem) tx.oncomplete () resolve() tx.onerror (event) reject(event.target.error) }) }需要注意的细节是 put 而不是 add。因为同一道题如果第二天又出现在每日一题里牛客确实会有周期性的旧题重推用 put 可以覆盖更新状态保留历史复习记录不会因为主键冲突而报错。3.3 取题脚本与打卡逻辑用户脚本的部分我写得很克制只做“尽力抓取”。核心逻辑如下// 在牛客题目页执行 const titleEl document.querySelector( .daily-question-title, .question-title, h1 ) const title titleEl ? titleEl.innerText.trim() : 未知题目 const link location.href const text ${title}\n${link} navigator.clipboard.writeText(text).then(() { alert(题目信息已复制请粘贴到显生之宙) })你可能会问为什么不用更稳定的方式比如直接调牛客的接口不是我做不到而是这种“爬接口”的方案有一个绕不过去的问题接口随时可能加鉴权、改参数一旦失效排查成本很高。而用户脚本读取页面 DOM即使牛客改版我只需要改选择器五分钟就能修好。页面里的打卡逻辑是整个 tracker 操作频率最高的动作。我把它做成一个清单页每天打开显生之宙首页从上到下依次显示三块内容——今日状态、待复习队列、历史统计。今日状态区有一行大字“今天的题做了吗”下面三个按钮标记完成、标记跳过、还没写。点击“标记完成”时系统会把今日题目的状态从 todo 改成 done写入 dailyLog 的当天记录然后自动调用 nextReview 把它推进盒子 1。整个过程三秒以内。待复习队列则是把 dueAt 小于等于今天的、status 为 review 的题目全部列出来。每题后面两个按钮掌握了、又错了。点“掌握了”就调 nextReview(problem, true)点“又错了”就调 nextReview(problem, false)然后页面自动刷新队列减少一项。这里我加了一个小细节复习完一道题之后页面不会弹任何“太棒了”之类的提醒而是直接静默刷新。我在实际使用中发现过度反馈会让人产生“我做了很多”的错觉刷题的成就感应该来自对题目的理解不能来自页面的吹捧。3.4 统计面板怎么算连击统计面板我做了三个模块连续打卡天数、年度热力图、标签分布。连续打卡天数的算法有一点需要想清楚如果今天还没打卡连续天数不应该断。很多打卡 App 当天没打卡就立刻显示 0这非常打击人尤其是晚上十一点多才想起来的时候。我的算法是如果今天有记录从今天往前数如果今天没记录从昨天往前数最多只宽限一天。function formatDate(d) { return d.toISOString().slice(0, 10) } export function calcStreak(logs) { const daySet new Set(logs.map((log) log.date)) let streak 0 let cursor new Date() if (!daySet.has(formatDate(cursor))) { cursor.setDate(cursor.getDate() - 1) } while (daySet.has(formatDate(cursor))) { streak 1 cursor.setDate(cursor.getDate() - 1) } return streak }这个“从昨天开始算”的逻辑你们别觉得是自欺欺人它实际上是给人留了一个缓冲时间。今天是周日晚上十一点我确实没刷题但昨晚刷了系统显示“连续 12 天”而不是冷冰冰的 0我会想的是“明天还来得及续上”而不是“毁了重来吧”。机制设计的细节决定了一个工具到底是助推器还是压力源。热力图参考了 GitHub Contributions 的样式每格代表一天颜色深浅表示当天做了几道题。这个模块对后端零依赖就是循环遍历 dailyLog 渲染色块但视觉反馈带来的激励比我想象中大得多看着绿色格子连绵不断人会本能地想维持它。标签分布用环形图展示统计当前 problems 表里非 mastered 状态的所有题目的标签出现次数。它能直接回答“我最近到底在学什么”这个问题我实测发现自己的标签分布严重偏向动态规划和链表因为每日一题本身就爱出这些但那段时间我的搜索算法几乎没练过。这个发现直接让我在做每日一题之外主动补了几道搜索题。4. 实操过程中的几个关键细节把整个系统搭出来只花了一个下午但真正让它稳定跑起来靠的是后面一段时间里不断调整的细节。这一节我说几个不亲自用不会发现的点。4.1 每日一题采集的“抓不到题”问题第一个挫折发生在搭完用户脚本的第二天。牛客网的每日一题页面当时正常显示但脚本抓到的是“求最长公共子序列(一)”这种标题我要的其实是题目本身的名字不是它所在专题的名字。后来我打开浏览器控制台逐个检查 DOM 节点发现不同的页面入口渲染结构不一样从题库进每日一题是一种样式从主页广告位点进去是另一种样式。我的解决方案是把选择器写成一个数组依次尝试直到哪个匹配到了就用哪个。上面代码里那条 querySelector 后面跟了三个类名就是为了应付这种“同站不同样式”的情况。这个坑给了我一个很深的教训不要试图做一个一劳永逸的全自动采集方案半自动才是最稳的。为什么因为全自动脚本一旦出错用户根本不会及时发现直到某天打开 tracker 发现数据全断才知道但问题的根因早就消失了。而半自动方案里标题和链接只是“填入表单”我每次都要肉眼确认一下任何异常都会当场暴露不会酿成大问题。4.2 打卡的低摩擦设计低摩擦是一个说起来容易做起来难的事情。最开始我的页面上打卡按钮放在了页面底部后来发现每次打卡都要滚动一整屏烦得要死。我重新设计后把今日状态区固定在页面顶部而且做成一直置顶。打开网页的第一个动作就是处理今天这题的状态。还有一个更隐蔽的细节对已完成题目的“撤销”操作。我最初没做撤销有一次手滑点错了只能手动改数据库心态直接崩。后来加了 undo 按钮用户销毁半小时内的标记。这个小功能不到十行代码但极大提升了安全感。说到底tracker 这种工具的门槛不在功能多少而在“每天打开它”是否足够顺滑。如果打开后还要思考“我先点哪儿”那它就活不过第一周。4.3 漏卡与空窗期处理第二个月我出差了一周整个 tracker 空转了 7 天。回来之后我面临一个现实问题复习队列里堆了十几道题全部 due这看起来像一笔巨债。我当时想了很多方案比如“按天分批补复习”但实际操作后发现完全没有必要。间隔重复算法本来就是容错的到期的题堆着你按照顺序一道道处理每道题都如实标记“忘了”或“记得”系统会自动重新安排节奏。这不是欠债这只是队列在排队。所以我没有做任何特别的“补卡机制”唯一做的调整是在展示层把复习队列按到期时间排序越早到期的排越前面。这样即使堆了一周我面前永远只有一列清单而不是一个巨大的红色警告。压制住那种“我必须补完”的焦虑让我对这套系统产生了真正的信任。工具不是用来惩罚人的它只是因为你的生活有间隙而如实记录仅此而已。5. 常见问题与排查技巧实录运行两个多月我陆陆续续遇到一些典型问题。这一节就当一份故障清单你如果复刻这套系统遇到类似问题可以直接抄答案。5.1 高频问题速查表问题主要原因处理办法取题脚本抓不到标题牛客页面结构改版打开控制台重新定位标题节点更新选择器实在不行就手动复制同一天出现多道每日一题每日一题入口不同导致重复以题目 URL 尾部的数字 ID 为准做主键重复时自动进入“更新”逻辑复习队列越积越多间隔太短答错的题频繁回来调整 BOX_INTERVALS 数组把第二盒间隔改成 3 天本地数据丢了浏览器清理了站点数据定期导出 JSON 备份具体导出方式见 6.2标签选得不准题目边界模糊导致误判初判用关键词表但保留手动修正入口30 秒内能改完手机上看不了数据没有部署线上环境做成 PWA添加到主屏幕或用局域网内访问开发服务器5.2 排查思路先看数据再猜代码我第一次遇到“统计面板的连击数显示 0”时第一反应是看代码逻辑哪里写错了。看了一个小时没看出问题后来发现是 dailyLog 里根本没写入今天的数据——打卡按钮的 onclick 事件绑错了元素压根没触发。从那以后我养成一个习惯任何功能异常先打开 IndexedDB 看原始数据确认数据层没问题再去查代码逻辑。这个习惯帮我省了大量排查时间。因为 tracker 的数据结构足够简单数据一眼就能看出对错比在代码里猜什么可靠得多。5.3 我的几条独家避坑技巧第一不要在构建工具上花时间。我最初用 Vite 配了一堆 eslint、prettier、自动部署结果真正写功能的精力被挤占。后来删掉了所有无关配置只留下构建和开发服务器。第二避免过度设计复习算法。我认识一个朋友参考我的思路自己写 tracker非要用复杂的 SM-2 算法给每道题记录记忆保持度、稳定性、难度系数。听起来高级但他每周要花半小时维护状态三周后放弃。我的 Leitner 五盒方案虽然粗糙可它足够的简单简单到我能坚持下来。工具的命比工具的精度重要得多。第三给每条数据都留一个“最后修改时间”字段。虽然日常使用中我基本不看它但有一次我发现某道题状态异常正是靠这个字段定位到是哪天哪次操作的产物直接回滚了数据。没有这个字段的话排查起来完全抓瞎。第四一定要把导出功能放在显眼的位置。不要想着“等需要了再加”等你真的需要恢复数据时才想起导出功能没做那就晚了。我用下来最稳定的备份方案是把 IndexedDB 里所有数据转成一个 JSON 字符串然后下载到本地再手动传一份到自己的笔记软件里双保险。6. 还能怎么玩从每日一题到完整刷题体系显生之宙做到现在已经不只是牛客每日一题的 tracker 了。它给我最大的启发是一旦你有了数据很多看似不相关的需求都能挂上来。6.1 复习队列接入日历提醒我后来把 dueAt 字段导出成 ics 文件导入到手机日历这样每天早上九点手机自动弹出“今天有 3 道题到复习期”。这个方案比在 tracker 内部做推送简单得多而且日历自带跨设备同步不需要我再做任何事。生成 ics 的代码逻辑很简单遍历 problems 表所有 status 为 review 的记录把 dueAt 转成事件的日期写一个标准格式的 ics 内容以 .ics 文件下载。这个功能我大概用了半小时就接上了投入产出比极高。6.2 多设备同步与备份本地存储的劣势是不能天然跨设备。我的解法不复杂在设置页留了一个导出按钮把刚才说的 JSON 备份字符串生成一个文件导入功能则反过来读取一个 JSON 文件后覆盖当前数据。实际操作中我把它和 WebDAV 网盘结合起来用。日常电脑上刷完题隔三差五导出一次 JSON传到网盘目录手机上打开 tracker 前先下载最新的 JSON 再导入。听起来有点手动但胜在完全自主、没有服务器成本而且从不出错。这件事可以做得更自动化但在一个周末项目的范畴里手动导出已经是性价比最高的路径了。6.3 从个人 tracker 变成小组 tracker显生之宙的名字里有“显生”这两个字我最初给它的定义是“让隐形的努力显形”。一个人用的时候它记录的是自己的学习轨迹如果把它开放给一个小组数据维度就丰富了。我给这套系统预留了一个简单的“组队模式”接口每天打卡时生成一条带日期和用户名的记录汇总到小组视野里每周互相看对方的完成率和复习队列长度。实际我只在内测时跑了两周效果是组内的人因为能看到彼此的进度客观上多了一种压力也多了坚持的理由。如果你身边有一起准备笔试的同学这个模式值得试试。但我要提醒一句组队模式对数据隐私的要求高很多如果只是个人练手不建议一上来就做社交功能。功能面上头坚持下来的概率反而更低。我自己用下来的体会是刷题这件事从来没有捷径好的工具也不会把难题变简单它能做的只是让你每次打开页面时清楚地知道下一步该干什么。而“一步步往前走”的感觉最终会变成一道额外的正反馈让你想把这个动作延续下去。如果你也在准备面试刷题又恰好被每日一题“刷过就忘”的问题困扰我建议你花一个周末搭一个类似的 tracker成本很低但之后几个月的收益是完全不一样的。
返回列表