news 2026/10/31 12:58:30

爬虫题库自动生成系统实战:从采集清洗到组卷完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
爬虫题库自动生成系统实战:从采集清洗到组卷完整实现

简介:利用爬虫的题库自动生成系统是一份面向计算机相关专业毕业设计的完整Java项目,适合需要完成选题、快速搭建系统原型或学习爬虫与Web开发结合的学生。项目涵盖后端业务逻辑、前端展示页面以及相关配置,压缩包共122个文件,其中包含71个Java源码文件、22个Vue组件文件、7个JavaScript脚本及XML、JSON等配置,整体仅474KB,结构紧凑,便于本地导入和二次开发。已有287人学习下载,说明具备一定的参考热度。通过该资源可以掌握爬虫抓取题目数据、清洗入库、自动组卷与题库管理等功能实现思路,同时学习Spring或SpringBoot等项目框架下的前后端分离开发方式,也能了解文件上传处理等常见模块的编码细节,对毕业设计答辩和技术提升均有实用价值。

1. 毕设选“爬虫+题库自动生成”,先想清楚系统到底要解决什么

毕设季最常被问到的题目之一,就是“利用爬虫的题库自动生成系统”。很多人看到这个标题的第一反应是有现成代码、能跑、有东西可写,但真正拿到手以后才发现,它要解决的并不是“用AI凭空生成题目”这件事,而是三件更具体的事:持续从公开可访问的网页采集题目;把带HTML标签、全角半角混乱、选项缺失的脏数据清洗成结构化题干、选项和答案;再按知识点、题型和难度自动组合成一份可导出、可打印的试卷。这套系统的价值不在于爬虫本身,而在于把“网上散落的题目”变成“自己库里能查、能出卷、能量化统计的题库”。

我见过不少同学把题目做成纯requests加正则的采集脚本,跑完一批网页就把数据丢进一个Word文档里,最后答辩时被问到“题库数据在哪、怎么查重、怎么抽题”就卡住了。原因不是代码写得少,而是没把“自动生成”这四个字理解透。它能快速出成果的路线,是批量采集加上结构化清洗,再加上选项乱序、参数替换和分层组卷这套确定性流程,而不是依赖一个不稳定的生成模型。这篇实战笔记就按我做过的一个相似系统的完整路径来讲:从架构设计、爬虫模块、清洗去重,到排坑和答辩演示。适合Python基础扎实、想在一个学期内完成毕设,并且不想把核心押在外部API上的同学。

2. 系统先拆成四条数据流:从网页页面到可导出试卷的架构设计

2.1 为什么把题库系统拆成采集、清洗、存储、组卷四个独立模块

刚开始规划这套系统时,很容易犯一个错误:把所有逻辑写进一个从上到下顺序执行的脚本里——先抓页面、再正则抠题、直接存成CSV、最后用一条命令生成试卷。这样写能跑,但只要任何一个环节出错,整个流程就得从头再来,而且你根本说不清数据是在哪一步变脏的。我当时的做法是先画数据流,把系统切成四个职责单一的模块:采集端只负责拿HTML和JSON,清洗端只负责把页面片段变成干净的题面字段,存储端只负责结构化落库,组卷端只负责从库里按条件取题导出。

这个分层带来的直接好处是每一步都可以单独调试和验证。比如采集端写好后,先存一批原始HTML文件,不急着入库;清洗端用这批固定样本反复改正则,不用担心每次调试都要重新请求网络。等到清洗逻辑稳定了,再把两部分用队列接起来。另一个好处是出错时可定位:如果库里出现了重复题,你只需要怀疑清洗端的去重逻辑,而不是怀疑采集端重复抓取了同一个页面。数据流是单向的,采集端不知道清洗端怎么处理字段,清洗端不知道组卷端怎么排列选项,模块之间的唯一约定就是一张已经定义好的题目字典结构。

网页页面 -> 采集端(requests) -> 原始HTML/JSON 原始HTML/JSON -> 清洗端(解析/正则/OCR标记) -> 结构化题目 结构化题目 -> 存储端(SQLite) -> 题目库 题目库 -> 组卷端(条件筛选/选项乱序) -> 导出Word/Excel试卷

这里有一个容易被忽略的架构决策:存储层不要用CSV或Excel当作主数据库,要用SQLite或MySQL这类真正的数据库。原因很简单,组卷端需要按知识点、难度、题型做条件查询,CSV每次都要全量读进内存再过滤,数据量上了几千条就会明显变慢。而且SQLite是单文件数据库,毕设交付时把.db文件一起交出去,对方直接就能跑起来,省去安装数据库服务的麻烦。下一节我给出具体的建表语句。

