news 2026/10/12 5:16:18

从docx到刷题系统:无人机题库解析与自动判分实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从docx到刷题系统:无人机题库解析与自动判分实战

简介:这份无人机理论考试题库面向无人机驾驶员、飞行教员及备考人员,系统梳理了理论考试所需的核心知识体系,帮助读者在飞行原理、操作规范、维护保养与法律法规等模块建立完整认知。资源包内含1个docx文档,压缩包约160KB,以题库形式组织,涵盖空气动力学中升力、阻力、推力与重力的相互作用,起飞、巡航、降落各阶段的操作要点,以及航程、航时、飞行速度等性能影响因素。维护部分强调定期检查结构、动力与控制系统,并说明适航证、注册要求及随机文档的作用;法规部分涉及空域划分、飞行计划申请、高度速度限制与驾驶员资质认证。题库还延伸至系统组成、飞行安全与应用领域,如农业植保、航拍测绘、电力巡检和物流运输。目前已有132人学习,适合需要集中刷题、查漏补缺的备考者使用。

1. 一份 docx 题库,怎么变成能刷题、能检索、能自动判分的系统

很多人拿到「无人机理论考试题库.docx」的第一反应是打开 Word 从头翻。翻到第 30 页就放弃了——几百道题混着单选、多选、判断,答案有的在题尾、有的在括号里、有的干脆单独成段,靠肉眼刷题效率极低。真正要解决的问题不是「有没有题」,而是「怎么把这份 docx 变成结构化数据,再变成一个能随机组卷、自动判分、错题重练的小系统」。这件事的门槛比想象中低:一台普通电脑、Python 环境、半小时就能跑通第一版。适合三类人:正在备考无人机执照、想反复刷题的考生;手里有题库 docx 想做成小程序或网页的开发者;以及需要把纸质/文档资料批量转成结构化题库的培训从业者。下面按「先看清 docx 里到底有什么 → 再解析成 JSON → 再落成可刷题的形态 → 最后讲踩过的坑」这条线走一遍。

2. 先摸清 docx 的底:题库文档的三种典型结构和解析选型

2.1 为什么不能直接读文本,必须先看 XML 结构

docx 本质是一个 zip 包,里面word/document.xml才是正文。用python-docx读出来的paragraph.text会丢掉很多信息:加粗、下划线、表格、编号列表的层级。而无人机题库里,正确答案经常靠加粗或下划线标记,选项经常放在表格里。如果只读纯文本,你会得到一堆没有边界的字符串,根本分不清哪行是题干、哪行是选项。

常见做法是先解压 docx,直接看document.xml里题目是怎么组织的。命令很简单:

# 把 docx 当 zip 解开,只看结构,不改原文件 mkdir -p /tmp/qbank && cd /tmp/qbank unzip -o "无人机理论考试题库.docx" -d extracted ls extracted/word/ # 重点看 document.xml 的大小,几百道题通常几百 KB 到几 MB

解压后如果document.xml里大量出现<w:tbl>,说明题目或选项用了表格排版;如果大量出现<w:numPr>,说明用了自动编号。这两种结构的解析方式完全不同,先看清楚再写代码,能省掉后面反复返工的时间。

2.2 三种典型结构,对应三种解析策略

我见过的无人机题库 docx 基本逃不出这三类:

结构类型特征解析难点推荐策略
纯段落型题干、选项、答案都是独立段落答案位置不固定正则匹配题号+答案关键词
表格型每题一个表格,或选项在表格单元格单元格合并、跨行按表格行遍历,取 cell.text
混合型题干段落+选项表格+答案段落边界判断先按题号切块,块内再分段落和表格

选型理由很直接:纯段落型用正则最快,但容错差;表格型必须用python-docx的tables接口;混合型最麻烦,建议先按「题号」把整个文档切成题目块,再在块内判断是段落还是表格。不要一上来就追求全自动,先拿 20 道题做样本,把结构摸清再写通用逻辑。

2.3 用 python-docx 做一次结构探查

在写正式解析前,先跑一段探查脚本,统计段落和表格的分布:

from docx import Document doc = Document("无人机理论考试题库.docx") # 统计段落里出现「题号」特征的频率 import re para_count = 0 question_like = 0 for p in doc.paragraphs: text = p.text.strip() if not text: continue para_count += 1 # 匹配 1. 1、 (1) 一、 等常见题号开头 if re.match(r'^[\(\(]?\d+[\.\、\)\)]', text) or re.match(r'^[一二三四五六七八九十]+[、\.]', text): question_like += 1 print(f"非空段落数: {para_count}") print(f"疑似题干段落数: {question_like}") print(f"表格数量: {len(doc.tables)}") # 打印前 30 个非空段落,人工确认结构 for i, p in enumerate(doc.paragraphs[:60]): t = p.text.strip() if t: print(i, repr(t[:80]))

这段代码的逻辑:先统计段落总数和疑似题干的段落数,如果两者接近,说明是纯段落型;如果表格数量很大而段落里题干很少,说明题目在表格里。打印前 60 个非空段落是为了人工确认题号格式、选项前缀(A. / A、/ A))、答案标记方式。参数上,正则里的\d+匹配阿拉伯数字题号,[一二三四五六七八九十]+匹配中文题号,实际题库可能只用其中一种,按探查结果删掉不用的分支。

提示:探查阶段不要急着写解析,先把题号格式、选项前缀、答案标记这三件事确认清楚,后面代码的准确率取决于这三个正则写对没有。

3. 把 docx 解析成结构化 JSON:题号切块、选项提取、答案对齐

3.1 按题号切块:解析的地基

不管哪种结构,第一步都是把整份文档切成「一题一块」。切块的依据是题号,所以题号正则必须准。常见题号格式有1.、1、、(1)、(1)、一、,无人机题库里最常见的是1.和1、。

import re from docx import Document doc = Document("无人机理论考试题库.docx") # 题号正则:行首可选的括号 + 数字/中文数字 + 分隔符 Q_HEAD = re.compile(r'^[\(\(]?\s*(\d+|[一二三四五六七八九十]+)\s*[\.\、\)\)]\s*') blocks = [] current = None for p in doc.paragraphs: text = p.text.strip() if not text: continue if Q_HEAD.match(text): if current: blocks.append(current) current = {"raw": [text]} else: if current: current["raw"].append(text) if current: blocks.append(current) print(f"切出题目块: {len(blocks)}") print("第一块内容:") for line in blocks[0]["raw"]: print(" ", line)

逻辑说明:遍历所有非空段落,遇到匹配题号的行就开一个新块,否则追加到当前块。这样每个块的raw列表里,第一行是题干(含题号),后面是选项和答案。参数上,Q_HEAD里的\s*允许题号和文字之间有空格,[\.\、\)\)]覆盖了点和顿号、半角和全角括号。如果题库里题号后面直接跟文字没有分隔符,这个正则要相应放宽,但放宽后误匹配风险上升,需要人工抽查。

3.2 选项和答案的提取:正则 + 状态机

切块之后,块内每一行要么是选项,要么是答案,要么是题干的续行。选项的典型前缀是A.、A、、A.,答案的典型标记是答案:、正确答案:、参考答案:。

OPT = re.compile(r'^([A-Da-d])\s*[\.\、\.\)\)]\s*(.+)$') ANS = re.compile(r'(?:答案|正确答案|参考答案)\s*[::]\s*([A-Da-d\s、,,]+)') def parse_block(block): stem_lines = [] options = {} answer = None for line in block["raw"]: # 去掉行首题号 line_clean = Q_HEAD.sub('', line, count=1) if Q_HEAD.match(line) else line m_ans = ANS.search(line_clean) if m_ans: answer = re.sub(r'[\s、,,]', '', m_ans.group(1)).upper() continue m_opt = OPT.match(line_clean) if m_opt: options[m_opt.group(1).upper()] = m_opt.group(2).strip() continue stem_lines.append(line_clean) return { "stem": " ".join(stem_lines).strip(), "options": options, "answer": answer } parsed = [parse_block(b) for b in blocks] # 抽查前 3 题 for q in parsed[:3]: print(q)

逻辑说明:对块内每一行,先尝试匹配答案行,匹配到就记录答案并跳过;再尝试匹配选项行,匹配到就存入 options 字典;剩下的都当作题干。参数上,OPT只认 A-D,如果有多选题选项到 E 或 F,要把[A-Da-d]改成[A-Fa-f]。ANS里的[\s、,,]用来清理「A、B、C」这种多选答案里的分隔符,统一成ABC。这一步做完,你会得到一批结构化对象,但一定会有解析失败的题,下一节讲怎么定位。

3.3 解析质量校验:三道检查必须过

