☰
爬虫题库自动生成系统:从清洗到组卷的完整实践
2026/10/17 15:06:29 网站建设 项目流程

简介:面向毕业设计的“利用爬虫的题库自动生成系统”项目包,适合计算机相关专业学生、爬虫技术初学者及题库平台开发者参考。系统基于Java语言开发后端核心逻辑,并使用Vue构建前端管理界面,整个流程涵盖网页题目抓取、内容清洗去重、分类存储以及题库自动生成与展示,能够帮助使用者快速体验一个从零搭建的数据采集与整理项目。压缩包内共有一百二十二个文件,其中包括七十一份Java源码文件、二十二份Vue页面文件、七份JavaScript脚本、多份XML与JSON配置,以及Maven封装脚本和少量样式图片资源,整体大小约四百七十四KB,结构清晰、轻量便携。目前该项目已获得二百八十六人浏览学习。从内容预览来看,包内提供了题目服务实现、爬虫处理模块、文件上传控制器、启动脚本和全局样式等关键部分,读者可以据此理解系统分层、爬取策略、数据持久化与前端交互的设计思路,也便于直接借鉴到自己的毕业设计或课程项目中。

1. 利用爬虫的题库自动生成系统:为什么难的不是爬而是“洗”

这个题目很多人第一反应是“爬虫”,真正做完才发现,爬只是整个系统里最不起眼的一段。一个利用爬虫的题库自动生成系统,核心链路是抓取、清洗、入库、生成四段,其中清洗和结构化占了全项目一半以上的工时。你可能会遇到:抓回来的HTML里混着广告、答案藏在一个奇怪标签里、同一道题被抓了十几遍。这篇按实际落地顺序拆解整个方案,从选型到代码到参数,照着走能少踩几个坑。适合拿这个题做毕设的同学,也适合想给团队快速堆一套内部题库工具的开发者。

2. 整体方案与数据流:先定边界再写代码

2.1 先搞清楚题目从哪来:目标站点画像

题库自动生成系统第一个要定的事不是技术栈,而是数据源。你打算抓什么样的站点,直接决定了后面所有代码的复杂度。以我的经验,题目来源大致分三类:公开题库网站、历年真题页面、在线刷题平台的Web端接口。

公开题库网站通常是“列表页 + 详情页”结构,列表页给题目链接,详情页里是题干、选项和答案。这类站点最友好,页面结构稳定,适合作为毕设的首选目标。历年真题页面往往是学校或机构发布的HTML表格,题目直接嵌在表格里,一个页面就是一套完整试卷,解析难度低,但答案有时不在页面里,需要额外处理。在线刷题平台的Web端一般不会把题目渲染进静态HTML,而是通过XHR接口返回JSON数据,这种接口反而比HTML更干净,因为JSON天然结构化,省掉大量清洗工作。

我一般建议先做静态页面,理由很实际:静态HTML出了问题你能肉眼看到,调试成本低;接口型数据虽然干净,但对方可能随时改参数、加签名,前期定位问题要开浏览器开发者工具去翻请求。对于毕设周期来说,先用列表页+详情页把链路跑通,再考虑升级数据源,这个顺序最稳。

2.2 技术选型:为什么是 requests + BeautifulSoup 而不是 Scrapy

爬虫框架选型很多人纠结,实际上选错才是最大的坑。Scrapy 功能强,有并发调度、去重、管道机制,但它有一个学习门槛:你的代码逻辑被框架切成了 spider、item、pipeline 三段,调试时要开 scrapy shell 逐层看。一个毕设项目从零开始写 Scrapy,光理解框架就要花掉两三天。

我的建议是:数据量在几千到几万题这个量级,requests + BeautifulSoup + sqlite3 就够了。requests 负责发请求,BeautifulSoup 负责解析HTML,sqlite3 负责存储。三个库都是 Python 标准生态,心智负担小,清洗逻辑可以直接写在同一个脚本里,出了问题单步调试也方便。

技术选型可以参考这个对比:

维度requests + BeautifulSoupScrapy
上手成本低,逻辑都在一个脚本里高,要理解框架分层概念
并发能力手动控制,适合中小数据量内置高并发,适合大规模抓取
调试体验直接 print 输出,直观需要 scrapy shell 辅助
项目结构自由组织,灵活度高结构固定,扩展能力强
适合场景毕设、内部工具、原型验证生产级数据采集平台