2.2 题目表、选项表和采集记录表:一题一行还是题面选项分开

题库结构设计决定了后面所有功能的复杂度上限。我见过最少的设计是“一道题一行”,所有字段塞进一个表里,选项用A|B|C|D这种分隔符拼在一个字段中。这种设计做采集入库确实简单,但到了组卷端要乱序选项时就很难受:你得把选项拆开、重新排序、再拼回去,而且答案字段必须跟着动,稍不注意答案就对不上了。所以标准做法是把题面主体和选项拆成两张表,题面存题干、知识分类、难度、来源等固有属性,选项单独存question_id + option_key + option_text + is_correct,这样乱序、替换选项、统计答案分布都只需要对子表做操作。

采集记录表是另一个容易被漏掉的表。它不存题目内容,只存“哪个URL在什么时间抓过、是否成功、重试了几次”。这张表的用途是断点续采和增量更新。比如爬了2000个页面后程序崩了,重启时只要查这张表就能跳过已经成功的URL;下一次运行时也能通过比对URL是否出现过来决定要不要重新抓取。很多翻车现场都是因为没有这张表,导致重复采集了几百页,去重时才发现库里有一堆一模一样的数据。下面是我建表用的核心SQL,你可以直接参照改字段名。

CREATE TABLE questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, stem TEXT NOT NULL, answer TEXT, analysis TEXT, question_type TEXT DEFAULT 'choice', subject TEXT, chapter TEXT, difficulty TEXT DEFAULT 'medium', source_url TEXT, source_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(source_url, source_id) ); CREATE TABLE options ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, option_key TEXT NOT NULL, option_text TEXT NOT NULL, is_correct INTEGER DEFAULT 0, FOREIGN KEY (question_id) REFERENCES questions(id) ); CREATE TABLE crawl_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, status TEXT, retry_count INTEGER DEFAULT 0, fetched_at DATETIME, UNIQUE(url) );

这段SQL做了三件关键的事。questions表设置了UNIQUE(source_url, source_id),保证同一个来源页面的同一道题不会因为重复运行采集脚本而插两次;options表把正确答案和选项文本独立存储,方便组卷端重新排序;crawl_records表专门记录采集状态,给续采提供依据。参数说明两点:question_type和difficulty字段要提前定好可枚举的值,不要自由填写,否则组卷筛选时WHERE difficulty = '难'会漏掉写成了“困难”的数据;source_id是题目在来源页面上的编号,如果页面没有编号,就取标题文本的哈希值。

这里还要提醒一个容易被忽略的边界:这套系统大概率会依赖某个公开站点的题目页面,做毕设演示时没问题,但如果要长期使用或部署成对外服务,你必须在采集策略上控制频率和范围,优先选择有公开许可或明确允许非商业使用的数据源,并且保留来源URL字段以便溯源。不要为了追求数据量去对抗站点的风控措施,这是毕设项目能不能顺利落地的底线问题。

2.3 数据字典与字段约定:几张表之间如何对齐

有了表结构还不够,模块之间的数据对齐靠的是约定。我在实际项目里维护了一个question_dict字段清单,规定清洗端输出给存储端的字典必须包含stem、answer、analysis、subject、chapter、question_type、source_url、options这八个键,其中options必须是列表类型,列表里每个元素是{"key": "A", "text": "某某选项", "is_correct": 0}的结构。这个约定写在一个独立的schema.py文件里,清洗端和组卷端都引用它,避免两边各写各的字段名导致数据对不上。

用表格把这个约定列出来,后面写代码时可以对照检查:

字段名类型是否必填说明
stemstring是题干纯文本,不含HTML标签
answerstring是答案,选择题存“A”或“BCD”
analysisstring否解析内容,没有就填空字符串
question_typestring是choice / judge / fill
subjectstring是所属科目,如“数据结构”
chapterstring否章节或知识点标签
difficultystring否easy / medium / hard
source_urlstring是题目来源页面URL
optionslist按题型选择题必填,判断题可为空列表

字段约定里最值钱的细节是answer和options的联动逻辑——选择题的answer不要从清洗端直接抄原始页面的文本,而是要在清洗端完成“识别答案字母、标记对应选项is_correct”两步后再存库。这样组卷端乱序选项时只需要重排options,再根据is_correct=1的选项重新算出答案字母,彻底避开“原来选B,打乱顺序后还得手工改答案”的经典翻车情况。下一章就进入采集端的代码实现。

3. 爬虫采集端怎么落:请求参数、解析模板与断点续采

