简介:这是一份面向高校计算机相关专业毕业设计场景的完整项目源码包,主题为利用爬虫技术实现题库自动生成系统,适合正在准备毕设、需要参考前后端完整实现方案的学生与开发者。项目采用Java作为后端主力语言,配合Vue构建前端界面,覆盖数据抓取、题目解析、题库存储与展示等核心环节,可作为同类选题的工程模板与思路参考。压缩包共122个文件,约474KB,其中71个java文件承载后端业务逻辑与爬虫处理,22个vue与7个js文件负责前端页面与交互,另有xml、yml、properties等配置文件和少量图片、json资源,整体结构清晰、分层明确。目前已有286人学习下载。读者可从中获取一套可运行的毕设级项目骨架,理解爬虫抓取与题库生成模块的代码组织方式,并借鉴其接口设计与目录划分,用于快速搭建自己的开发环境与功能验证。
1. 从一份题库压缩包说起:爬虫抓题、清洗入库、自动组卷这条链路到底怎么跑通
毕设季一到,问得最多的就是「利用爬虫的题库自动生成系统」这类题目能不能落地。我先把结论摆出来:能,而且技术栈并不玄学,核心就三件事——用 requests 爬虫把题目抓下来,用 sqlalchemy 把爬虫数据规整进数据库,再用一套组卷逻辑把题库变成可用的试卷。真正卡人的从来不是「会不会写爬虫」,而是抓回来的数据脏得没法用、题目去重做不干净、组卷时题型分值对不上。这套系统适合两类人:一类是计算机相关专业做毕设的学生,需要一条能演示、能答辩、能扩展的完整链路;另一类是培训或教务场景里想自己攒题库的从业者,手头有公开题目资源但缺自动化工具。下面我按自己实际搭过的顺序,把这条链路拆开讲清楚,包括参数怎么设、坑在哪、哪些地方可以偷懒哪些地方不能省。
2. 爬虫抓题:requests 会话、分页拼接与反爬边界
2.1 为什么题库类爬虫优先选 requests 而不是浏览器自动化
题库站点大多是服务端渲染或者接口返回 JSON,页面结构相对规整,用 requests 直接请求接口或解析 HTML 就够了,没必要上 Selenium 或 Playwright。浏览器自动化启动慢、内存占用高,跑几千道题的时候差距非常明显。我一般先用浏览器开发者工具看 Network 面板,确认题目数据是跟着 HTML 一起回来的,还是单独走了一个 XHR 接口。如果是接口,直接请求接口拿 JSON,解析成本几乎为零;如果是 HTML,就用 lxml 或 BeautifulSoup 提取。
这里有个判断标准:如果翻页时 URL 里的页码参数是规律的,比如?page=2、?page=3,那基本可以确定是普通分页,requests 完全够用。如果翻页靠 POST 请求带 token,或者页码被 JS 加密,那才需要考虑更重的方案。题库类项目里,前者占大多数。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() # 重试策略:题库站点偶尔抽风,3 次重试能省很多手动补抓的功夫 retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) session.mount("https://", HTTPAdapter(max_retries=retry)) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/tiku/list", } def fetch_page(page: int) -> dict: url = f"https://example.com/api/questions?page={page}&size=50" resp = session.get(url, headers=headers, timeout=10) resp.raise_for_status() return resp.json()这段代码的关键点有三个。第一,用 Session 复用连接,比每次裸 requests.get 快不少,尤其是抓几十页的时候。第二,Retry 策略里backoff_factor=0.5表示第一次重试等 0.5 秒,第二次 1 秒,第三次 2 秒,避免短时间内反复打人家服务器。第三,timeout=10必须加,不加的话遇到慢响应会一直挂着,批量抓取时整个脚本卡死。size=50是每页条数,这个值不要设太大,有些站点超过 100 会直接返回空数据,我一般控制在 20 到 50 之间。
2.2 分页拼接与终止条件:别让爬虫变成死循环
分页抓取最容易翻车的地方是终止条件写错。常见做法是判断返回的题目列表长度,如果小于 page_size 就说明到最后一页了。但有些站点最后一页仍然返回满额数据,下一页才返回空,所以更稳的判断是「返回列表为空」或者「当前页码超过接口返回的总页数」。
def crawl_all(max_pages: int = 200) -> list: all_questions = [] for page in range(1, max_pages + 1): data = fetch_page(page) items = data.get("list", []) if not items: break # 返回空列表,说明已经到底 all_questions.extend(items) print(f"page {page} done, total {len(all_questions)}") return all_questionsmax_pages=200是一个保险丝,防止接口异常时无限翻页。实际跑的时候如果 200 页还没抓完,先去看看是不是分页参数没生效,而不是直接把上限调到 10000。我见过有人把上限设成 99999,结果因为接口忽略了 page 参数,同一个页面被反复抓了几万次,白白跑了一晚上。
请求间隔也要控制。题库站点通常不是高并发场景,加个time.sleep(0.3)到time.sleep(1)就够了。如果站点有频率限制,返回 429 状态码,那就把间隔调大,或者用指数退避。不要一上来就上多线程,先把单线程跑通,确认数据没问题再考虑并发。
3. 数据清洗与 sqlalchemy 入库:把脏题目变成结构化记录
3.1 题目字段设计:一道题最少要存哪些列
抓回来的原始数据往往字段混乱,有的题干和选项混在一个 HTML 字符串里,有的答案用字母有的用文字。入库前必须先定义一张干净的题目表。我一般会设计这几个核心字段:题干 content、选项 options(JSON 存储)、正确答案 answer、题型 question_type、来源 source、去重指纹 fingerprint。
from sqlalchemy import create_engine, Column, Integer, String, Text, JSON, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base = declarative_base() class Question(Base): __tablename__ = "questions" id = Column(Integer, primary_key=True, autoincrement=True) content = Column(Text, nullable=False) options = Column(JSON, nullable=True) # 选择题选项,存成列表 answer = Column(String(255), nullable=False) question_type = Column(String(32), index=True) # 单选/多选/判断/填空 source = Column(String(128)) fingerprint = Column(String(64), unique=True, index=True) # 去重指纹 created_at = Column(DateTime, default=datetime.utcnow) engine = create_engine("sqlite:///tiku.db", echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)fingerprint这一列是整个入库环节的关键。它的作用是给每道题生成一个唯一标识,防止同一道题被重复插入。生成方式一般是对题干做归一化处理后取哈希,比如去掉空格、标点、大小写差异,再算 MD5。options用 JSON 类型存,SQLite 和 MySQL 都支持,好处是不用单独建选项表,查询时直接反序列化就行。question_type加了索引,因为组卷时经常按题型筛选。
3.2 清洗逻辑:题干归一化与去重指纹生成
清洗这一步决定了后面组卷能不能用。原始题干里常见的脏数据包括:HTML 标签残留、全角半角混用、多余空白、题号前缀(比如「1.」「第1题」)。这些不处理掉,去重就会失效,同一道题换个题号就被当成新题。
import re import hashlib def normalize_content(raw: str) -> str: text = re.sub(r"<[^>]+>", "", raw) # 去 HTML 标签 text = re.sub(r"^\s*\d+[\.、]\s*", "", text) # 去题号前缀 text = text.replace(" ", " ").strip() # 全角空格转半角 text = re.sub(r"\s+", " ", text) # 合并连续空白 return text def make_fingerprint(content: str, qtype: str) -> str: base = f"{qtype}::{content.lower()}" return hashlib.md5(base.encode("utf-8")).hexdigest()normalize_content里每一步都有对应的问题。去 HTML 标签是因为有些站点题干里嵌了<p>、<br>;去题号前缀是因为不同页面的题号规则不一样;全角空格转半角是因为中文站点里这两种空格混用很常见。make_fingerprint把题型和归一化后的题干拼在一起再哈希,这样即使两道题题干相同但题型不同(比如一道是单选一道是多选),也不会被误判为重复。
入库时用session.merge或者先查 fingerprint 再决定插入,避免唯一约束报错。
def save_questions(items: list): session = Session() new_count = 0 for item in items: content = normalize_content(item["content"]) fp = make_fingerprint(content, item["type"]) exists = session.query(Question).filter_by(fingerprint=fp).first() if exists: continue session.add(Question( content=content, options=item.get("options"), answer=item["answer"], question_type=item["type"], source=item.get("source", "unknown"), fingerprint=fp, )) new_count += 1 session.commit() session.close() print(f"inserted {new_count} new questions")这段逻辑里,先查再插比直接 merge 更可控,因为 merge 在批量场景下会触发大量 SELECT,性能反而差。new_count用来观察每次抓取实际新增了多少题,如果连续几次都是 0,说明要么抓重了,要么去重太激进把不同题也判成重复了。
4. 自动组卷:按题型分值抽题与试卷结构校验
4.1 组卷规则怎么定义:题型、数量、分值三要素
题库有了,下一步是自动生成试卷。组卷的核心是一份规则配置,描述每种题型抽多少道、每题多少分。常见做法是用一个 JSON 或字典来定义规则,比如单选 20 道每题 2 分、多选 10 道每题 3 分、判断 10 道每题 1 分。
PAPER_RULE = { "single": {"count": 20, "score": 2}, "multiple": {"count": 10, "score": 3}, "judge": {"count": 10, "score": 1}, } def generate_paper(rule: dict) -> dict: session = Session() paper = {"questions": [], "total_score": 0} for qtype, cfg in rule.items(): questions = ( session.query(Question) .filter_by(question_type=qtype) .order_by(func.random()) .limit(cfg["count"]) .all() ) if len(questions) < cfg["count"]: raise ValueError(f"{qtype} 题量不足,需要 {cfg['count']} 实际 {len(questions)}") for q in questions: paper["questions"].append({ "id": q.id, "content": q.content, "options": q.options, "answer": q.answer, "type": q.question_type, "score": cfg["score"], }) paper["total_score"] += cfg["score"] session.close() return paperorder_by(func.random())是随机抽题,SQLite 和 MySQL 都支持。但要注意,数据量大时func.random()性能很差,因为它会对全表排序。题库超过几万条时,更高效的做法是先查出该题型的所有 id,在 Python 里随机采样,再按 id 批量查详情。raise ValueError那行是防止题量不足时生成一份残缺试卷,答辩现场如果抽出来只有 15 道单选,会很尴尬。
4.2 试卷结构校验:分值加总与题型完整性检查
生成试卷后必须做一次校验,确认总分、题型数量、答案完整性都没问题。这一步看起来多余,但实际跑的时候经常发现某道题 answer 是空的,或者 options 只有三个选项。
def validate_paper(paper: dict, rule: dict) -> list: errors = [] type_count = {} for q in paper["questions"]: type_count[q["type"]] = type_count.get(q["type"], 0) + 1 if not q["answer"]: errors.append(f"题目 {q['id']} 缺少答案") if q["type"] in ("single", "multiple") and not q["options"]: errors.append(f"题目 {q['id']} 缺少选项") for qtype, cfg in rule.items(): if type_count.get(qtype, 0) != cfg["count"]: errors.append(f"{qtype} 数量不符:期望 {cfg['count']} 实际 {type_count.get(qtype, 0)}") expected_total = sum(c["count"] * c["score"] for c in rule.values()) if paper["total_score"] != expected_total: errors.append(f"总分不符:期望 {expected_total} 实际 {paper['total_score']}") return errors校验函数返回错误列表,为空才说明试卷可用。我一般会在生成后直接调用它,有错误就重新抽题或者报警。这套校验逻辑在批量生成多套试卷时特别有用,能挡住大部分数据质量问题。
5. 避坑与排查:题库系统上线前必须过的五道坎
5.1 抓回来的题目全是重复的
现象:入库几千条,去重后只剩几百条,大量题目内容几乎一样只是题号不同。原因是归一化没做干净,题号前缀、空格、标点差异导致 fingerprint 不一致。解决:在normalize_content里把题号、全角半角、连续空白都处理掉,并且对题干做小写化。如果还有漏网,可以再加一步去掉所有标点符号再算哈希。
5.2 sqlalchemy 批量插入时报 UNIQUE constraint failed
现象:插入过程中突然报唯一约束冲突,脚本中断。原因是并发写入或者同一批次里有重复 fingerprint。解决:入库前先用集合记录本批次已处理的 fingerprint,跳过重复项;或者用session.merge代替session.add,但 merge 性能差,批量场景不推荐。更稳的做法是捕获 IntegrityError 后回滚当前条继续。
5.3 组卷时某些题型抽不满
现象:生成试卷时报「题量不足」,但数据库里明明有这类题。原因是question_type字段的值不统一,比如有的存的是「单选」有的是「single」。解决:入库时做一次题型映射,把所有变体统一成固定枚举值,并且在数据库层面加约束或索引方便排查。
5.4 随机抽题越来越慢
现象:题库到几万条后,order_by(func.random())查询要好几秒。原因是全表排序。解决:先SELECT id FROM questions WHERE question_type=?拿到 id 列表,在 Python 里random.sample抽够数量,再按 id 批量查详情。这样把随机压力从数据库转移到应用层,速度快很多。
5.5 试卷答案和选项对不上
现象:生成的试卷里,答案写的是 B,但选项只有 A、C、D 三个。原因是抓取时选项解析漏了,或者答案字段和选项字段来自不同接口没对齐。解决:在validate_paper里加一条检查——答案字母必须在选项数量范围内。同时入库时对 options 做长度校验,少于两个选项的选择题直接标记为异常数据不入库。
6. 进阶技巧:把题库系统做成可复用的组卷服务
前面五章把链路跑通了,这一章说一个我实际用下来最省事的进阶做法:把组卷逻辑抽成一个独立的服务函数,支持传入不同规则生成不同试卷,并且把生成结果导出成 Word 或 PDF。这样毕设答辩时可以直接演示「换一套规则,十秒生成一份新试卷」,比只展示数据库有说服力得多。
具体做法是把generate_paper和validate_paper包成一个PaperService类,规则从外部 JSON 文件读取,导出用 python-docx 写一个简单模板。
from docx import Document def export_to_docx(paper: dict, path: str): doc = Document() doc.add_heading("自动生成试卷", level=1) for idx, q in enumerate(paper["questions"], 1): doc.add_paragraph(f"{idx}. {q['content']}({q['score']}分)") if q.get("options"): for opt in q["options"]: doc.add_paragraph(f" {opt}") doc.add_paragraph(f"总分:{paper['total_score']}分") doc.save(path)export_to_docx里选项的缩进用四个空格模拟,实际排版可以再调。这个导出函数的好处是,生成的试卷可以直接打印或者转 PDF,答辩时拿在手里比看屏幕直观。规则文件我一般放在rules/目录下,比如rules/default.json、rules/final_exam.json,换规则就是换文件路径。
验证方法也很简单:准备三套规则,分别生成试卷,跑一遍validate_paper,确认错误列表为空,再检查导出的 docx 里题目数量、分值、答案是否齐全。如果三套都通过,说明组卷逻辑是稳的。
我自己的习惯是,每次改完组卷规则或者清洗逻辑,先拿 100 条测试数据跑一遍全链路,确认没问题再上全量。这个习惯帮我省过好几次「改了一行正则,结果把一半题目去重掉了」的后悔药。题库系统这东西,数据质量比代码技巧重要得多,宁可清洗慢一点,也别让脏数据进库。
希望帮到你。
本文还有配套的精品资源,点击获取