存储方面,SQLite 起步完全够用。MySQL 的优势在于多人协作和远程访问,但毕设场景单机跑,SQLite 零配置、单文件、随手备份,反而是最佳选择。导出用 python-docx,因为最终交付的往往是 Word 试卷,用代码直接生成 docx 比先出 HTML 再手动转格式可控得多。

2.3 表结构与生成流程设计

数据表设计决定了后期清洗和组卷的方便程度,建议建一张带唯一约束的题目主表,字段设计如下:

CREATE TABLE question_bank ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_text TEXT NOT NULL, -- 题干,清洗后的纯文本 option_a TEXT, option_b TEXT, option_c TEXT, option_d TEXT, question_type TEXT DEFAULT 'single', -- single / multiple / judge difficulty INTEGER DEFAULT 3, -- 1-5,默认中等 knowledge_point TEXT, -- 知识点分类 answer TEXT NOT NULL, -- 归一化后的答案 fingerprint TEXT UNIQUE NOT NULL, -- 用于去重的指纹 source_url TEXT, -- 来源链接,便于复查 created_at TEXT DEFAULT (datetime('now')) );

这里有两个字段容易忽略。一个是 fingerprint,它是对题干文本做哈希后得到的字符串。同一道题可能出现在不同站点的不同页面里,文本相同但URL不同,如果没有指纹字段,重复题会积累到让你怀疑人生。另一个是 source_url,清洗发现某道题的答案明显不合逻辑时,你要能随时回到原始页面确认是不是解析规则写错了。

整体数据流是:采集脚本抓取原始HTML → 清洗脚本抽取题干、选项、答案 → 写入 question_bank 表 → 组卷脚本按知识点和难度抽题 → 导出 docx 试卷。四段独立运行,中间用 SQLite 文件衔接,每一段挂了都能单独重跑,不需要从头再来。

3. 核心模块落地:采集、清洗、入库与组卷的完整示例

3.1 采集端:一个带重试、限速和断点续爬的抓取函数

采集模块不需要花哨,核心就一句话:稳定地把目标页面拿下来。所谓的稳定,指的是超时能重试、失败了能跳过、请求频率不会触发对方限制。我常用的写法是这样的:

import time import requests from requests.adapters import HTTPAdapter # 复用连接,避免每次请求都重新握手 session = requests.Session() adapter = HTTPAdapter(max_retries=3) # 网络层自动重试 session.mount('http://', adapter) session.mount('https://', adapter) HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Accept-Language': 'zh-CN,zh;q=0.9', } def fetch_page(url: str, retry: int = 3, delay: float = 0.5): """ 抓取单个页面 :param url: 目标页面地址 :param retry: 失败后重试次数 :param delay: 请求间隔,单位秒 :return: 页面文本,失败返回空字符串 """ for attempt in range(retry): try: resp = session.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 按实际编码解码,避免中文乱码 resp.encoding = resp.apparent_encoding or 'utf-8' return resp.text except (requests.Timeout, requests.ConnectionError): # 退避重试:第一次失败等 0.5s,第二次等 1s,第三次等 1.5s if attempt == retry - 1: return '' time.sleep(delay * (attempt + 1)) return ''

要点有三个:头部伪装、超时控制和退避重试。User-Agent 伪装成浏览器是基本操作,有些站点还会检查 Accept-Language,所以一并带上。timeout=10 的意思是一旦超过10秒还没响应就放弃,避免某个页面卡死整个采集任务。delay 参数控制了两次请求之间的最小间隔,0.5秒是保守值,跑起来不容易触发限流。

断点续爬的做法是:在采集列表页时记录当前页码,写入一个 progress.txt 文件,脚本启动时先读这个文件,如果上次跑到第30页,这次就从第30页继续。这个设计很土,但对毕设答辩很有用——演示的时候网络断了,重跑不需要从第一页开始。

3.2 清洗端:把 HTML 变成结构化题目

清洗是整个系统最容易翻车的环节。最常见的错误写法是拿一个大正则去匹配所有内容,结果题干里的“A.”被误认成选项,答案提取永远差一行。我的方案是先用 BeautifulSoup 定位容器,再在容器内用正则提取,窗口缩小后误判率会直线下降。