3.1 技术选型:requests加parsel,还是直接上Scrapy

爬虫模块的选型直接影响开发节奏。我的建议是,除非题目站点的页面数量级在十万以上,否则不要上Scrapy。Scrapy自带调度器、去重器、中间件和Item Pipeline,架构上很完整,但它的调试方式对毕设周期来说偏重——写一个跑通的爬虫要先理解引擎、爬虫、管道之间的关系,出了问题要翻日志定位,很多同学把时间耗在这里。用requests加parsel的组合就直白得多:requests负责拿页面,parsel负责用CSS选择器或XPath提取内容,逻辑是线性的,断点打在哪个函数就看哪个函数。

parsel这个库值得单独说一句。它是Scrapy底层用的解析库,但可以独立安装使用,API比BeautifulSoup更接近浏览器选择器语法,.css("选择器::text").get()这种写法在写解析模板时很顺手。如果你对BeautifulSoup更熟,也可以继续用,不影响架构。真正决定采集效率的不是库的选择,而是两点:是否复用连接、是否处理编码。前者用requests.Session()解决,后者用resp.encoding = resp.apparent_encoding解决。不处理编码的话,页面里的中文可能变成乱码,后面清洗端怎么努力都救不回来。

3.2 一个能跑的采集模板:从页面列表到结构化题目的最小爬虫

写采集代码前先花半小时手工分析目标页面结构,用浏览器开发者工具查看题目列表页里每道题对应的HTML节点,确定三件事:题目的CSS选择器路径、选项的CSS选择器路径、答案在哪个节点下。这个准备工作比写代码本身重要得多,因为解析模板的准确性直接决定清洗端的输入质量。给一个最小可用的采集函数骨架,假设目标页面每道题都在.question-item节点内,题干在.stem节点,选项在.option节点,答案在.answer节点:

import time import requests from parsel import Selector HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" } def parse_question_page(page_url, session): resp = session.get(page_url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding sel = Selector(text=resp.text) raw_questions = [] for card in sel.css(".question-item"): stem = card.css(".stem::text").get(default="").strip() options = card.css(".option::text").getall() options = [opt.strip() for opt in options if opt.strip()] answer = card.css(".answer::text").get(default="").strip() if not stem: continue raw_questions.append({ "stem": stem, "options": options, "answer": answer, }) return raw_questions def crawl_seed_pages(seed_pages, max_pages=10, delay=1.5): session = requests.Session() all_items = [] for seed in seed_pages: for page_no in range(1, max_pages + 1): page_url = f"{seed}?page={page_no}" try: items = parse_question_page(page_url, session) except requests.RequestException as exc: print(f"[skip] {page_url}, reason: {exc}") continue if not items: print(f"[done] {page_url} no more items") break all_items.extend(items) time.sleep(delay) return all_items

这段代码有几个关键参数要说明。timeout=10是请求超时上限,超过10秒没响应就抛异常,避免某个卡死的页面拖住整个采集流程;resp.raise_for_status()会在返回HTTP 4xx或5xx时直接中断本次请求,配合外层try跳过坏页面。delay=1.5是每两个页面之间的固定间隔,单位是秒,这个值不能省——没有间隔地连续请求几十页,很快会被站点限流。max_pages限制每个种子页最多翻10页,防止失控。如果发现某页解析出来的题目数为空,直接break跳出循环,因为列表页一般是按时间倒序或页码递增排列的,翻到没有内容的页就可以认为已经到底了。

这里有一个实操技巧:首次调试解析模板时,不要直接跑整个循环,先单独抓一个页面存成sample.html,然后用Selector(text=open("sample.html", encoding="utf-8").read())反复试验CSS选择器。这样既不会频繁请求目标站点,也不会因为网络波动干扰调试判断。等到选择器敲定,再把解析逻辑放回采集函数里跑全量。

3.3 断点续采与增量更新:用哈希和采集记录表避免重复劳动

爬虫跑一半崩溃是常态,不崩溃才是意外。如果脚本没有续采能力,重启后从第一页重新抓,不仅浪费流量,还会在库里制造大量重复数据。我一般用URL的哈希值做去重判断:每个待采集URL先算一个SHA-256指纹,在crawl_records表里查一下这个指纹是否存在,存在就跳过,不存在才发起请求。这个逻辑可以单独写成一个小函数,挂在采集主循环前面:

import hashlib def url_fingerprint(url): return hashlib.sha256(url.encode("utf-8")).hexdigest() def should_fetch(url, conn): fp = url_fingerprint(url) cursor = conn.execute( "SELECT status FROM crawl_records WHERE url = ?", (url,) ) row = cursor.fetchone() if row is None: return True if row[0] == "success": return False return True def mark_fetched(url, status, conn): fp = url_fingerprint(url) conn.execute( "INSERT INTO crawl_records(url, status, fetched_at) VALUES(?, ?, datetime('now')) " "ON CONFLICT(url) DO UPDATE SET status = excluded.status", (url, status), ) conn.commit()

url_fingerprint的作用是给URL一个定长摘要,避免把完整URL直接放在表里做比较时因为长度和特殊字符产生问题;should_fetch查询采集记录表,只有从未抓过或失败过的URL才会继续抓;mark_fetched在请求完成后写入状态,ON CONFLICT语句是SQLite的“存在就更新”写法,这样同一URL不会被插入两次。这三个函数组合起来就实现了断点续采和增量更新的最小闭环。

实际操作时还有一个细节:状态不要只记录success和failed,要区分“解析成功但题目数为0”和“网络错误”。前者说明页面结构可能变了或翻到了末尾,后者说明站点暂时不可达。这两种情况的处理策略完全不同。我习惯把no_items也作为一种状态单独存,方便后续检查是不是页面结构改版导致选择器失效。采集阶段不需要过度设计,但状态字段的值一定要少而明确。

3.4 采集参数的三个必调项:请求间隔、重试次数、超时上限

采集参数是爬虫模块里最像“玄学”的部分。不同站点对爬虫的容忍度不一样,同一个站点不同时间的风控强度也不一样。我在没有更多信息的情况下,会先按下面这组参数起步,跑一个小时内不出错再考虑加速:

参数初始值调整方向说明
请求间隔 delay1.5秒被限速时调到3秒以上控制请求频率,匀速比突发更安全
超时 timeout10秒网络差时调到15秒防止单页卡死拖慢全流程
最大重试次数3次连续失败时降为1次超过3次直接跳过,不恋战
单种子最大页数10页按列表深度调整防止列表页无限翻页

这里最重要的原则是“连续失败要减速,而不是加速重试”。很多同学遇到请求超时第一反应是缩短延时、加快重试,结果让情况变得更糟。正确的做法是记录连续失败次数,连续失败3次就暂停60秒再继续;如果连续失败达到5次,说明当前频率已经不被接受,直接停止采集,等半小时后再手工重启。这组参数不是理论值,是我在多个题库类页面上实测过相对稳妥的起点。

另外说一个容易被忽略的点:请求头里的User-Agent不要用同一个固定值跑全量,可以准备一个包含5到8个常见浏览器的UA列表,每次请求随机取一个。这不是为了对抗什么,而是减少因为“单一UA高频访问”被识别为异常的可能性。配合上文的crawl_records表和延时参数,这个采集模块对毕设项目来说已经足够完整了。

4. 题库自动生成的核心在清洗与去重:三种策略与三个阈值

4.1 题目文本清洗:从带HTML标签的原始片段到干净题干

从网页里解析出来的题干和选项,几乎不可能直接入库。常见脏数据包括:题干里混着<span>、<p>标签;全角括号、全角逗号混用;选项文本带着空格和换行;答案字段里既有“正确答案:B”这种前缀,也有“B”单独出现。清洗端要做的就是把所有字段收敛成统一的纯文本格式。下面是最基础的清洗函数,适合处理大多数HTML来源的题目文本:

import re import html def clean_text(raw_text): if not raw_text or not raw_text.strip(): return "" text = html.unescape(raw_text) # &nbsp; -> 空格, &amp; -> & text = re.sub(r"<[^>]+>", " ", text) # 去掉HTML标签 text = re.sub(r"\s+", " ", text) # 连续空白合并为单个空格 text = text.replace("“", '"').replace("”", '"') text = text.replace("(", "(").replace(")", ")") text = text.replace(",", ",").replace("、", ",") text = text.replace("。", ".").replace(";", ";") text = text.strip() return text

这个函数做了四层处理。html.unescape先把页面里的HTML实体还原成可见字符,这一步不做的话,题干里的&nbsp;会变成一个奇怪的空白符号,后续正则匹配容易漏;re.sub(r"<[^>]+>", " ", text)把标签替换成空格而不是直接删除,是为了避免“题目A标签内容题目B”这种相邻文本被拼成“题目A题目B”;\s+合并连续空白;最后的全角转半角是给文本统一字符形态,后面做相似度去重时,这一步能显著减少“因为全角半角不同导致重复题没被识别”的情况。

