简介:洛谷OJ题库离线HTML合集,面向算法竞赛学习者、编程爱好者和备战各类程序设计竞赛的考生,旨在帮助读者在无稳定网络或需要集中精力反复揣摩题目时,依然能够系统刷题、深入分析算法思路。压缩包共2000个文件,主体为HTML格式的题目页面,整体大小44.6MB;每道题对应一个独立网页,包含题目编号、标题、详细描述、输入输出格式、样例测试、数据范围及代码提交接口,部分题目还附有社区题解。目前已有5946人学习下载,内容覆盖数据结构、算法、数学、逻辑等多个领域,难度梯度完整,适合从入门到进阶的各类选手。借助这套离线题库,读者可以自由检索和整理题目,进行专题训练与复习;在无网络环境下也能专注推演算法、查看提交接口说明,是备战蓝桥杯、NOIP、ACM等竞赛的实用参考资料。
1. 某OJ题库下载:为什么老手也在把题库离线化
很多人在某OJ题库上刷题,都是从“打开网页、读题、交代码”开始的,从没想过要把题库下载到本地。直到某天遇到弱网、机房维护或者想在路上刷几道题,才发现整个OJ网站不是随时都能访问。把题库离线化,听起来像在复制几百个网页,但真正值钱的不是那堆HTML,而是你围绕题库建立的本地结构和更新节奏:能离线看题、能按难度标签检索、能断网复习,这些才是老手愿意折腾的理由。
这篇文章面向三类人:准备算法岗面试、每天需要稳定刷题量的;带学生训练、需要按难度和标签组题单的;以及网络不稳定、想把常用OJ题库备份一份的。我会从方案选型讲起,落到可运行的Python抓取脚本,再讲清洗和避坑,最后收在增量更新和刷题闭环上。这套做法我迭代过好几次,最早也翻过车——一次性全量抓取,结果被限流,硬盘里堆了几千个没清洗的垃圾网页。后面改成“先验证、再小步增量”才稳定下来。
2. 先把方案想清楚:四种离线题库做法与选型对比
很多人第一反应是把网页另存为,或者用浏览器打印成PDF。这个思路在题目量少于50道的时候完全够用,但一旦超过几百道,文件命名、图片缺失、目录检索都是麻烦。离线题库这件事,选型比动手更重要,因为方案决定了你要踩的坑有多大。下面这四种做法我都见过人用,各自边界不太一样。
2.1 手动保存网页与浏览器打印:适合应急,不适合规模化
手动保存单个题目页是最直觉的做法,在浏览器里Ctrl+S,把HTML和图片一起存下来。优点是完全不写代码,打开什么就存什么。缺点是文件命名完全靠手,一旦题目量上来,找题目等于翻硬盘。另一个问题是不少OJ的题目页带有动态加载的内容,另存为得到的HTML可能缺题面,样例数据也容易丢。
浏览器打印成PDF比另存为好一点,因为渲染完整,排版固定,适合自己刷题后留档。但它有两个硬伤:一是PDF里的公式、代码块在换设备后显示效果不稳定;二是无法检索,想找出“所有难度为中等、标签是动态规划的题”,只能靠肉眼翻。所以手动方案更适合临时保存几场比赛的题目,或者一次性准备某场面试的十道经典题,不适合作为长期题库方案。
2.2 爬虫批量抓取与官方接口:批量化作业的两条路
如果题目量超过了300道,手动方案的时间成本就高到不划算了。这时一般会考虑两条路:官方接口和爬虫。官方接口是最省力的,但很多OJ平台不会提供完整的题库导出接口,有的只有单题查询,有的干脆没有开放入口。去翻robots.txt和开发者文档,经常发现接口只对站内工具开放,个人用户拿不到完整列表。
爬虫则是更通用的做法,也是本文后面要展开的路线。它的核心逻辑很简单:按题号请求题目页、解析HTML、抽取题面和样例、落盘成结构化文件。难点在于站点反爬、页面结构变化、公式和图片离线后失效。爬虫方案的上限很高,因为清洗和索引一旦做好,就拥有一份可持续维护的私人题库,不限平台、不限网络。代价是前期调试成本,以及要时刻守住合规边界。
2.3 方案对比表与我的推荐
| 方案 | 适合规模 | 维护成本 | 主要风险 | 一句话选型建议 |
|---|---|---|---|---|
| 浏览器另存为 | 50道以内 | 中,手维护文件名 | 动态内容缺失、不可检索 | 仅应急,不要作为长期方案 |
| 浏览器打印PDF | 100道以内 | 中高,PDF难合并检索 | 公式渲染漂移、体积膨胀 | 适合临时组题,不适合建库 |
| 爬虫+本地结构化 | 300道以上 | 低,脚本复用 | 反爬、页面改版、合规边界 | 想长期维护题库的正路 |
| 官方接口 | 依平台而定 | 低 | 接口开放范围不确定 | 有接口先试接口,没有就爬 |
我的推荐很直接:先查目标OJ有没有官方接口,有就直接用;没有或者接口覆盖不全,就上爬虫,但不要一上来就追求“全量一次性抓完”。最佳实践是先做20道题的迷你流水线,验证解析逻辑能扛住不同类型的题目,再扩展全量。另外,无论用哪种方案,都要把“合规”当作用例之一:只做个人学习备份,不公开传播题库文件。这一步做对了,后面才不会白忙。
3. 最小可用题库抓取:按题号爬题目的Python脚本与参数调整
这一章直接给可运行的方案。我习惯用Python的requests加BeautifulSoup组合,不引入重量级框架,因为题目页结构并不复杂,核心就三步:构造请求、解析HTML、落盘。以下代码基于一个通用的OJ题目页结构,实际使用时替换选择器即可。先别急着全量跑,把本节最后的小批量验证方法用上,能省掉后面大量返工。
3.1 抓取前的准备:robots约束、请求头与访问节奏
正式写爬虫之前,先看一眼目标站的robots.txt,确认哪些路径被禁止访问。这不是走形式,而是给自己划红线。很多OJ站点允许访问题目详情页,但禁止高频抓取。下面这段代码是请求会话的基础配置,把UA换成主流浏览器的,加上重试和超时,避免一次网络抖动就让整个任务中断。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() # 模拟浏览器UA,很多OJ会直接屏蔽默认的python-requests session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36", "Referer": "https://oj.example.com/problems", "Accept-Language": "zh-CN,zh;q=0.9", }) # 对429/5xx做指数退避重试,backoff_factor=1表示第n次重试等待2^n秒 retry = Retry(total=3, backoff_factor=1.0, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter)这段配置里有几个参数值得说明。total=3是最大重试次数,不要调太高,否则遇到持续性限流时,单题会卡住几十秒;status_forcelist限定了只有这几种状态码才触发重试,404和403不重试,因为重试也大概率是同一个结果;backoff_factor=1.0控制重试间隔,配合后面的sleep,能让请求节奏更接近人的操作习惯。
3.2 主脚本:列表爬取、详情解析与JSON落盘
请求会话准备好之后,写核心的抓取函数。常见题目页的正文容器是.problem-content,标题在h1里,难度和样例分别在.difficulty和.sample块里。你会发现不同OJ的类名差异很大,所以建议先用浏览器开发者工具确认一到两个页面的结构,再写选择器。下面代码里保留了正则兜底逻辑,防止个别页面漏掉标签。
from bs4 import BeautifulSoup import json import time import random BASE_URL = "https://oj.example.com/problem?id={pid}" def fetch_problem(pid): url = BASE_URL.format(pid=pid) resp = session.get(url, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") main = soup.select_one(".problem-content") if main is None: return None # 题目不存在或页面结构变更,交给调用方决定 title_el = main.select_one("h1") diff_el = main.select_one(".difficulty") statement_el = main.select_one(".statement") # select()取多个样例,题面里如果嵌套了公式标签,保留原始HTML samples = [el.get_text(strip=True) for el in main.select(".sample")] return { "pid": pid, "title": title_el.get_text(strip=True) if title_el else "untitled", "difficulty": diff_el.get_text(strip=True) if diff_el else "unknown", "statement_html": str(statement_el) if statement_el else "", "samples": samples, "fetched_at": time.strftime("%Y-%m-%d %H:%M:%S"), }这里有几个细节容易忽略。resp.encoding = "utf-8"必须在解析前指定,否则某些没有charset声明的页面会被requests猜错编码,中文题面变成乱码。statement_html保存的是原始HTML而不是纯文本,目的是保留公式标记和代码块的样式,方便后面转Markdown时二次处理。fetch_problem返回None的情况要单独处理,不要直接抛异常,因为题目删除或隐藏是常态。
3.3 先跑5题再全量:验证解析正确性的小技巧
全量抓取之前,先拿5道不同题型的题跑一遍,是我反复踩坑后养成的习惯。为什么是5道?因为太少覆盖不了题型差异,太多浪费调试时间。选题时尽量覆盖:纯文本题、带公式的题、带大量样例的题、有图片的题。跑完打印出JSON的关键字段,重点看题面有没有被截断、样例有没有缺项。
test_pids = [1001, 1002, 1003, 1004, 1005] for pid in test_pids: data = fetch_problem(pid) if data: print(pid, data["title"], data["difficulty"], len(data["samples"])) # 把单题先落盘,检查statement_html里是否有异常闭合 with open(f"raw/{pid}.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) else: print(pid, "MISS") # 随机间隔0.5~1.5秒,避免请求节奏过于规整被识别 time.sleep(random.uniform(0.5, 1.5))验证通过后再去拿题目列表。这步要特别注意,不要用range(1001, 9999)这种连续区间去猜题号,OJ的题号往往有断档。正确的做法是请求题目列表页或题单接口,从里面解析出全部有效题号,存成一个pids.txt,每行一个编号。后面不管是全量抓取还是增量更新,都基于这份列表,而不是拍脑袋的连续编号。
4. 抓下来只是开始:HTML转Markdown与本地索引生成
原始HTML落盘只完成了问题的一半。直接打开这些文件,你会看到导航栏、相关推荐、页脚脚本,还有一堆不该出现的噪音。这一章解决两件事:把HTML清洗成干净的Markdown,再建立一个可以用SQL查询的本地索引。清洗工作做好了,题库才真正变成“自己的东西”。
4.1 去掉导航和广告:正文容器的定位与抽取
在清洗之前,先明确哪些是正文。用select_one(".problem-content")把正文容器取出来之后,里面仍然可能混着编辑器的工具栏、标签按钮等干扰信息。我的做法是先在容器内部做一次“剪枝”,把script、style、nav、aside这些无关节点直接移除。
import re def clean_container(soup): # 复制一份,避免影响后续解析 node = soup.select_one(".problem-content") if node is None: return None for tag in node.find_all(["script", "style", "nav", "aside", "footer"]): tag.decompose() # 移除空段落,很多模板会留一堆占位p标签 for p in node.find_all("p"): if not p.get_text(strip=True) and not p.find("img"): p.decompose() return node移除节点时用decompose()而不是extract(),两者的差别在于前者会连带把标签内部的所有内容清掉,后者只是把节点移走,子节点还可能残留在内存里。空段落处理也是必要操作,否则转Markdown后会出现大段空白,影响后续阅读体验。判断空段落时注意保留含有图片的段落,否则有图题会被误删。
4.2 清洗与落盘:从HTML到Markdown的转换脚本
清洗完节点之后,写一个轻量级的HTML转Markdown函数。不引入外部库也能覆盖大部分场景:标题转#,段落直接取文本,代码块保留缩进,链接转成[text](href)。重点是把题目里的换行和缩进处理好,不要让题面挤成一行。
def html_to_markdown(node): md_lines = [] for elem in node.descendants: if elem.name in ("h1", "h2", "h3"): level = int(elem.name[1]) md_lines.append("#" * level + " " + elem.get_text(strip=True)) elif elem.name == "p": text = elem.get_text(strip=True) if text: md_lines.append(text + "\n") elif elem.name == "pre": code_text = elem.get_text("\n", strip=True) md_lines.append("```\n" + code_text + "\n```\n") elif elem.name == "img": # 图片地址转本地路径,这一步在下载图片时替换 src = elem.get("src", "") md_lines.append(f"") elif elem.name is None: # NavigableString text = str(elem).strip() if text: md_lines.append(text) return "\n".join(md_lines)这个转换函数的关键在elem.name is None分支,它处理的是文本节点。导航和脚本已经被前面的步骤清掉了,所以这里拿到的文本基本就是题目描述。图片转换那行单独提一下:离线题库最稳妥的做法是下载所有图片到本地images/目录,然后把src改成相对路径,避免打开Markdown时还在请求网络资源。
4.3 用SQLite建索引:以后按难度、标签、AC率检索
清洗后的Markdown文件按problems/{pid}.md存放,但光有文件还不够,需要一张索引表来支撑查询。SQLite是单文件数据库,不依赖服务,适合本地题库。表结构建议包含题号、标题、难度、标签和AC率,后面做刷题计划时直接对着这张表查。
CREATE TABLE IF NOT EXISTS problems ( pid INTEGER PRIMARY KEY, title TEXT NOT NULL, difficulty TEXT, tags TEXT, ac_rate REAL, md_path TEXT, updated_at TEXT );写入时用INSERT OR REPLACE,这样同一题重抓后直接覆盖更新。如果题目有多个标签,用逗号拼接成字符串存入tags,查询时用LIKE做简单筛选就够用了。这里不建议强行拆关联表,个人题库的数据量在几千行,拆表反而增加查询复杂度。索引创建好之后,你可以随时执行SELECT * FROM problems WHERE difficulty = '中等' AND tags LIKE '%动态规划%',这比在网页端翻题单靠谱得多。
5. 题库下载避坑指南:封IP、验证码、漏题与合规边界
离线题库这件事,写脚本只占三分之一的时间,剩下三分之二都在和“意外”打交道。这一章把最常见的坑按“现象 → 原因 → 解决”列出来,每一条都是我实际踩过或帮别人排查过的。
5.1 请求一多就503:限流识别与退避策略
现象:脚本跑了几百题之后,突然连续返回503,或者HTML变成了验证码页,题面解析全部失败。
原因:站点对单个IP的请求频率做了限流,秒级并发或固定间隔的请求容易被识别。很多OJ的反爬不封IP,而是返回验证码页面,干扰比封IP更讨厌。
解决:把请求间隔从固定值改成随机值,不要用time.sleep(1)这种规整节奏;并发数控制在1到2个;遇到503时立即停止当前批次,休息30秒到60秒再继续。代码里要加一个“连续N次失败就中断”的熔断逻辑,否则脚本会在验证码页里空转,最后产生一堆脏数据。
5.2 公式和图片离线变乱码:资源补抓与本地渲染
现象:辛苦抓下来的题面里,公式显示成$x_i$这类原始标记,或者图片位置一片空白。
原因:OJ的公式有几种渲染方式,有的在服务端生成图片,有的靠浏览器端MathJax动态渲染。离线打开HTML时,动态渲染所需的JS和字体文件不在本地,公式自然恢复成文本。
解决:如果公式是图片,抓取时把img的src下载到本地images/目录,并把HTML里的src改成相对路径;如果是MathJax渲染,Markdown里保留$公式源文,然后在本地阅读器里启用MathJax插件。不要试图把公式转成图片,那样会丢失可检索的文本内容。
5.3 题号不连续导致漏题:按题目列表接口再补一遍
现象:全量跑完后,发现某个区间的题号大量缺失,还以为是解析失败,但手动打开URL又是存在的。
原因:OJ的题号不是连续递增的,题目可能因为版权、隐藏、比赛下架等原因从列表移除,但URL仍然可以访问。用range()扫描必然漏题。
解决:放弃连续编号扫描,改为解析题目列表页或题单接口。抓完列表后,把列表里的题号集合和已抓取的集合做一次diff,差异部分就是漏网的。这个校验逻辑也可以保留下来,作为后面增量更新的基础。
5.4 编码和转义:JSON里常见的\uXXXX与HTML实体
现象:落盘的JSON文件里全是\u4e2d\u6587,或者题面里出现<、&这类实体。
原因:写入JSON时没设ensure_ascii=False,requests解析页面时又没显式指定编码,导致中文字符被转义成Unicode码点。HTML实体是因为直接抓了包含转义符号的文本,没有做反转义。
解决:所有落盘操作统一加参数json.dump(data, f, ensure_ascii=False, indent=2);解析HTML前强制resp.encoding = "utf-8";题面文本清洗时用html.unescape()把实体转回正常符号。这三步配合好,中文和特殊符号就不会再出问题。
5.5 合规边界:个人备份可以,公开传播不行
现象:费劲整理的题库被别人要过去,随手发到群里,结果被原平台发函要求删除。
原因:题库内容属于平台方或题目作者,虽然做题本身是公开行为,但整库抓取并传播已经超出个人学习范畴,涉及复制和再分发。
解决:把合规当作开发需求的一部分。抓取前确认robots.txt和平台用户协议;抓下来的题库只用于个人学习和离线复习,不打包分享、不传网盘、不用于商业培训。如果确实需要和别人共享题目,只分享链接,不传播离线文件。这条边界守住了,前面所有技术工作才安全。
6. 增量更新与刷题闭环:让本地题库长期可用
题库不是抓一次就完事的,OJ会加新题,题面会修正,难度标签也会调整。维护一套长期可用的本地题库,必须把“增量同步”做进去,而不是每次全量重抓。最省事的做法是维护一个已抓题号集合,配合内容哈希判断哪些题目需要更新:题号存在且内容没变就跳过,新增题号或内容变化才重新抓取。
import hashlib import json from pathlib import Path def sync_problems(pids, index_path="index.json"): index = Path(index_path) known = {} if index.exists(): known = {p["pid"]: p["hash"] for p in json.loads(index.read_text(encoding="utf-8"))} sync_list = [] for pid in pids: md_file = Path(f"problems/{pid}.md") if md_file.exists(): current_hash = hashlib.md5(md_file.read_bytes()).hexdigest() if current_hash == known.get(pid): continue # 内容没变,跳过 sync_list.append(pid) for pid in sync_list: data = fetch_problem(pid) if data is None: continue # 先写md,再更新索引 md_path = Path(f"problems/{pid}.md") md_path.write_text(html_to_markdown(clean_container(data)), encoding="utf-8") known[pid] = hashlib.md5(md_path.read_bytes()).hexdigest() # 把新索引写回,方便下次同步 Path(index_path).write_text( json.dumps([{"pid": p, "hash": h} for p, h in known.items()], ensure_ascii=False, indent=2), encoding="utf-8" )增量同步的逻辑不复杂,关键在约束自己不要全量重抓。配合刷新频率:我习惯每周同步一次,只处理新增和修改的题目,一次任务几十秒就结束,比全量跑半小时干净得多。
刷题闭环的下一步,是把本地题库接进自己的复习节奏。我一般用SQLite索引做两件事:一是按难度和标签生成每日五题,二是收集错题标记,每周回头重刷。错题标记不需要单独建表,在题目表里加一列wrong_count,重抓时不覆盖这个字段就行。这个闭环让题库不再是“存了没看”的静态文件夹,而是一个会跟着你水平走的私人训练系统。
最后说一点个人教训:我最初做题库时追求“全量、全部格式、完美排版”,结果花了大量时间在边缘题目的解析上,核心的增量同步反而拖了很久。后来把目标从“100%覆盖”改成“覆盖常用题型,漏题靠列表diff兜底”,进度反而快了。先让题库跑起来,再在用的过程中补缺口,是更务实的方式。希望帮到你。
本文还有配套的精品资源,点击获取