import re from bs4 import BeautifulSoup def clean_question(html: str): """ 从页面 HTML 中提取题干、选项和答案 :param html: 原始页面源码 :return: (题干文本, 选项字典, 答案列表) """ soup = BeautifulSoup(html, 'html.parser') # 去掉 script/style 标签,防止内容混入正文 for tag in soup.find_all(['script', 'style']): tag.decompose() # 优先找题目容器,找不到就用整个 body question_area = soup.find('div', class_='question-content') or soup.body question_text = question_area.get_text('\n', strip=True) # 合并连续换行 question_text = re.sub(r'\n{2,}', '\n', question_text) options = {} # 先定位选项容器,再逐条匹配,避免题干里的“A.”误伤 option_area = soup.find('div', class_='option-area') or question_area for mark in ['A', 'B', 'C', 'D']: m = re.search(rf'{mark}[\.、]\s*(.+)', option_area.get_text()) if m: options[mark] = m.group(1).strip() # 答案提取:各站点格式差异很大,这里给一种常见模式 answer_area = soup.find('div', class_='answer-area') answer = '' if answer_area: # 常见答案格式:正确答案:B 或 答案:ABC m = re.search(r'答案[::]\s*([A-D]+)', answer_area.get_text()) if m: answer = ''.join(sorted(m.group(1))) # 归一化为升序 return question_text, options, answer

这段代码的关键在于“分容器”的思路:题干在 question-content 里找,选项在 option-area 里找,答案是独立的 answer-area。每个站点容器类名可能不一样,你需要打开开发者工具确认一次,把类名改成实际值。

正则部分也有细节。选项匹配用了[\.、]表示点号或顿号,兼容“A.”和“A、”两种写法。答案归一化做了升序排序,比如页面写的是“答案:CBA”,统一存成“ABC”,后续组卷判断答案时不用处理顺序问题。注意这里只匹配单个英文字母加符号,如果你的目标站点选项是中文编号“一、二、三”,正则要换成对应的中文字符集。

3.3 入库与组卷:从 SQLite 抽题到导出 Word

清洗出来的数据结构化之后,入库就很简单了。组卷时有一个容易被忽略的需求:试卷不能只随机抽,还要按知识点分布抽。直接 ORDER BY RANDOM() 会抽出一堆同一知识点的题,真实考试里题目分布是有要求的。

import sqlite3 from docx import Document def build_paper(db_path: str, total_count: int = 20): """ 按知识点均匀抽题并导出 Word 试卷 """ conn = sqlite3.connect(db_path) cur = conn.cursor() # 子查询先随机排序,外层再按知识点分组,保证每个分组抽到的题是随机的 rows = cur.execute( ''' SELECT * FROM ( SELECT * FROM question_bank WHERE question_type = 'single' ORDER BY RANDOM() ) GROUP BY knowledge_point LIMIT ? ''', (total_count,) ).fetchall() doc = Document() # 题干和选项逐段写入,不用 HTML 直接转,排版更可控 for i, row in enumerate(rows, start=1): doc.add_paragraph(f'{i}. {row[1]}') for opt in row[2:6]: if opt: doc.add_paragraph(opt) # 每个选项占一个段落,保持缩进一致 doc.save('paper.docx') conn.close()

这里有一个细节值得多说一句:SQLite 的 GROUP BY 在配合子查询使用时,保留的是分组后的第一条记录。所以内层必须先 ORDER BY RANDOM(),让每条记录随机排列,外层分组取到的第一条才是随机的。如果直接对整表 GROUP BY,取到的永远是最早入库的那批题目,知识点分布再均匀也没有意义。

组卷参数 total_count 表示题目总数,代码里是20,你可以按需调整。实际项目中我还会加一个 difficulty 过滤条件,比如只抽难度3-5的题,SQL 里加一行AND difficulty BETWEEN 3 AND 5就行。Word 导出用 python-docx,每道题的题干和选项各自独立成段,后续要调缩进、字体、间距都很方便,不会出现格式错乱的问题。

4. 题库自动生成系统的5个常见坑与排查清单

4.1 抓回来的中文全是乱码

现象:页面标题和题干里的中文显示成“中æ– ”之类的乱码。

原因:requests 默认用 HTTP 响应头里的 charset 解码,但不少站点在响应头里不写 charset 或者写错,requests 就会用默认的 ISO-8859-1 去解码,中文自然就炸了。有的站点更隐蔽,响应头写的 charset 是 utf-8,页面内容实际是 gb2312 编码,直接按 utf-8 解也会乱码。