要注意一个边界:clean_text只处理文本内容,不要用它处理答案结构。如果原始答案字段是“正确答案:B”,你需要先提取最后的字母,再存成B。我一般会单独写一个parse_answer函数,用正则匹配最后一个A到D的字母(判断题匹配“对/错”或“T/F”),匹配不到就返回空字符串并给这道题打上answer_missing标记。带标记的题可以入库,但要单独筛出来人工处理,不能让它混进正常题目里。

4.2 选项解析与答案对齐:把“ABCD文本”拆成结构化选项

选择题的清洗难点在选项拆解。原始页面的选项文本通常是A. 选项内容或A、选项内容这种格式,清洗时要先按换行或正则拆出每一行,再识别开头的选项字母,最后把选项内容和字母分开。给一个解析选项的函数:

def parse_options(raw_options): if not raw_options: return [] lines = [] for line in raw_options: line = clean_text(line) if line: lines.append(line) parsed = [] for line in lines: match = re.match(r"^([A-D])[\.\、.\s]*(.*)$", line) if match: key = match.group(1) text = match.group(2).strip() parsed.append({"key": key, "text": text, "is_correct": 0}) else: # 无法识别题号,默认追加到上一个选项末尾 if parsed: parsed[-1]["text"] += line return parsed

这个函数有个保守的设计:re.match(r"^([A-D])[\.\、.\s]*(.*)$")只匹配A到D开头的选项,如果某一行没有匹配上,就把这行文本拼接到上一个选项的末尾。这个兜底逻辑能应对“选项内容换行了但下一行没有选项字母”的情况,但也可能把原本是第五个选项的E. 选项内容错误拼进D选项里。所以解析结束后要检查parsed的长度,如果大于4,把它标记为parse_warning,人工确认后再确定是否入库。

答案对齐是最后一步。解析完选项后,用原始的answer字段去匹配选项里的key,匹配到就把对应选项的is_correct设为1。这里有一个重要的参数:如果原始答案是ABD这种多选格式,要逐字匹配并标记多个选项;如果选项里根本没有答案里提到的字母,这道题直接标记为answer_mismatch,不要自作聪明去猜。题库里混入一道答案错误的题,比缺一道题的危害大得多。

4.3 相似题去重:精确去重之外,用Jaccard相似度兜底

题库类系统最怕的数据问题是重复题。来源站点之间互相转载非常普遍,同一道“数据结构中栈的特点”可能在三个不同的页面里有三种不同的表述,题干文本不完全一致,但本质是同一道题。精确去重对这种数据完全无效。我在项目里采用两级去重:第一级用题干的MD5值做精确匹配,去掉一模一样的数据;第二级用Bigram词袋上的Jaccard相似度做模糊去重,把相似度超过阈值的题目合并。

import re import hashlib def exact_hash(stem): return hashlib.md5(stem.encode("utf-8")).hexdigest() def tokenize_bigram(text): text = re.sub(r"[^\w\u4e00-\u9fa5]", "", text.lower()) if len(text) < 2: return {text} return {text[i:i+2] for i in range(len(text) - 1)} def jaccard_similarity(set_a, set_b): if not set_a or not set_b: return 0.0 union = set_a | set_b if not union: return 0.0 return len(set_a & set_b) / len(union) def is_duplicate(new_stem, existing_stems, exact_set, threshold=0.85): if exact_hash(new_stem) in exact_set: return True new_tokens = tokenize_bigram(new_stem) for stem in existing_stems: old_tokens = tokenize_bigram(stem) if jaccard_similarity(new_tokens, old_tokens) >= threshold: return True return False

这段代码的核心是Bigram词袋化。把题干切成连续的两个字符作为token,比如“栈的特点是先进后出”会被切成“栈的”“的特”“特点”“点是”“是先”“先进”“进后”“后出”这些二元组,然后比较两组token的Jaccard相似度。这个做法对题干中的小幅改写有一定容忍度:加了一两个字、换了个标点、调整了语序,Bigram相似度仍然可能维持在高位。exact_set是已经处理过题目的MD5集合,先查精确哈希能跳过大量明显相同的数据,减少对existing_stems列表的遍历。

相似度的阈值是这里最需要调参的地方,我建议用0.85起步,然后按实际数据反馈调整。阈值太高会漏掉改写较多的重复题,阈值太低会把相似但不相同的题也合并掉。实际操作时做一个抽检脚本:随机抽取100对相似度在0.7到0.95之间的题目,人工标注是否真的是重复题,再根据标注结果调整阈值。如果发现大量相似度0.8的题目其实是不同的题,就把阈值升到0.88;如果0.88还漏掉了明显重复的题,就降到0.82。这个校准过程是必须的,不能拍脑袋定一个值就完事。

