资讯详情

洛谷题库离线下载:从爬虫抓取到SQLite本地知识库搭建全攻略

📅 2026/10/9 11:55:18 | 华诺云谱 👁 阅读
洛谷题库离线下载:从爬虫抓取到SQLite本地知识库搭建全攻略
简介洛谷OJ题库离线版收录了覆盖数据结构、算法、数学、逻辑等领域的编程竞赛题目难度跨度大面向入门巩固到进阶备赛的编程学习者可解决网络受限时无法在线查看题目内容的问题。压缩包共2000个文件以HTML题目页面为主整体44.6MB文件以洛谷题目编号命名每页通常包含题目ID、标题、完整描述、输入输出格式、示例测试用例、数据范围及提交入口说明部分页面还附有题解和讨论区内容离线也能按题号检索与参考。目前已有5946人学习/下载适合日常刷题、归类整理与赛前集中复盘。按需取出HTML文件即可在本地浏览题目便于自行建立专项训练目录也可以在无网环境中集中研究典型算法和解题策略是提升编程能力与准备竞赛的实用资料。1. 洛谷OJ题库下载这件事先搞清楚你想要的到底是题面还是刷题环境很多人搜洛谷_oj题库或洛谷oj网站第一反应是想把题库下载到本地离线刷题。但真正做过一次就会明白你需要的可能不是下载而是把题目变成自己能检索、能标记、能离线阅读的数据。如果只想要一份文本备份抓题面就够了如果想在本地提交代码、看判题结果那必须依赖洛谷OJ网站的在线判题服务本地模拟的成本高得多。下面这套方案面向前者手动爬取洛谷题库的公开题面结构化成JSON再导入SQLite最后生成离线网页和自己的刷题状态。不依赖任何官方接口只用最基础的Python库。算法竞赛备赛的同学、想摆脱网络依赖的刷题者、需要给题目做归档的爱好者都能直接照着做。我本人在模拟项目里用同样的思路跑通过稳定可控不玄学。2. 洛谷题库的数据结构从网页到 JSON 的拆解任何自动化下载的第一步不是写爬虫而是搞清楚目标页面的数据长什么样。洛谷的题目页面在浏览器里看是排版精美的HTML但真正有价值的是藏在页面源码里的结构化数据。我一般会先在浏览器开发者工具里打开Network面板找到题目详情对应的XHR请求看返回的JSON字段。这一步能省掉后面很多瞎猜。2.1 洛谷题目页里到底有什么题面、输入输出、标签、难度、题解洛谷OJ网站的每一道题核心信息可以分成四块。第一块是题号与标题比如P1001、P1002这是全站唯一标识也是爬虫的遍历粒度。第二块是题面内容包括题目描述、输入格式、输出格式、样例输入输出以及可选的提示和题目背景。第三块是元信息包括难度评级、标签如入门、普及/提高-、动态规划、搜索等、时间限制、内存限制。第四块是周边内容包括题解、提交记录、讨论区这些不属于题库本体体积大且更新频繁。我们下载题库时只需要抓前两块和部分元信息。题解可以后面按需抓取不建议一开始就全量保存。如果一股脑全抓本地文件很可能在几个小时内膨胀到几百MB而且大部分是重复的图片和代码块检索时反而碍事。洛谷的标签体系本身也很有价值它跨题目分类能帮你在不知道题号的情况下快速定位练习方向。我在实际抓取时会把下面这张表作为字段契约写进代码注释里避免过几天自己忘了。字段类型是否必抓说明pidstring是题号主键titlestring是标题difficultyint是难度等级tagsarray是标签contentstring是题面HTMLinput_formatstring是输入格式output_formatstring是输出格式samplesarray是样例对time_limitstring否时间限制memory_limitstring否内存限制backgroundstring否题目背景hintstring否提示2.2 构造一个最小化的题库字段模型在动手写脚本之前先定一个JSON schema后面所有代码都围着它转。我的最小模型长这样{ pid: P1001, title: AB Problem, difficulty: 1, tags: [入门], time_limit: 1.00s, memory_limit: 128.00MB, content: 题目描述正文, input_format: 输入格式说明, output_format: 输出格式说明, samples: [ {input: 1 2, output: 3} ], source: 洛谷OJ }这个模型里pid是主键difficulty用数值1到7对应洛谷的难度等级tags是标签数组content是整段HTML格式的题面。把samples设计成数组而不是字符串是为了后续能方便地做样例个数校验。所有字段都对应页面里真实存在的元素不虚构。有些题目还有background题目背景和hint提示可以合进content也可以单独存。我习惯单独存因为后续做全文搜索时背景和提示往往不是必需字段。多一个字段不增加多少成本但少一个字段后面想补抓就得重爬那才是真亏。另外time_limit和memory_limit最好存原始字符串比如1.00s而不是解析成数字因为有的题目写1s有的写1000ms统一成纯数字反而要额外处理单位换算。为什么用JSON而不是YAML因为Python标准库自带json而且洛谷页面里已有的数据就是JSON格式转换成本最低。YAML虽然可读性更好但需要额外装pyyaml缩进错了还会解析失败。对于题库这种嵌套结构JSON足够直观也便于直接入库。2.3 为什么不能直接复制网页需要结构化提取有的同学说我直接打开浏览器CtrlA全选复制到Word里不就行了功能上可以但你得到的是一大坨带样式的富文本不是数据。想统计某个标签下有多少道题想按难度排序想快速跳转到某一题的本地缓存都做不了。更麻烦的是复制的网页里常常夹带广告位、导航栏、推荐题目等无关内容清洗起来比写爬虫还累。结构化提取的价值在于把所有题目统一成同一种格式题号、标题、内容、样例都变成可编程访问的字段。之后无论是生成Markdown、导入SQLite还是做错题本都是几行代码的事。这个思路在任何一个OJ上都通用不只是洛谷。如果你之后想换到另一个OJ平台只需要改解析器数据模型和下游代码都不用动。我见过有人用浏览器另存为抓了100道题结果想按难度过滤时只能一个个打开文件人工看最后全部放弃。这就是没有结构化提取的代价。另一个反面教训是有人用正则直接从HTML里抠文字结果把样例中的和当成标签丢了导致题目数据不完整。正确做法是拿到JSON后统一处理HTML实体或者在渲染时交给HTML解析器去管。3. 写一个洛谷题库爬虫按题号抓取并保存为本地 Markdown既然知道了数据结构下一步就是用代码把每道题从洛谷OJ网站拉到本地。整个过程可以分为三步伪装请求、解析页面、落盘保存。下面这段脚本是我常用的模板你需要根据目标页面实际结构微调。注意整套逻辑只用于个人学习务必控制频率别给服务器添堵。3.1 准备工作请求头、会话、限速与伪装爬虫翻车的头号原因不是反爬而是你自己的请求太莽。我一般会先建立一个带重试能力的requests.Session把User-Agent设成一个常见浏览器的值再给请求加上超时和重试。代码import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def make_session(): session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }) retry Retry( total3, backoff_factor1, status_forcelist[408, 500, 502, 503, 504], allowed_methods[GET] ) adapter HTTPAdapter(max_retriesretry) session.mount(https://, adapter) session.mount(http://, adapter) return session session make_session()这里Retry里的backoff_factor1表示第一次重试等待1秒第二次2秒第三次4秒指数退避。我在status_forcelist里特意没放403因为403通常意味着频率限制或封禁重试只会加重问题不如直接停下来检查策略。allowed_methods只设置GET避免POST被意外重试。如果你在本地跑有代理干扰可以加一行session.trust_env False防止requests自动读环境变量里的代理配置。3.2 单题抓取脚本从列表页到详情页洛谷的题目页是一题一个URLURL结构是https://www.luogu.com.cn/problem/{pid}。所以抓取的核心就是对每个题号发起GET请求再解析返回的HTML。但问题在于洛谷页面是前端渲染的题目数据往往不是直接出现在HTML的DOM里而是嵌在script标签中的一个全局变量里比如window._feIni或者类似的字段。我常用的解析方式是先用正则把这段变量切出来再交给json模块解析。import re import json def fetch_problem(pid): url fhttps://www.luogu.com.cn/problem/{pid} resp session.get(url, timeout10) resp.encoding utf-8 if resp.status_code ! 200: raise RuntimeError(f{pid} 请求失败状态码 {resp.status_code}) html resp.text # 不同时期洛谷页面结构会变正则要跟着调整 match re.search(rwindow\._feIni\s*\s*({.*?});, html, re.S) if not match: # 再试试另一种字段名 match re.search(rwindow\.__INITIAL_STATE__\s*\s*({.*?});, html, re.S) if not match: raise ValueError(f{pid} 页面中没有找到可解析的数据) data json.loads(match.group(1)) # 数据层级需要根据实际返回调整 problem data.get(problem) or data.get(data, {}).get(problem) if not problem: raise ValueError(f{pid} 数据结构未命中需要查看页面字段) return problem注意正则切JSON最怕内容里出现};所以用非贪婪模式并且后面加了分号限定。如果这个正则失效不要硬调直接去浏览器开发者工具里复制实际的数据挂载点改成字符串查找也行。json.loads之前最好先print前200个字符确认一下是不是合法的JSON。有些页面还会把数据放在script typeapplication/json里那时用json.loads(script_tag.string)更稳。拿到problem字典后下一步是把它映射成我们定义的JSON模型然后保存成Markdown文件。我习惯每道题存一个.md文件名就是题号这样以后好找。def problem_to_markdown(pid, problem): lines [f# {pid} {problem.get(title, )}, ] lines.append(f难度{problem.get(difficulty, )} ) lines.append(f标签{, .join(problem.get(tags, []))} ) lines.append(f时间限制{problem.get(time_limit, )} ) lines.append(f内存限制{problem.get(memory_limit, )}) lines.append() lines.append(## 题目描述) lines.append(problem.get(content, )) lines.append() lines.append(## 输入格式) lines.append(problem.get(input_format, )) lines.append() lines.append(## 输出格式) lines.append(problem.get(output_format, )) lines.append() for i, sample in enumerate(problem.get(samples, []), 1): lines.append(f## 样例 {i}) lines.append(f输入\n\n{sample[input]}\n) lines.append(f输出\n\n{sample[output]}\n) lines.append() return \n.join(lines)这里把描述里的HTML标签原样保留等后面生成HTML时再处理。如果你想要纯文本可以在保存前先用正则去掉[^]。但注意洛谷的数学公式和代码块都依赖HTML结构删掉标签后内容会变得很难读所以我更建议保留HTML。上面的fetch_problem只处理了单题。实际抓取时我会写一个主循环从一个txt里读题号列表逐题调用。列表来源可以是洛谷题库页面的分页接口也可以自己维护一个题号范围。我一般会从P1000开始往上遍历遇到不存在的题号会返回404这时直接跳过。注意有些题号中间有跳号比如某些比赛专用题不在公共题库里所以不要用range闭区间硬算总数。import time import random def crawl_all(pid_list): downloaded load_downloaded() for pid in pid_list: if pid in downloaded: continue try: problem fetch_problem(pid) except Exception as e: with open(errors.log, a, encodingutf-8) as f: f.write(f{pid}: {e}\n) continue errors validate_problem(pid, problem) if not errors: md problem_to_markdown(pid, problem) with open(fmarkdown/{pid}.md, w, encodingutf-8) as f: f.write(md) mark_downloaded(pid) print(fdone {pid}) time.sleep(random.uniform(0.5, 1.5))3.3 断点续抓与增量更新用本地索引记录已抓题号洛谷题库有两千多道公开题一次性抓完需要不少时间。如果中途断网或程序崩溃没有断点记录的话之前的全白干了。我会用一个纯文本文件downloaded.txt记录已经成功的题号每抓完一题追加一行。下次运行先读这个文件跳过已有的。import os INDEX_FILE downloaded.txt def load_downloaded(): if not os.path.exists(INDEX_FILE): return set() with open(INDEX_FILE, r, encodingutf-8) as f: return {line.strip() for line in f if line.strip()} def mark_downloaded(pid): with open(INDEX_FILE, a, encodingutf-8) as f: f.write(pid \n)这套逻辑虽然简单但很实用。它还有一个好处如果你想增量更新比如洛谷新出了题目只要把新题号加入一个waiting.txt队列脚本会自动只抓没抓过的。如果哪天想全量重抓删掉downloaded.txt再跑一遍就行这就是后悔药。注意mark_downloaded是在成功写入Markdown之后调用不要放在请求成功后就执行否则写入失败时索引会误记。3.4 数据校验检查题面是否完整、样例是否对齐抓下来的数据不等于可信数据。我遇到过标题正常、题面却是空的的情况也遇到过样例只有输入没有输出。所以保存之前加一道校验def validate_problem(pid, problem): errors [] if not problem.get(title): errors.append(缺少标题) content problem.get(content, ) if len(content) 10: errors.append(题面过短) samples problem.get(samples, []) if not samples: errors.append(没有样例) for idx, sample in enumerate(samples): if input not in sample or output not in sample: errors.append(f样例 {idx1} 字段不完整) return errors校验不通过的题不要入库单独写进failures.log方便后面人工处理。宁可少抓一题也不要让脏数据混进本地题库。脏数据会在后面的搜索和错题本环节污染整个结果。校验逻辑也可以扩展比如检查content里的HTML标签是否闭合、样例输入与输出是否都有非空内容这些细节遇到一次坑就会知道有多值。4. 把抓回来的题库变成可检索的本地知识库抓下来的Markdown文件适合阅读但想按难度、标签组合筛选还是要导入数据库。SQLite是绝佳选择单文件、零配置、支持SQL查询而且Python自带支持。4.1 用 SQLite 存结构化字段题号、标题、难度、标签先把所有Markdown文件重新解析成结构化记录再写入SQLite。这里我用problem_to_markdown的反向操作直接从之前保存的JSON原始数据读取更稳。如果当时只存了Markdown那就得额外写一个解析函数。为了避免这个麻烦我建议抓取时既存Markdown也存一份原始JSON只占一点空间后续处理省心非常多。建表语句CREATE TABLE IF NOT EXISTS problems ( pid TEXT PRIMARY KEY, title TEXT NOT NULL, difficulty INTEGER, tags TEXT, content TEXT, input_format TEXT, output_format TEXT, samples TEXT, downloaded_at TEXT );对应的Python插入代码import sqlite3, json, datetime conn sqlite3.connect(luogu.db) conn.execute( CREATE TABLE IF NOT EXISTS problems ( pid TEXT PRIMARY KEY, title TEXT NOT NULL, difficulty INTEGER, tags TEXT, content TEXT, input_format TEXT, output_format TEXT, samples TEXT, downloaded_at TEXT ) ) def save_problem(conn, problem): now datetime.datetime.now().isoformat() conn.execute( INSERT OR REPLACE INTO problems (pid, title, difficulty, tags, content, input_format, output_format, samples, downloaded_at) VALUES (?,?,?,?,?,?,?,?,?), ( problem[pid], problem[title], problem.get(difficulty), json.dumps(problem.get(tags, []), ensure_asciiFalse), problem.get(content, ), problem.get(input_format, ), problem.get(output_format, ), json.dumps(problem.get(samples, []), ensure_asciiFalse), now ) ) conn.commit()注意tags和samples都序列化成JSON字符串因为SQLite没有数组类型。查询时需要反序列化这有点麻烦但习惯就好。INSERT OR REPLACE保证同一题号重复插入时是更新而不是报错。如果你担心REPLACE会改变downloaded_at可以先查一下记录是否存在再决定用UPDATE还是INSERT。4.2 全文搜索与标签筛选用 SQL 和简单的 Python 查询本地题库的杀手级功能是组合筛选。比如我想找难度在普及/提高-左右、且带搜索标签的题直接一句SQLSELECT pid, title, difficulty FROM problems WHERE difficulty BETWEEN 2 AND 3 AND tags LIKE %搜索% ORDER BY difficulty ASC, pid ASC;tags存的是JSON数组字符串所以用LIKE %搜索%能匹配到但会有边缘情况比如匹配到搜索基础或深度优先搜索等。如果要做精确标签匹配最好把标签拆成单独的表。但对于个人刷题LIKE已经够用。需要注意SQL里的%是通配符用参数化查询时别漏了转义。再加一个关键词搜索直接搜题面内容keyword 背包 cursor conn.execute( SELECT pid, title FROM problems WHERE content LIKE ? OR title LIKE ?, (f%{keyword}%, f%{keyword}%) ) rows cursor.fetchall()这里用参数化查询防止SQL注入虽然是本地库但好习惯不能丢。如果你需要更复杂的全文搜索可以引入SQLite的FTS5虚拟表但对题库这种体量LIKE已经够快。我还经常用一条聚合SQL看难度分布SELECT difficulty, COUNT(*) FROM problems GROUP BY difficulty ORDER BY difficulty;这能帮你直观看到题库里哪个区间的题最多制定刷题计划时心里有数。4.3 生成离线网页版题库用 Jinja2 渲染题目卡片数据库里的数据要变成可读的界面最方便的方式是用Jinja2渲染静态HTML。我一般会生成一个index.html每道题渲染成一张卡片点击展开看题面。模板核心部分如下!DOCTYPE html html langzh-CN headmeta charsetUTF-8title本地题库/title/head body {% for p in problems %} div classcard h2{{ p.pid }} {{ p.title }}/h2 p难度{{ p.difficulty }} | 标签{{ p.tags }}/p details summary查看题面/summary div{{ p.content | safe }}/div /details /div {% endfor %} /body /htmlPython侧渲染from jinja2 import Template template Template(html_template) html_output template.render(problemsrows) with open(index.html, w, encodingutf-8) as f: f.write(html_output)这里content字段直接用了| safe前提是我们信任自己抓取的数据。如果题面里混入恶意脚本本地打开也可能出问题所以渲染前最好用bleach之类的库清洗一遍HTML标签只保留p、pre、code、h2等白名单标签。对于个人题库简单方式是用正则把script和iframe整段删掉再放进模板。4.4 用 CSV 导出与快速迁移SQLite文件可以拷贝走但如果你想把题库分享给别人或导入其他工具CSV最好用。给代码import csv def export_csv(conn, pathluogu_problems.csv): rows conn.execute(SELECT pid, title, difficulty, tags FROM problems).fetchall() with open(path, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([pid, title, difficulty, tags]) writer.writerows(rows)这里用utf-8-sig而不是utf-8是因为Excel打开CSV时如果文件头没有BOM中文会乱码。newline是避免writerows在Windows下写入多余空行。如果你要把整个数据库迁移到另一台机器直接拷贝.db文件就行SQLite没有服务端依赖这是它最大的优势。5. 洛谷题库下载避坑指南反爬、编码、登录态与误删这一章全是踩坑记录每条都是我或者周围朋友真实遇到过的。按现象、原因、解决三步写方便你对照排查。如果你只打算记住一章那就是这一章。5.1 现象抓了几百题后突然返回 403抓得正爽的时候突然连续几个请求都返回403日志刷屏。我当时以为是IP被封换了代理也没用后来才发现是把请求间隔设成了0触发频率限制。原因服务器不是无差别封禁而是短时间请求过多。单个IP的阈值通常很低每秒超过1-2次持续一段时间就会触发。403响应里往往没有明确说明但多半是限流。解决把每次请求后的sleep(1)改成随机延迟比如random.uniform(0.5, 1.5)。同时把Retry里的403去掉因为403重试只会加重封锁。如果已经触发停下来等几分钟再继续。最关键的是要有一个退避机制连续失败N次就自动暂停10分钟。import time, random def polite_get(url, session, max_failures5): failures 0 while True: resp session.get(url, timeout10) if resp.status_code 200: return resp failures 1 if failures max_failures: print(连续失败暂停600秒) time.sleep(600) failures 0 else: time.sleep(random.uniform(0.5, 1.5))这种延迟看起来很笨但很有效。洛谷的题库更新不频繁你没必要抢在一小时内抓完拉长到两天抓完服务器压力小你的IP也更安全。5.2 现象题目详情拿到的是乱码或空题面请求返回200但resp.text解析出来是乱码或者content是空字符串。问题多半出在编码识别上。原因requests从响应头里猜编码如果服务器给的字符集不明确就会用默认的ISO-8859-1然后中文全变乱码。洛谷的HTML声明的是UTF-8但某些响应头可能没带charset。解决拿到响应后强制设置编码resp.encoding utf-8 html resp.text如果强制后还是乱码检查是不是被Gzip压缩了但没解压。requests默认会解压但如果用了底层的urllib3或者代理可能拿到压缩流。可以在请求头里加Accept-Encoding: gzip, deflate并让requests自己处理。另外如果页面是异步加载第一次请求返回的HTML里可能没有完整题面需要额外请求一个接口这种情况就要抓包看接口URL了。5.3 现象登录后仍然看不到全部题面有些题目需要登录甚至付费才能查看完整题面。你明明已经在浏览器登录了洛谷但脚本发请求时没带Cookie拿到的是未登录版本。原因脚本和浏览器是两个独立会话。服务端通过Cookie识别登录态脚本必须使用同一个Cookie才有效。解决从浏览器开发者工具的Network面板里复制任意一个请求的Cookie头放进脚本的session.headers里。注意Cookie经常过期长期批量抓取的同学要考虑定期更新。更稳妥的做法是用requests.Session先登录一次把账号密码通过POST表单提交但洛谷的登录流程可能涉及验证码自动化成本高所以直接借用浏览器Cookie更省事。session.headers[Cookie] your_copy_cookie_here把Cookie写死在代码里有个坏处一旦过期脚本会全部失败。我一般会把Cookie放到环境变量或独立配置文件里方便替换。5.4 现象下载中途断网重启后重复抓取脚本跑了一半家里路由器跳动一下TCP连接全断。重启脚本后它从第一题重新开始之前抓了500题全都白费。原因没有持久化的断点记录进程一退出内存里的状态全没了。解决用前面介绍的downloaded.txt每成功一题就追加一行。重启后先load_downloaded()跳过已有题号。我习惯每抓10题打印一次进度同时把当前进度写到一个progress.txt万一程序崩溃也能从日志里知道抓到哪了。def load_downloaded(): ...这个方案的另一个好处是可以多进程并行把题号列表拆成多份每个进程用独立的downloaded_{id}.txt最后合并索引。不过洛谷的题库量不大单进程加断点完全够用并行反而容易触发限流。5.5 现象把题解也抓进来后文件暴涨本来只想抓题库结果脚本里的URL解析逻辑写得太宽把题解页面的图片、代码、评论全抓下来了。题库目录从几MB涨到几百MB而且大部分是重复资源。原因题解页面的URL和题目详情页很相似正则解析时没有做严格的前缀过滤。很多题解里嵌入的图片是外链直接下载后空间消耗翻倍。解决抓取时只允许/problem/路径的URL遇到其他路径直接跳过。题解图片保留原链接不下载到本地。如果已经抓了用文件名特征把题解目录删掉重来。后悔药就是断点文件删掉不必要的目录后重新跑一次增量更新即可。具体做法是在主循环里加一层URL白名单过滤if not url.startswith(https://www.luogu.com.cn/problem/): continue6. 进阶玩法用本地题库搭建自己的刷题打卡与错题本抓题库不是终点利用题库才是。我最近把自己这套本地题库和刷题状态管理结合起来效果还不错。6.1 给每道题打上已刷/未刷/重刷状态在problems表里加一个字段conn.execute(ALTER TABLE problems ADD COLUMN status TEXT DEFAULT 未刷)刷完一题后更新状态def set_status(pid, status): conn.execute(UPDATE problems SET status? WHERE pid?, (status, pid)) conn.commit()状态值我习惯用未刷已刷重刷三个词简单直白不需要再建一张状态表。6.2 生成每日刷题计划的简单调度从未刷题目里随机抽N道按难度加权避免全抽简单题。import random def daily_plan(conn, n5): rows conn.execute( SELECT pid, title, difficulty FROM problems WHERE status未刷 ORDER BY RANDOM() LIMIT ?, (n,) ).fetchall() return rows如果你不想完全随机可以把难度作为权重比如难度3的题占三份难度1的题占一份。先按难度分组取样再合并会更贴近实战需求。6.3 把错题导出成 Markdown 报告对于那些做错的题标记为重刷然后生成一份带题号和标签的清单def export_wrong_report(): rows conn.execute( SELECT pid, title, tags FROM problems WHERE status重刷 ORDER BY difficulty ).fetchall() with open(wrong_notes.md, w, encodingutf-8) as f: f.write(# 错题集\n\n) for pid, title, tags in rows: f.write(f- [{pid}] {title} (标签: {tags})\n)另外我还会定期用SELECT count(*) WHERE status未刷统计剩余题数再把近三天的新增错题单独输出。这个报告不用做得很复杂纯文本或者Markdown就行关键是让它成为每天打开终端的第一件事。以前我盲目追求全量抓题结果数据健康度很差后来改成抓一题校验一题再按状态维护反而稳定多了。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