解决:请求后立刻调用resp.encoding = resp.apparent_encoding or 'utf-8',apparent_encoding 是 requests 根据页面返回的字节内容自动猜测的编码方式,绝大多数场景能猜对。如果个别页面还是不对,可以在清洗脚本里增加一个编码白名单,逐个尝试 UTF-8、GBK、GB2312 解码,用能否成功解析中文标点来判断。

4.2 滚动加载的内容抓不到

现象:列表页能看到30道题,抓下来却只有第一屏的10道,往下翻的题在 HTML 源码里根本不存在。

原因:这类站点是前端动态渲染的,数据不是直接写在 HTML 里,而是页面加载后用 JavaScript 异步请求接口再渲染出来。requests 不执行 JS,拿到的只是空壳页面。

解决:打开浏览器开发者工具的 Network 面板,刷新页面,找到返回题目数据的那个 XHR 请求,直接模拟这个接口。接口通常返回 JSON,按 key 取题目的标题和选项就行。比 Selenium 启动浏览器等渲染的速度快一个量级,也更稳定。如果遇到接口参数带签名验证签名的生成逻辑就行,实在绕不过去再考虑无头浏览器,但那是下策。

4.3 答案和题目对不上

现象:入库后抽查,发现“我国首颗人造卫星发射年份”这道题的答案是“18世纪法国大革命”。

原因:页面结构里题目列表和答案列表是分开的两个区域,你用了两条清洗逻辑分别提取,中间一旦出现题序跳变,答案就错位了。特别是有时候某道题缺失导致解析结果比题库少一行,后续所有答案整体错位。

解决:抓取详情页时把题目、选项、答案放在同一个请求里解析完再入库,不要先批量抓题目列表、再批量抓答案。如果目标站点确实是列表和答案分离的,必须用题目所在区域的唯一标识把两者绑定后再写入,不要依赖行号对应。

4.4 同一道题在库里出现了三四遍

现象:题库总数从5000涨到15000,去重后发现实际不同的题只有4000多。

原因:列表页可能有多页重复内容,或者多个栏目推了同一道题,清洗时又没有去重保障。这是最容易被忽略的坑,因为报错会有提示,业务上的重复却不会报错。

解决:建表时给 fingerprint 字段加 UNIQUE 约束,入库前先对题干文本做 MD5,用 INSERT OR IGNORE 写入。MD5 在这里做指纹足够用,不是安全场景,冲突概率低到可以忽略。如果要求更严谨,可以对“题干+选项”联合哈希,防止不同选项组合被当成同一道题。

4.5 导出 Word 后格式全乱

现象:题干和选项挤成一团,缩进完全不对,某个选项还带残留的<p>标签文本。

原因:清洗时只处理了可见文本,没有处理残留的空格、换行和隐藏字符。有的页面选项里嵌了<br>标签,get_text 提取后变成连在一起的字符串。

解决:入库前做一次文本规范化,把连续空白符压缩成单个空格,把全角空格替换成半角,把常见的\u3000、\xa0这些不可见字符清理掉。导出时用 python-docx 逐段写入,题目一个段落、选项每个单独一个段落,并设置段落缩进,这样不管原始页面多乱,出来的试卷都是规整的。

排查这类问题,我习惯在入库脚本里加统计日志:每次入库后打印总数、去重数、异常数,分字段统计空值比例。空值比例突然升高,基本就是清洗规则对该页面失效了,这时候打开 source_url 去看原始结构,比盲改正则高效得多。

5. 参数调优:频率、权重与清洗规则怎么调

5.1 请求参数:间隔、重试、超时怎么定

请求间隔和超时是每个做采集的人都会纠结的参数。给一组我常用的初始值:请求间隔 0.5 至 1 秒,超时 10 至 15 秒,网络层重试 3 次。具体调优时观察两个信号。

第一个信号是响应耗时。如果目标站点平均响应时间是 200ms,你设置 0.5 秒的间隔是合理的;如果平均耗时已经到 800ms,间隔可以放宽到 1.5 秒,否则请求会排队积压,采集速度反而下降。第二个信号是 HTTP 状态码。日志里连续出现 403 或 429,说明频率已经触发对方限制,这时候不要再加大并发,先把间隔翻倍再看。玄学做法是枚举间隔从 0.3 到 5 秒,找一个“不回 429 的最小值”,这方法笨但有效。

超时值不建议小于 5 秒。有些页面的附属资源较多,服务器响应本身不快,超时太短会导致整批请求失败。更稳妥的做法是把超时设成一个元组(3, 10),表示连接超时 3 秒、读取超时 10 秒。读取超时比连接超时长,因为页面内容大时传输耗时高是正常的。