要注意的时间复杂度问题:当库里题目超过一万条时,每条新题都要和所有旧题做一次Jaccard计算会明显变慢。解决办法有两个:一是只在同科目、同章节的题目里查找近似重复,先缩小范围;二是用MinHash加LSH做索引,但这个对毕设来说属于锦上添花,我实际项目里用“按章节分桶”已经够用了。分桶操作很简单,SELECT stem FROM questions WHERE chapter = ?,只把同一章节的旧题加载进内存参与去重,速度能快一个数量级。

4.4 “自动生成”的落地形态:选项乱序、参数替换与组卷导出逻辑

回到这个系统的核心卖点——“自动生成”。我的实践经验是,这个方向最容易出成果的路线不是让程序从零写题,而是做三种确定的变换:选项乱序、数值参数替换、题干模板化改写。其中选项乱序是最稳定、最不容易出错的一种,因为选项表是独立存储的,重排时只需要根据is_correct重新计算答案字母:

import random def shuffle_options(option_list, seed=None): if len(option_list) < 2: return option_list indexed = list(enumerate(option_list)) if seed is not None: random.seed(seed) random.shuffle(indexed) new_options = [] key_map = {} for new_key, (old_index, option) in enumerate(indexed): new_key_char = chr(ord("A") + new_key) key_map[old_index] = new_key_char option = dict(option) option["key"] = new_key_char new_options.append(option) correct_keys = [opt["key"] for opt in new_options if opt["is_correct"]] new_answer = "".join(correct_keys) return new_options, new_answer

这里有两个参数值得讲。seed参数用于控制随机数的确定性:组卷导出时传入一个固定的随机种子,同一道题每次导出的选项顺序完全一致,这样做到一半想重新生成试卷时,前后两份卷子可以对齐对比;调试时也可以用固定种子复现“答案位置变了”的具体场景。key_map的存在价值是防止选项内容与答案脱节,乱序时同步记录旧索引和新字母的映射,最终答案从句法上只能来自映射后的correct_keys,不存在“答案和选项对不上”的逻辑漏洞。

参数替换是另一种生成方式,适合数值型的计算题。比如题干是“一个栈的容量为5,依次入栈3个元素,问栈顶元素是什么”,可以把5和3替换成随机数,形成一个“新题”。做法是在清洗端人工维护一组模板字段,用占位符标识可替换的位置。这个功能的代码量不大,但收益明显:一份原始的50道计算题可以扩展成几百道表面不同的题。模板化改写则属于进阶方向,可以考虑用生成式模型辅助,但核心流程上不要依赖它。组卷端其实就是按subject + chapter + difficulty查询题目,再对每一题执行乱序或参数替换,最后用python-docx导出Word。组卷条件要支持“难度分布”和“章节占比”,这两个参数通常放在组卷配置里。

5. 爬虫题库项目的常见坑与排查:从风控到题目变乱的定位路径

5.1 爬了几十页后突然拿不到数据:频率限制还是页面结构变了

现象:采集脚本前50页跑得好好的,到第60页开始出现连续的403或Timeout异常,偶尔能成功一页,但还是以失败为主。此时如果用浏览器手动打开同一个页面,大概率能正常访问。

原因:这是典型的请求频率超过站点承受阈值,被限流了。浏览器访问有JavaScript执行、图片加载、用户操作间隔,采集脚本的请求速度远高于真人,模式也单一,容易被识别为异常流量。

解决:先停止脚本至少30分钟,让IP的访问记录冷却下来。重启前把请求间隔从1.5秒提高到3到5秒,并在请求间隔上增加一个随机的抖动值,比如time.sleep(1.5 + random.uniform(0, 1.5))。同时把连续失败超过5次就自动暂停的逻辑加上,暂停期间不重试,只等待。如果冷却后仍然失败,再怀疑是不是页面本身出了问题,用浏览器开发者工具看返回的HTML是否还能解析出题目节点。

5.2 页面能打开但解析结果为空:动态渲染页面和解析模板失效

现象:请求返回了HTTP 200,但解析出来的题目列表为空,或者只解析出几道题,数量远少于页面实际显示的题目数。

原因:分两种情况。第一种是页面内容由JavaScript动态渲染,requests拿到的HTML里只有空壳节点,数据是浏览器加载完脚本后通过接口填充的;第二种是页面结构改版,CSS选择器路径已经不匹配当前的HTML结构。

