资讯动态

爬虫题库自动生成系统:从清洗到组卷的完整实践

发布时间:2026/10/17 15:06:38 来源:尧图企业网站定制
简介面向毕业设计的“利用爬虫的题库自动生成系统”项目包适合计算机相关专业学生、爬虫技术初学者及题库平台开发者参考。系统基于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 负责解析HTMLsqlite3 负责存储。三个库都是 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_retries3) # 网络层自动重试 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;q0.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, headersHEADERS, timeout10) 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所以一并带上。timeout10 的意思是一旦超过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, stripTrue) # 合并连续换行 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, start1): 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-8apparent_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 题人工抽检最后才敢生成整卷。这套流程多花半小时但能避免在答辩现场打开试卷时发现答案错位那种尴尬。题库自动生成系统的价值不在于爬得快而在于生成的内容可信验证环节才是把“能跑”变成“敢交”的关键一步。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