解析完不能直接用,先跑校验。我一般查三件事:题干为空的题、选项少于 2 个的题、答案不在选项里的题。

bad = [] for i, q in enumerate(parsed): if not q["stem"]: bad.append((i, "题干为空")) elif len(q["options"]) < 2: bad.append((i, f"选项过少: {list(q['options'].keys())}")) elif q["answer"] and any(a not in q["options"] for a in q["answer"]): bad.append((i, f"答案不在选项中: {q['answer']} vs {list(q['options'].keys())}")) print(f"总题数: {len(parsed)}, 异常题数: {len(bad)}") for idx, reason in bad[:20]: print(idx, reason) print(" raw:", parsed[idx]["stem"][:60])

逻辑说明:题干为空通常是切块时把答案行误当题干,或者题号正则漏匹配导致块没切开;选项过少通常是选项用了表格而段落里没读到;答案不在选项里通常是答案标记格式特殊,或者选项前缀不是 A-D。这三类异常各自对应不同的修复方向,先分类再修,不要盲目改正则。参数上,len(q["options"]) < 2这个阈值对判断题不适用——判断题可能只有「正确/错误」两个选项但没有 A/B 前缀,需要单独处理。

注意:如果异常题集中在某几页,很可能是那几页用了不同的排版(比如表格),回到 2.1 的 XML 层面确认,不要硬改正则。

4. 从 JSON 到可刷题形态:随机组卷、自动判分、错题重练

4.1 存成 JSON 和 SQLite,两种形态各有用

解析结果先落盘,最省事的是 JSON,方便人工检查和版本对比;要支持随机组卷和错题统计,SQLite 更合适。

import json import sqlite3 # 落 JSON with open("qbank.json", "w", encoding="utf-8") as f: json.dump(parsed, f, ensure_ascii=False, indent=2) # 落 SQLite conn = sqlite3.connect("qbank.db") conn.execute(""" CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, stem TEXT NOT NULL, options TEXT NOT NULL, answer TEXT, type TEXT ) """) for q in parsed: qtype = "多选" if q["answer"] and len(q["answer"]) > 1 else "单选" conn.execute( "INSERT INTO questions (stem, options, answer, type) VALUES (?, ?, ?, ?)", (q["stem"], json.dumps(q["options"], ensure_ascii=False), q["answer"], qtype) ) conn.commit() conn.close()

逻辑说明:JSON 保留原始结构,适合 diff 和人工修;SQLite 的options字段存 JSON 字符串,查询时再解析。type字段按答案长度粗判单选/多选,判断题需要额外规则(比如选项只有「正确/错误」)。参数上,ensure_ascii=False保证中文不转义,方便肉眼检查;AUTOINCREMENT让 id 稳定,方便错题表关联。

4.2 随机组卷和自动判分的最小实现

有了 SQLite,组卷就是一条 SQL,判分就是字符串比对。

import random import sqlite3 import json def make_paper(n=20): conn = sqlite3.connect("qbank.db") rows = conn.execute( "SELECT id, stem, options, answer, type FROM questions ORDER BY RANDOM() LIMIT ?", (n,) ).fetchall() conn.close() return [ {"id": r[0], "stem": r[1], "options": json.loads(r[2]), "answer": r[3], "type": r[4]} for r in rows ] def grade(paper, user_answers): # user_answers: {question_id: "A"} 或 {"A","B"} wrong = [] for q in paper: ua = user_answers.get(q["id"], "") if isinstance(ua, (list, set)): ua = "".join(sorted(ua)) if ua.upper() != (q["answer"] or "").upper(): wrong.append(q["id"]) return wrong paper = make_paper(5) for q in paper: print(q["id"], q["stem"][:40], list(q["options"].keys()), "答案:", q["answer"])

逻辑说明:ORDER BY RANDOM() LIMIT ?是 SQLite 里最简单的随机抽样,题量上千时性能也够用。判分时把用户答案统一成大写字符串再比对,多选题要求顺序无关,所以先sorted再拼接。参数上,n=20是组卷题数,按考试要求改;wrong列表就是错题 id,可以写回一张wrong_questions表做重练。

4.3 错题重练:把错题 id 存下来,下次优先出

错题重练的核心是记录错题 id 和错误次数,组卷时给错题更高权重。