解决:先在浏览器里用开发者工具查看“网络”请求,如果发现页面数据来自一个JSON接口,直接把采集目标换成那个接口,解析逻辑从CSS选择器改成JSON字段提取,性能更好也更稳定。如果确认没有接口,只能引入无头浏览器,但这对毕设来说代价偏大——无头浏览器占用内存高、运行慢、调试复杂。更现实的方案是换一个数据源,寻找结构更静态的页面。至于结构改版导致的解析失败,只能重新分析页面,更新选择器,同时把旧的crawl_records状态重置为failed,等到下次运行时自动重新抓取。

5.3 题库重复率高企:模糊去重阈值设置不合理

现象:精确去重后,题库里仍然有明显的大段重复题目,比如同一道“二叉树遍历”的题,题干措辞略有不同,选项顺序不同,但本质相同。人工抽查100题里能找出十几道重复。

原因:精确去重只能处理文本完全一致的题目,对改写过的题目无能为力;模糊去重没有生效,要么是阈值设置过高导致相似但不等同的题全被放过,要么是模糊去重只用了题干,没有考虑选项文本对相似度的贡献。

解决:按上一章说的抽检校准流程,把阈值从0.85往下调或往上调,用人工标注结果确定最终值。另外把选项文本作为去重的一部分参与相似度计算——将“题干 + 排序后的选项文本”拼成一个长字符串再算Bigram,这样即使题干被少量改写,选项高度相似也能触发去重。校准阈值前先确认测试样本数量够用,至少抽300道题,否则统计结果没有参考价值。

5.4 图片题目OCR后数据错乱:识别误差与答案错位

现象:有些题目不是文本形式,而是整张图片。用OCR识别后,题干里出现错别字,数字“3”被识别成“8”,选项顺序被打乱,甚至答案字母识别错误,导致入库的题是错的。

原因:OCR引擎的处理能力是有限的,尤其是印刷体、公式、上下标混合的题目,识别错误率很高,而且OCR输出的文本无法完全还原原图的结构关系,选项和答案之间经常错位。

解决:不要把OCR结果直接当作可信数据。给这类题目单独设置source_type = "image"的标记,入库时走独立的检查队列,人工校对后再转入正式题库。如果OCR准确率低于95%,宁可不入正式库,也不要强行用。另一个可选做法是给图片题建一个“图片原文 + OCR结果 + 校对状态”三字段结构,保留原图路径,方便人工打开原图对照。对毕设而言,图片题占比通常不高,偶尔放弃这部分数据换取整体准确率是值得的。

5.5 导出试卷时乱码或选项错位:字符编码与模板字段不一致

现象:用python-docx生成Word试卷后,打开文件发现中文乱码,或选项缩进不一致、答案与选项顺序不匹配。

原因:乱码通常是因为清洗端没有正常处理编码,或者源数据本身就包含了异常字符;选项错位多半是组卷端直接复制了清洗端的原始字段,没有重新走一遍选项乱序和答案对齐接口。

解决:在导出前对所有文本字段强制执行一次clean_text,确保库里即使有脏数据也不会直接进入试卷。选项乱序和答案重算必须成对调用,组卷端不要手工修改option["key"],而是让shuffle_options统一返回重排后的选项和新的答案。导出后抽看前10题,重点检查:题干是否完整、答案是否对应正确选项、选项编号是否连续。这个人工抽检步骤虽然简单,但能拦截大部分演示时的尴尬问题。

6. 让毕设演示不翻车:验收清单与一个可复用的组卷检查

6.1 答辩前用五个指标给系统做体检

系统做完后,真正决定答辩效果的往往不是代码量,而是演示时能不能流畅跑通一条“从题库到试卷”的完整链路。我给自己定的验收标准是五个指标,每个指标对应一个具体问题。第一个是采集可用率:成功解析出至少一道题的页面数占请求页面总数的比例,这个值低于80%说明解析模板还没稳定,先别急着入库。第二个是清洗通过率:能成功提取出题干、选项、答案三要素的题目占采集题目总数的比例,低于90%时优先补解析错误的分支逻辑。第三个是去重后的有效题目数:采集两万条原始数据,去重后应该还有三千到八千条,如果去重后只剩几百条,说明采集源单一或阈值过严;如果还剩一万多,说明去重没生效。第四个是组卷正确率:连续导出三套试卷,人工检查每套的前20题,错误率要低于2%。第五个是演示链路耗时:从点击“生成试卷”到Word文件弹出,最好控制在5秒内,超过10秒答辩现场会显得非常拖沓。

6.2 演示前做一个“一键重建题库”脚本来兜底

