简介:一份基于爬虫技术的题库自动生成系统完整毕设项目工程,采用Java与Vue前后端分离架构,面向计算机相关专业与Java全栈开发者,解决题库构建中试题收集、整理与自动生成效率低的问题。压缩包共含122个文件,以71个Java后端业务类、22个Vue前端组件与7个JavaScript脚本为主要代码,另配XML、JSON、YML、properties等配置文件,以及HTML页面、CSS样式、图片和其他辅助文件,整体仅474KB,目录结构清晰紧凑。已有287人学习下载,适合作为毕业设计、课程设计或爬虫与前后端结合项目的参考样例。源码涵盖爬虫抓取、题目解析入库、题库生成管理、文件上传等关键模块,从后端接口到前端展示均有完整实现,便于对照学习工程化思路;同时可基于现有架构扩展新题型、更换采集源,或用于快速搭建毕业设计演示原型。整套资源麻雀虽小五脏俱全,可作为技术预研与项目答辩的落地素材。
1. 爬虫题库自动生成系统:到底是做什么的
很多人听到“爬虫题库自动生成”第一反应是:这不就是个爬虫抓网页吗?其实真正动手做一次就知道,抓题只占三分工作量,后边的清洗、去重、组卷、导出才是让这个系统从“玩具”变成“能用”的关键。这个毕设项目的落点是“自动生成”,也就是抓回来的题要能按知识点、难度、题型自动拼成一套可用卷子,而不是把网页原样堆进数据库。适合谁?适合正在选毕设方向的学生,也适合想做内部刷题工具的从业者,以及想搞明白爬虫工程化如何落地的人。
我见到不少模拟项目X拿这套思路跑通后,被导师追问最多的不是爬虫写得怎么样,而是“题库里的题质量怎么保证”“重复题怎么处理”“试卷排版怎么做”。这篇笔记就沿着这几个致命问题往下拆,从架构选型讲到代码实现,再到参数调优和常见翻车现场。读完你能直接照着复现出来,不是看个热闹。
2. 架构设计与技术选型:先定边界,再写代码
2.1 爬虫模块的边界:抓什么、不抓什么
很多A同学上来就写一个通用爬虫,想什么网站都抓,结果崩在登录、验证码、动态渲染这些环节上。我一般建议把边界画清楚:只抓公开可访问、无需登录、无强反爬的静态或半静态页面,优先选有列表页和详情页结构的站点。这样爬虫部分就能用最简单的requests加BeautifulSoup解决,不用上Selenium。
边界定了以后,科目和范围也得固定。常见做法是先配一个config文件,把目标站点的入口URL、列表页翻页参数、详情页题目标题和答案的CSS选择器都写进去。这套配置化的好处是换题库源时不用改逻辑代码,只改配置。代码骨架大概长这样:
# config.yaml 片段 sources: - name: "math_practice" list_url: "https://example.com/list?page={page}" detail_url: "https://example.com/detail/{id}" max_pages: 20 title_selector: "h1.question-title" options_selector: "ul.options li" answer_selector: "div.answer-box" difficulty_selector: "span.difficulty-tag"这里list_url支持分页占位符,detail_url用id拼接,选择器全部配置化。实际抓的时候,爬虫模块只需要读配置、请求页面、解析元素、落库存原始HTML,剩下的解析统一交到清洗模块处理。把抓取和解析分开是这套系统的第一个关键设计,否则你会在改版、字段变动时反复重写爬虫。
2.2 存储选型:为什么用SQLite,而不是直接上MySQL
毕设场景里,数据量一般在一万到五万题之间,SQLite完全够用,还省掉了数据库安装配置的麻烦。更关键的是,SQLite单文件备份方便,答辩演示时可以直接把db文件拷到任何电脑上跑。MySQL的好处是并发读写能力强,但题库生成系统是典型的“写多读少、数据量不大”场景,SQLite的性能瓶颈根本摸不到。
表结构设计上,我见过很多翻车案例是“一张大表存所有题”,字段塞得乱七八糟,后面查重和组卷写成噩梦。我习惯拆四张表:question主表、option选项表、knowledge_point知识点表、question_kp题和知识点关联表。主表只存题干、答案、难度、题型、来源URL、发布时间这些稳定字段。
CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_url TEXT NOT NULL, title TEXT NOT NULL, answer TEXT, analysis TEXT, qtype TEXT NOT NULL, difficulty TEXT NOT NULL, raw_html TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE option ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, opt_key TEXT NOT NULL, opt_value TEXT NOT NULL, FOREIGN KEY (question_id) REFERENCES question(id) );这段DDL里值得注意的点:raw_html字段留着原始页面结构,是给后面的解析回溯用的;qtype直接存字符串(单选、多选、判断)而不是数字枚举,毕设项目里可读性优先于性能;difficulty用easy/mid/hard三个档位,方便后面组卷按难度比例筛。选项单独拆表,是因为多选、判断、填空的选项数量和结构都不一样,混在主表会出现大量空字段。
2.3 为什么任务队列不需要Redis
有人推荐毕设里加Redis做任务队列,理由是锻炼技术栈、显得项目更完整。我的看法是:如果你的爬虫是单机、单进程、串行抓取,Redis纯粹是给自己找麻烦。多了一个服务要部署和调试,最后答辩很可能被问“你这里Redis到底解决了什么问题”,答不上来反而扣分。
真正需要队列的场景是分布式抓取或者抓取量大到单进程明显跑不完。对于课程设计和毕设,一个简单的Python生成器加进度日志就够。我用过一个simpletask类来做任务管理,本质就是个列表加游标:
class SimpleTask: def __init__(self, tasks): self._tasks = tasks self._idx = 0 def next(self): if self._idx >= len(self._tasks): raise StopIteration task = self._tasks[self._idx] self._idx += 1 return task @property def done(self): return self._idx这个类的逻辑直白:每次调用next拿一个未处理的URL,done属性返回处理了多少个。好处是你可以随时断点续跑——把done这行写到日志里,崩溃后从日志里恢复游标继续抓,不用重复请求页面。很多人在毕设里忽略断点续跑这个需求,抓到一半网络断了只能重来,这是最浪费时间的踩坑点之一。
3. 题库清洗与去重:从“抓下来”到“能用”
3.1 脏数据从哪来:常见的三类进场垃圾
抓回来的题库直接入库是会出事的。第一类垃圾是标签残留,页面解析不干净时题干里会混进span、div标签以及style属性;第二类是空白与换行混乱,题目和选项之间挤着一堆换行符;第三类是最难处理的错位题目,详情页结构不统一导致选项A被识别成选项B,或者答案字段抓到了解析内容。
我踩过最狠的一次坑是某图像处理Demo,抓一个题库站时把两个不同科目的题目抓进了同一个表,原因是目标网站有推荐阅读区域,详情页主内容后面的“相关推荐”区块选择器和主题干的class名高度相似。从那次以后,我的清洗模块里固定会砍掉页面底部和侧边栏区域,再开始主内容解析。
清洗流程我统一做成管道(pipeline)模式,每一道工序只负责一件事:
def clean_html(raw_html, selectors): # 1. 用BeautifulSoup移除脚本和样式块 soup = BeautifulSoup(raw_html, "html.parser") for tag in soup(["script", "style", "ins", "iframe"]): tag.decompose() # 2. 砍掉广告与“相关阅读”区块,按class名黑名单匹配 for ad in soup.select(".recommend, .related, .advert"): ad.decompose() # 3. 提取题目正文,去掉内部标签但保留文本顺序 title_node = soup.select_one(selectors["title_selector"]) title_text = title_node.get_text(" ", strip=True) if title_node else "" # 4. 统一空格和换行 title_text = re.sub(r"\s+", " ", title_text) return title_text这个函数里最关键的是get_text的strip参数和分隔符。用" "作为分隔符,会把题干里的
转成空格而不是把文字粘成一坨;strip=True会把每段文字首尾空格吃掉。第4步用正则把多个空白压缩成一个空格,是为了后续去重时做相似度比较有一个干净、统一的输入。
3.2 去重策略:为什么只用MD5一定会翻车
很多最初级做法是抓完URL后用URL做去重,再对题目正文算一遍MD5。URL去重防的是同一页面重复抓取,正文MD5防的是不同URL但内容一模一样的题。问题是,同一道题在两个网站里排版不同,正文里可能差一个空格、一个全角括号、甚至一个图片懒加载的alt文本,MD5全完蛋——稍有差异就判定为不同题。
我后来改用的方案是“先归一化再哈希”,把文本里的全角转半角、去掉所有标点符号和空格、中文一律统一成简体,然后再做SHA1。归一化以后,标题文本的稳定性大大提升。代码这样写:
def normalize_title(title_text): # 全角转半角 converted = [] for ch in title_text: code = ord(ch) if code == 0x3000: code = 0x20 elif 0xFF01 <= code <= 0xFF5E: code -= 0xFEE0 converted.append(chr(code)) title_text = "".join(converted) # 繁体转简体,这里用OpenCC做简单转换 title_text = OpenCC("t2s").convert(title_text) # 去标点和空白 title_text = re.sub(r"[^\w\u4e00-\u9fa5]", "", title_text) return title_text.lower()这段逻辑中OpenCC这个库负责繁体转简体,如果你的题库来源主要是简体站,这一步可以省略,但保留它会让系统能兼容港澳台地区的题库源。去标点这一步在英文题里尤其重要,因为英文单词间空格去掉后,Theoretical和theoretical会被归一到同一个串,MD5或SHA1计算出来就完全相同了。实际工程里我还会加一道“相似度去重兜底”:哈希相同的直接删,哈希不同但编辑距离高于0.95的标为疑似重复,组成审核列表人工看一眼。这一步是后面自动组卷质量的关键保障。
3.3 字段补全校验:难度、知识点、题型的冷启动问题
清洗后还有一个系统初始阶段绕不开的问题:新抓的题库没有难度和知识点标注。很多毕设系统在这一步就卡死了,因为后面抽题组卷都要按难度和知识点过滤。标注方案我建议混合做,能识别则自动识别,识别不到的降级处理。
自动识别依靠规则:题干里有“下列”“以下”“正确的”这类词偏向中低难度;题目长度超过80字的往往是中高难度;答案里有长篇解析的也往偏难方向标。但规则永远是猜,所以要留人工修正入口。我一般会生成一个unlabeled_questions.csv,每行是题干前30个字加一个difficulty建议值,导入到Excel里让人快速过一遍,比在系统里一个个改快得多。
def guess_difficulty(title_text, answer_text): if len(answer_text) > 200: return "hard" if len(title_text) > 80: return "mid" if any(word in title_text for word in ["下列", "以下", "不属于"]): return "mid" return "easy"这个函数逻辑简单,但要注意它的顺序:先看答案长度、再看题干长度、最后看关键词。顺序很重要,因为一道多选题“以下哪些属于……”题干只有十几个字,答案可能有300字的解析,先命中答案长度规则就会判为hard,而不是被关键词规则抢成mid。规则顺序排错会带来系统性的难度偏移,组卷时你会发现抽出的试卷全是中等题,没有任何区分度。
4. 自动组卷与导出:核心流程的工程落地
4.1 组卷策略:按知识点、难度、题型做多路筛选
组卷是这个系统的灵魂。常见做法是给前端一个组卷配置面板,参数有四块:知识点范围、难度分布比例、题型数量、总分限制。后端拿到参数后,先查知识点关联表找出候选题目池,再按题型分组,最后每道题独立按难度加权抽样。
我习惯把组卷算法做成“先粗筛后微调”的两步走。第一步粗筛保证每种题型、难度、知识点的题目数量都满足硬性要求,第二步微调让总分接近目标值。比如一套总分100的卷子,单选20题每题2分共40分,多选10题每题4分共40分,判断10题每题2分共20分。粗筛完了总分可能只有92或108,第二步要随机替换同难度、同知识点的题把分数拉回来。
def generate_paper(question_pool, config): # config: {"knowledge_points": [1,2,3], "difficulty": {"easy": 0.4, "mid": 0.4, "hard": 0.2}, # "types": {"single": 20, "multi": 10, "judge": 10}, "total_score": 100} selected = [] for qtype, count in config["types"].items(): type_questions = [q for q in question_pool if q["qtype"] == qtype] # 按知识点过滤 type_questions = [q for q in type_questions if set(q["kps"]) & set(config["knowledge_points"])] # 按难度比例抽题 for difficulty, ratio in config["difficulty"].items(): target_num = round(count * ratio) candidates = [q for q in type_questions if q["difficulty"] == difficulty] selected.extend(random.sample(candidates, min(target_num, len(candidates)))) return selected这个函数的问题也很明显:如果某个难度档位的候选池不够,random.sample会直接抛异常。我实际用的时候会包一层降级逻辑,候选池不足时先从高一档难度里挪用,还没有就从低一档挪用,同时记录一条warning日志。组卷过程中日志一定要打出来,这样后期查“为什么卷子里这么多简单题”才能有据可查。
4.2 试卷渲染:从数据库到Word/PDF的两种路线
题库生成系统的最终产出必须是让人看得懂的文档,常见的输出格式是Word和PDF。Word的好处是方便老师编辑题目,PDF的好处是格式固定、打印方便。毕设里不要两个都做,选一个做到稳定输出就是亮点。
我推荐的路线是生成Word(.docx),然后用工具转PDF。原因有两条:一是python-docx操作表格和段落非常简单,二是Word转PDF的库很成熟。试卷结构通常是试卷标题、考生信息栏、大题分隔线、小题列表、答题卡。渲染时每道题按题型不同走不同模板,单选题加括号放选项,多选题题干加粗标“多选”,判断题题干后紧跟“正确/错误”。
from docx import Document doc = Document() doc.add_heading("模拟试卷", level=0) for section, questions in paper_sections.items(): doc.add_heading(section, level=1) for idx, q in enumerate(questions, start=1): p = doc.add_paragraph() p.add_run(f"{idx}. {q['title']}({q['score']}分)") if q["qtype"] == "single": for opt in q["options"]: p = doc.add_paragraph(f"{opt['key']}. {opt['value']}") else: p = doc.add_paragraph("答案:________________") doc.save("paper.docx")这段代码是简化版,实际工程里还要处理分页:每道大题之间插分页符,题干超长时保持选项不跨页断裂。python-docx的ParagraphFormat里可以设置keep_with_next属性,让题干和第一个选项不分开。这个细节是“导师看了会加印象分”的点,因为很多胡乱生成的卷子排版散乱,一看就是没处理过分页逻辑。
4.3 公式和图片题:容易暴露短板的隐藏需求
题库里如果涉及数学公式或者图片题,生成的Word里公式会变成一张图片或者乱码。处理方式有两种:把源站图片下载到本地,Word里用add_picture插入;或者公式转成Office Math格式。毕设阶段我强烈建议不碰公式题,如果题库源里确实有,就在清洗模块里把含img标签的题干标记为“含图题”,从组卷候选池排除,并在配置里加一个开关允许手动开启。
但你得有预案,因为导师可能会问“如果题库里有数学公式怎么办”。预案代码是这样写的:遇到图片题先下载原图到本地assets目录,然后用python-docx插入。至于公式的OCR识别,那是另一个课题,不要往这个毕设里塞,否则会失控。做减法也是毕设的一项重要能力,把爬虫、去重、组卷、导出四条线跑通做稳,已经是一个完整度很高的系统了。
5. 常见问题排查与避坑:现象、原因、解决办法
5.1 抓回来的题大量是乱码或者缺行,不是反爬问题是编码问题
现象:爬虫跑完后,数据库里的题干文本出现“锟斤拷”“é”这类乱码,或者某些题的选项只有ABCD四个键名但没有选项内容。
原因:90%的情况是目标网站的编码判断错了。有些老网站用GBK/GB2312编码,requests库默认按UTF-8解码,得到的就是一堆乱码。选项缺失则是页面里选项不是纯文本,而是被拆成单独节点,选择器没匹配到。
解决:请求时显式指定编码。requests拿到response后不要直接.text,先看headers里的Content-Type字段,再根据charset参数去设置编码。选项缺失的选择器问题,要打开浏览器的开发者工具,实际比对页面结构和配置里的CSS选择器,十有八九是class名带空格或者是动态生成的选择器没选中。
resp = requests.get(url, headers=headers, timeout=10) if resp.status_code != 200: log_error(f"请求失败: {url}, status={resp.status_code}") return None content_type = resp.headers.get("Content-Type", "") if "charset=gbk" in content_type.lower(): resp.encoding = "gbk" elif "charset=gb2312" in content_type.lower(): resp.encoding = "gb2312" else: resp.encoding = "utf-8"这段代码的关键在encoding赋值必须发生在resp.text被访问之前。因为resp.text属性是惰性解码,第一次访问时就会根据当前encoding把字节流转成字符串,后面再改encoding就晚了。这个坑很多新手踩过:明明设置了gbk还是乱码,一看代码顺序,原来是先print了resp.text才去设置的。
5.2 去重误杀严重:相似题全被当成重复题删掉
现象:题库里有些题是相似题干但答案不同,比如“下列哪个是面向对象语言”和“下列哪个不是面向对象语言”,系统把它们判为重复,后入库的题被删了。
原因:我前面写的归一化去重方案太激进,把“是”和“不是”这种关键否定词都标准化后,两个题干文本几乎一致,哈希一样就被判重了。核心问题是归一化规则没有保护关键语义词。
解决:调低相似度阈值,不只看哈希一致,还要加编辑距离算法。只有当归一化文本完全一致、且原文里“是/不是/属于/不属于”这类词的位置一致时,才判定为重复。更稳的办法是把去重分成两档:哈希完全相同的自动删除;编辑距离相似但语义词不同的,进入“疑似重复”人工审核区。
from difflib import SequenceMatcher def is_duplicate(existing_title, new_title): norm_existing = normalize_title(existing_title) norm_new = normalize_title(new_title) if norm_existing == norm_new: return True ratio = SequenceMatcher(None, norm_existing, norm_new).ratio() return ratio > 0.95这个函数用SequenceMatcher的ratio方法算出相似度,0.95这个阈值我是拍出来的,不同科目数据下效果不同。如果你抓的题库源以选择题为主,0.95偏高,容易漏掉真正的重复;如果大多是一个字不差的同样题,0.95够用。实操中建议先抽200条人工标注跑一遍,看阈值设多少准确率最高,这是花最少时间却最能提升数据质量的投入。
5.3 组卷时总分怎么调都差几分,是没考虑题型分值权重
现象:生成一套卷子总分要求100分,组卷结果却一直是95分或者107分,调整题目数量后分数不是低就是超,始终卡不准。
原因:组卷算法按“数量”抽题,但没按“分值”抽题。比如单选每题2分、多选每题4分,题目数量固定后分数已经基本固定,微调空间非常小。
解决:把抽题思考顺序改成分值优先。先算出每种题型的目标总分,再按每题的分数去反推需要多少题。这样抽题时就已经按分数配额执行,总分的偏差会在很小的范围内,再通过替换少量同难度题就能校准。
def calculate_question_count(total_score, type_score_per_question): # 例如单选总分要30分,每题2分,则取15题 return total_score // type_score_per_question更稳的做法是组卷完成后单独跑一次分数校验函数,遍历所有选中题目累加score字段,如果分数偏差超过正负2分,就从同类型同难度的待选池里找一道分数合适的题替换进来。在校验函数里一定要用严格大于和小于判断,别用浮点数表示分数,所有分数用整数(比如分数都放大10倍)来计算,否则会出现0.1分的误差累计。
5.4 Word导出后图片不显示或排版错乱,是资源路径写死导致
现象:生成的docx单独打开时图片位置显示空白,但在项目同目录下打开却正常,发给别人看就丢图。
原因:插入图片时用了绝对路径,比如C:/assets/formula1.png。docx内部其实引用的是一个相对路径的文件关系,绝对路径写错或者目录迁移后就找不到图了。
解决:先把图片复制到项目统一的assets目录,插入docx时用相对路径引用。python-docx的add_picture会处理这个过程,关键是图片必须先存在assets目录里,且docx里的图片关系是相对该docx文件位置的。导出Word后,最好用另一个脚本专门做个“打包”动作,把docx和assets目录一起打包成zip,确保试卷附件能完整迁移。
6. 一个让题库系统持续变聪明的进阶技巧:错题回流
系统全部跑通后,我发现真正让它“渐渐好用”的功能不是自动出卷,而是错题回流。思路很简单:把用户考试、练习的作答记录和错题信息写回题库,给每道题增加一个“错误率”字段。这套逻辑在毕设答辩中是加分项,因为它让系统从一个静态的题库,变成了有数据闭环的活系统。
ALTER TABLE question ADD COLUMN wrong_count INTEGER DEFAULT 0; ALTER TABLE question ADD COLUMN answer_count INTEGER DEFAULT 0;每次学生提交练习后,系统对比学生答案和标准答案,若不一致则wrong_count加1,无论对错answer_count都加1。组卷时可以把错误率高的题优先抽取出来,生成“错题强化卷”。这个功能在真实考前提分场景里非常有说服力,我一直认为比单纯增加爬虫源更能体现工程意识。
实现时注意错题回流不是实时写库就完了,要避免并发计数丢失。SQLite在并发场景下可以用事务更新,代码里加两句:
conn.execute("BEGIN IMMEDIATE") conn.execute("UPDATE question SET wrong_count = wrong_count + 1 WHERE id = ?", (qid,)) conn.commit()这里BEGIN IMMEDIATE是为了防止两个请求同时读取旧值再加1导致只加一次。如果你用的是SQLite,事务加上之后并发安全基本够用。如果是MySQL就直接用UPDATE自增,不用手动管理锁。
我的个人习惯是把洗题的质量指标也做成可视化报表,抓了多少题、清洗后剩多少、重复删除多少、人工审核多少,每条记录都在日志里留痕。以前总觉得这步麻烦,后来被导师问“你的数据质量怎么保证,有多少题能直接用”,我直接打开日志拉出数据清洗链路的数字,一页图表就把问题解答了。后面每次做题库方向的项目,我都会先搭这套清洗链路日志,再谈爬虫与组卷,血泪经验。
还有一个保命习惯是每爬完一批数据就立刻备份一次数据库,文件按日期命名。某次模拟项目X跑了一夜爬虫,凌晨数据库文件损坏,我因为没有备份只能重来。后来我写了个定时任务,备份文件放到独立目录,从未再出现过“一夜回到解放前”的绝望。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取