conn = sqlite3.connect("qbank.db") conn.execute(""" CREATE TABLE IF NOT EXISTS wrong_log ( qid INTEGER, wrong_count INTEGER DEFAULT 1, last_wrong_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (qid) ) """) def record_wrong(qids): for qid in qids: conn.execute(""" INSERT INTO wrong_log (qid, wrong_count) VALUES (?, 1) ON CONFLICT(qid) DO UPDATE SET wrong_count = wrong_count + 1, last_wrong_at = CURRENT_TIMESTAMP """, (qid,)) conn.commit() def make_paper_with_wrong(n=20, wrong_ratio=0.5): n_wrong = int(n * wrong_ratio) wrong_rows = conn.execute( "SELECT qid FROM wrong_log ORDER BY wrong_count DESC LIMIT ?", (n_wrong,) ).fetchall() wrong_ids = [r[0] for r in wrong_rows] placeholders = ",".join("?" * len(wrong_ids)) if wrong_ids else "0" rows = conn.execute( f"SELECT id, stem, options, answer, type FROM questions WHERE id IN ({placeholders})", wrong_ids ).fetchall() # 剩余题量从非错题里随机补 remain = n - len(rows) if remain > 0: rows += conn.execute( f"SELECT id, stem, options, answer, type FROM questions WHERE id NOT IN ({placeholders}) ORDER BY RANDOM() LIMIT ?", wrong_ids + [remain] ).fetchall() return rows

逻辑说明:wrong_log用ON CONFLICT做 upsert,同一题错多次就累加wrong_count。组卷时先取错误次数最多的若干题,再从非错题里随机补足。参数上,wrong_ratio=0.5表示一半题来自错题,备考后期可以调到 0.7 甚至 0.8,把精力集中在薄弱题上。注意placeholders在wrong_ids为空时要给一个占位,否则 SQL 语法错误。

5. 避坑与排查:解析无人机题库 docx 时最容易翻车的 5 个点

5.1 现象:解析出来题目数量对,但题干和选项串行

原因:题库里题干和选项之间没有空行,切块时把选项行也当成了题干续行,或者题号正则漏匹配导致上一题的选项被并进下一题。解决:在parse_block里对每一行先判断是不是选项,再判断是不是题干;同时打印前 20 题的stem和options,人工核对边界。如果串行集中在某几题,单独看那几题的 raw 行,多半是题号格式特殊(比如1)而不是1.)。

5.2 现象:答案全是 None

原因:答案标记不是「答案:」,而是「正确答案是」「参考答案为」「【答案】」等变体。解决:把ANS正则改成(?:答案|正确答案|参考答案|【答案】)\s*[::是为]?\s*([A-Da-d\s、,,]+),覆盖更多写法。改完先跑校验,看答案不在选项里的异常是否减少。如果答案单独成段且没有标记词,只能靠位置规则(比如每题最后一行是答案)来兜底,但位置规则不稳定,优先找标记词。

5.3 现象:选项只解析出 A 和 B,C、D 丢了

原因:选项用了表格排版,doc.paragraphs读不到表格里的文字。解决:改用doc.tables遍历,或者在切块时同时遍历段落和表格。python-docx里段落和表格是分开的两个集合,顺序信息会丢,所以混合型题库建议直接解析document.xml,按 XML 节点顺序遍历,才能保证题干、选项、答案的相对顺序正确。

5.4 现象:多选题答案被判成单选

原因:答案字段是「A、B、C」,清理分隔符后变成ABC,但type判断用了len(answer) > 1,如果答案只有AB会判成多选,但如果答案写成「A B」中间有空格,清理后是AB没问题;真正的问题是有些题库多选题答案只写了一个字母但实际是多选。解决:不要只靠答案长度判断题型,结合题干里的「多选」「单选」「判断」关键词,或者看选项数量。判断题单独用「正确/错误」选项识别。

5.5 现象:随机组卷每次出的题重复率高

原因:ORDER BY RANDOM()在题量少时重复正常,但如果题量上千还重复,可能是 SQLite 的随机种子问题,或者组卷逻辑里错题部分固定取了同一批。解决:错题部分按wrong_count排序取前 N 是故意的,但非错题部分要确保NOT IN排除的是当前已选的错题 id,而不是所有错题。另外,如果每次组卷都从同一个连接读,确认没有缓存。题量少时接受重复,题量多时重复率高就检查LIMIT和NOT IN的参数拼接。

6. 进阶:把题库做成可检索的本地刷题页,以及我自己的使用习惯