答辩现场最怕的不是系统功能少,而是数据出问题。比如前一天测试还好好的,当天早上重跑了一次采集,解析模板因为页面结构微调突然失效,库里数据被截断。为了避免这种翻车,我在交付前会准备一个rebuild_demo.py脚本,它做的事情非常保守:从备份的原始HTML文件中重新跑清洗和入库流程,不发起任何网络请求。这个脚本保证即使目标站点已经改版或无法访问,演示环境里的题库仍然可以重建。操作上就是定期把采集到的原始HTML目录整体压缩成备份包,演示前用备份包重跑清洗入库,整个过程不依赖外部网络,任何教室环境都能适配。这个习惯是我在多次项目演示里总结出来的血泪经验。

6.3 进阶方向:把生成式模型接进来前,先想清楚的三个准备

如果你的系统做完后还有余力做进阶,最自然的方向是用生成式模型辅助题干改写。但接进来之前有三个准备必须完成。第一是确定改写范围:只对chapter标签干净、analysis完整的题目做改写,避免模型在解析信息不全的题上胡编答案。第二是设计人工审核队列:模型产出的新题不能直接入库,必须落到一个review表里,由人工确认答案正确后再标记为可用。第三是控制成本与速度:批量改写几百道题的时间如果可以接受,就离线跑完再入库,而不是在组卷时实时调用,保证演示链路不受外部接口稳定性影响。

这套系统做完之后,我自己印象最深的不是爬虫跑得多快,而是“数据质量决定了系统上限”这句话的分量。爬虫代码只解决了获取问题,真正的工程量在清洗、去重和验证这三步。我现在接到任何新的采集项目,第一件事仍然是先手工分析页面结构,再动笔写解析代码——这个习惯帮我少走了很多弯路。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 12:58:25

Windows下Codex沙箱启动失败:失效ACL与Defender白名单的完整排查与修复

1. 故障现场还原&#xff1a;一场"莫名其妙"的构建中断那天是2026年10月8日&#xff0c;我像往常一样在Windows开发机上跑一个基于Codex的自动化代码生成与构建任务。Codex负责根据需求描述生成模块代码&#xff0c;然后自动调用沙箱环境做语法检查、依赖解析和基础测…

作者头像 李华
网站建设 2026/10/22 5:13:10

博客离线备份全攻略:从wget命令到Python脚本的完整实践

简介&#xff1a;这是一份CSDN博客下载/搬家工具&#xff0c;主要面向需要批量备份与迁移博文的博主或爬虫学习者。作者在本地调试后解决了部分长文及大容量下载问题&#xff0c;但原始代码存在与新版htmlparser.jar相关的兼容性缺陷&#xff0c;源码中部分域名写死为CSDN&…

作者头像 李华
网站建设 2026/10/31 12:58:25

SqlTools.ServiceLayer 实战:JSON-RPC构建本地SQL后端服务

简介&#xff1a;使用Visual Studio Code连接SQL Server时&#xff0c;mssql扩展需要依赖SqlToolsService组件&#xff1b;由于该组件的默认下载地址位于GitHub&#xff0c;国内网络往往无法顺利获取&#xff0c;导致扩展安装完成后在首次连接时报错。此离线资源为zip格式&…

作者头像 李华
网站建设 2026/10/19 7:46:23

Spring Boot应用配置实战:YAML语法与多环境管理

1. 配置文件在现代Java应用中的核心地位Spring Boot应用的配置文件就像一艘轮船的导航系统——它决定了应用启动时的所有关键参数和行为走向。作为从业十年的Java开发者&#xff0c;我见过太多因为配置文件不当导致的"生产事故"&#xff1a;从数据库连接泄露到内存溢…

作者头像 李华
网站建设 2026/10/22 17:58:54

AI应用进生产,实时数据智能如何成为业务护城河?

云栖大会聊到 AI 进生产的时候&#xff0c;我坐在台下最大的感受是&#xff1a;今年已经没有多少人再去吹模型参数了。台上台下反复出现的焦点&#xff0c;是一句话——AI 应用进入生产&#xff0c;开始拼「实时数据智能」。这句话翻译成工程师听得懂的话就是&#xff1a;模型算…

作者头像 李华
网站建设 2026/10/23 6:37:40

Windows Sandbox网络故障排查:DNS解析超时与Hyper-V虚拟交换机修复

这次复盘记录的是我们“Codex-Windows沙箱”项目的一次典型网络故障&#xff0c;从出现症状到定位根因花了六个多小时。如果你也在用Windows Sandbox跑自动化服务&#xff0c;那这个真实案例的排查脉络和坑点应该能帮你省下不少时间。故障本身并不复杂&#xff0c;但极具迷惑性…

作者头像 李华