5.2 清洗规则:正则的边界和回退策略

正则表达式在清洗环节是把双刃剑。匹配选项时,最简单的正则是[A-D][\.、]\s*(.*),但它有两个边界问题:题干里可能出现“A. 下列说法错误的是”这类句式,选项容器匹配不到时就会误伤;另外有些题目的选项编号不是 A/B/C/D,而是“Ⅰ、Ⅱ、Ⅲ、Ⅳ”或者中文数字。

我的做法是分层处理:优先用 HTML 结构定位真正的选项容器,容器里的文本再交给正则二次提取。容器定位用 BeautifulSoup 的 class 查找,比正安稳得多。如果页面结构里选项容器不明显,就退回按“换行 + 字母编号”的特征找,正则改成(?:^|\n)\s*([A-D])[\.、]\s*(.+),用行首锚点把误匹配概率压到最低。

清洗规则还应该设计一个“回退通道”。当一道题的选项数量不是 2 到 5 个,或者答案不是 A-D 组合时,不要直接丢弃,把原始 HTML 存进一张 review 表,标记为待人工确认。这个设计比强行过滤靠谱得多——你不知道未来抓的页面结构会不会变,丢弃的题永远不会被发现,打标记却能在人工抽检时快速暴露规则失效。

5.3 组卷参数:难度占比、题型分布与知识点去重

组卷参数决定了生成的试卷像不像真实考试。常见的默认值是这样:单选题 60%、多选题 30%、判断题 10%,难度按易、中、难 3:5:2 分配。这个比例不是绝对的,取决于题目库里各类题的存量。

调参时要先统计各类题的数量。如果题库里判断只有 8 道,试卷却要求 10 道判断题,抽题时必然用重复题目凑数,这一点需要提前校验。我用一个简单的参数化实现:组卷前先跑一条 SQL 统计各类题型数量,不足时自动调整占比,比如判断题不够就改成单选,并打印告警日志。

def adjust_ratio(question_type: str, required: int, available: int): """ 根据题库存量自适应调整题型占比 :param question_type: 题型 :param required: 试卷需要的题目数 :param available: 题库实际可用的题目数 :return: 实际抽取数量 """ if available < required: print(f'[warn] {question_type} 需求 {required} 道,库存仅 {available} 道,已自动缩减') return available return required

知识点去重的逻辑是:同一张试卷里,相同知识点标签的题目不超过 2 道。实现思路是抽出候选题后,在 Python 里按 knowledge_point 字段做二次过滤。SQL 里的 GROUP BY 只能保证跨组均匀,保证不了组内不扎堆,这个坑我踩过一次:一场模拟试卷 30 道题里 12 道都打着“计算机网络基础”的知识点标签,看上去像一门专门考网络概念的专项测试。

6. 验收与收尾:把系统从“能跑”变成“敢交”

系统跑通之后,离交付还有一步:验证。我的验收习惯是先数据、后功能、再导出。

数据层面做三件事:统计题库总量和去重后的有效量,差异太大说明清洗有漏洞;按题型分组看占比是否符合预期;随机抽样 20 道题打开 source_url 人工核对答案。抽样时不要只看前 20 条,数据入库顺序可能导致前 20 条来自同一个页面,我用ORDER BY RANDOM() LIMIT 20随机抽,保证覆盖不同来源页面。

功能层面重点验证组卷的分布逻辑。生成一份完整试卷后,检查每个知识点的题数分布是否均匀、难度比例是否符合设置、是否存在同一页连续出现两道选项顺序完全相同的题。顺序问题需要在组卷脚本里加一道洗牌逻辑:抽完题后,对题目顺序和选项顺序都做一次随机排列。

导出层面检查 Word 的实际效果,而不是只看代码输出。用 python-docx 生成的文档,要在办公软件里打开确认格式没有异常,特别是不同操作系统上字体渲染效果差异较大。如果目标机器没有装中文字体,文档里的中文可能显示成方块,这就是为什么导出时建议在代码里显式设置字体为中文字体。

我现在的习惯是:每次跑完采集先看日志里的失败率,再导出 20 题人工抽检,最后才敢生成整卷。这套流程多花半小时,但能避免在答辩现场打开试卷时发现答案错位那种尴尬。题库自动生成系统的价值不在于爬得快,而在于生成的内容可信,验证环节才是把“能跑”变成“敢交”的关键一步。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询