解析成 JSON 和 SQLite 只是中间态,真正提升刷题效率的是把它变成一个能按关键词检索、能标记、能导出错题本的本地页面。最轻量的做法是用 Python 起一个本地 HTTP 服务,前端一个 HTML 页面,后端两个接口:/search?q=和/paper?n=。不需要框架,http.server就够。

import json import sqlite3 from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) qs = parse_qs(parsed.query) conn = sqlite3.connect("qbank.db") if parsed.path == "/search": kw = qs.get("q", [""])[0] rows = conn.execute( "SELECT id, stem, options, answer FROM questions WHERE stem LIKE ? LIMIT 50", (f"%{kw}%",) ).fetchall() data = [{"id": r[0], "stem": r[1], "options": json.loads(r[2]), "answer": r[3]} for r in rows] elif parsed.path == "/paper": n = int(qs.get("n", ["20"])[0]) rows = conn.execute( "SELECT id, stem, options, answer FROM questions ORDER BY RANDOM() LIMIT ?", (n,) ).fetchall() data = [{"id": r[0], "stem": r[1], "options": json.loads(r[2]), "answer": r[3]} for r in rows] else: data = {"error": "not found"} conn.close() body = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

逻辑说明:/search用LIKE做题干模糊检索,适合查某个知识点(比如「空域」「气象」)相关的所有题;/paper返回随机组卷。参数上,LIMIT 50防止检索结果过多,n默认 20。前端页面用fetch调这两个接口即可,不需要额外依赖。这个服务只监听127.0.0.1,本机使用,不对外暴露。

进阶用法里,我还会做两件事:一是把错题导出成单独的 docx 或 Markdown,考前只看错题;二是给每道题打标签(比如「法规」「气象」「飞行原理」),组卷时按标签抽题,针对薄弱模块集中练。标签可以存在questions表加一列tags,或者单独一张tags表。

进阶功能实现方式适用场景
关键词检索SQL LIKE 或 FTS5查某个知识点所有题
标签组卷questions 加 tags 列按模块集中突破
错题导出查 wrong_log 生成 Markdown考前只看错题
模拟考试固定题量+计时考前适应节奏

最后说个我自己的习惯:解析完第一版后,我不会马上开始刷,而是先随机抽 30 题,对着原始 docx 逐题核对题干、选项、答案是否一致。这一步花 20 分钟,能避免后面刷了几百题才发现答案错位的血泪教训。题库这东西,数据错了比没有更可怕,因为它会让你把错的记成对的。希望帮到你。

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

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

PLC联锁控制系统在污水泵站无人值守中的设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 5:15:47

《聚敛无厌》试玩报告:鼠标单指操作如何重构ARPG战斗逻辑

《聚敛无厌》试玩版出了之后&#xff0c;我第一时间把它装进硬盘&#xff0c;用了差不多三个晚上把可玩内容全部跑完。说句实话&#xff0c;最初吸引我的不是“反套路ARPG”这种宣传语&#xff0c;而是“靠鼠标就能玩”这个描述。作为一个从暗黑类游戏一路玩过来的老玩家&#…

作者头像 李华
网站建设 2026/10/12 5:15:07

真实日志驱动的LightFM音乐推荐系统:冷启动与可解释性实战

简介&#xff1a;这是一套面向计算机专业本科生的毕业设计级音乐推荐系统实战项目&#xff0c;专为正在开展毕设、课程设计或期末大作业的学习者打造&#xff0c;聚焦机器学习在个性化推荐场景中的工程落地。项目采用主流技术栈实现协同过滤与特征工程驱动的推荐逻辑&#xff0…

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

SpringBoot+Vue+MyBatis+MySQL铁路订票管理系统源码深度拆解

铁路订票管理系统&#xff0c;这个题目在毕业设计和Java学习者圈子里出现频率相当高。我不是第一次见这类项目&#xff0c;但说句实在话&#xff0c;能把一套基于SpringBoot Vue MyBatis MySQL的完整源码写明白、讲清楚&#xff0c;让拿到代码的人不只是会跑起来&#xff0c…

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

外部记忆层实战:给对话式AI装上claude-mem记忆系统

前阵子我在整理自己的 AI 工作流时&#xff0c;又碰到了那个老问题&#xff1a;和对话式 AI 助手聊过的内容&#xff0c;下一次打开会话&#xff0c;它全忘了。你可以把它当成一个只有几分钟记忆的同事&#xff0c;每次都要重新自我介绍、重新交代项目背景&#xff0c;甚至连你…

作者头像 李华