news 2026/9/18 16:15:55

用Python解析docx题库:从三支一扶试题到结构化数据

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python解析docx题库:从三支一扶试题到结构化数据

简介:2019年广西柳州市三支一扶考试补录试题及答案解析,是一份面向三支一扶备考群体的真题演练资料,尤其适合报考广西柳州地区岗位、需要熟悉当地笔试题型和难度的考生。资源包内共1个docx文档,文档约46页,整体大小仅64KB,占用空间小,下载后可在电脑或手机上直接阅读、打印,使用方便。题库内容覆盖时事政治、公共基础知识、法律常识、公文写作与处理、计算机基础等三支一扶考试常见模块,题型包括单选题、多选题、判断题,题目后均附标准答案和详细解析。解析部分不满足于简单给答案,还针对十九大报告知识点、著作权归属、管制刑期等易混淆内容进行展开说明,帮助考生理清概念、加深记忆。目前该文档已有94人学习使用,对正在系统备考三支一扶笔试的考生来说,既可用于考前冲刺刷题,也可作为日常巩固复习的题库资源,具有直接的参考和练习价值。

1. 一份三支一扶补录 docx 里,藏着哪些可复用的文档解析问题

拿到一份 46 页的《2019年广西柳州市三支一扶考试补录试题及答案解析.docx》,大多数人会直接打开 Word 看看,但如果你要做刷题小程序、题库系统,或者想按知识点统计考点分布,就必须先把这份 docx 拆成结构化的题目数据。真正的难点不在题目本身,而在于这份文档的排版几乎把所有解析场景的坑都集齐了:题号用“1、”也有用纯数字的,选项有的换行有的不换行,判断题只有题干没有 ABCD 选项,答案里还出现过“略”,多选、单选混在一起,答案符号既有【答案】也有直接“答案:”。虽然只是一份地方考试补录材料,但把它解析干净,能顺手解决掉一批同类题库文档的结构化问题。

2. 从 .docx 到干净题库:文本抽取、段落切分与答案标记清洗

把 Word 文档变成可入库的数据,第一步不是写复杂算法,而是先把 XML 里的段落读出来。docx 本身是 zip 包,python-docx 封装好了解析逻辑,能稳定拿到段落和表格。常见做法是先逐段读取非空文本,再在内存里做切分和清洗,避免反复打开文件。

2.1 用 python-docx 批量抽取正文与题干

先写一个最基础的抽取函数,把每个自然段连同它在文档中的索引号捞出来。索引号很重要,后面校验解析结果时,能直接定位到原文档的某个段落。

from docx import Document def extract_paragraphs(docx_path: str): doc = Document(docx_path) paras = [] for i, para in enumerate(doc.paragraphs): text = para.text.strip() if text: # 过滤空段,保留有内容的段落 paras.append((i, text)) return paras

这个函数返回的是一个由“(段落索引, 文本)”组成的列表。段落索引用来和 Word 原文对照,文本去掉了首尾空白。不要在抽取阶段做太激进的清理,比如替换换行、合并列表,否则后续很难判断某一行属于题干还是选项。

2.2 题干、选项、答案的切分策略

观察这份补录试题的排版,可以看到几类固定模式:题目以“1、”这种数字加顿号开头,选项是“A、”到“D、”,答案用“【答案】X”标注,解析用“【解析】”开头。判断题没有选项,答案直接是“正确”或“错误”。写解析脚本时,我用三个正则分别匹配这三种关键位置。

import re Q_START = re.compile(r'^(\d{1,3})[、..](.+)$') # 匹配题目起始行 OPT_START = re.compile(r'^([A-DA-D])[、..](.*)$') # 匹配选项行 ANS_PATTERN = re.compile(r'【答案】([A-D]+|正确|错误|对|错|略)')

参数说明:\d{1,3}限制题号长度,避免把解析里的页码误判为题目;[、..]同时兼容中文和英文标点;[A-DA-D]覆盖全角选项字母。这样设计是为了让后面清洗环节少处理一类问题。匹配到Q_START时,说明前一道题结束,新题目开始;匹配到OPT_START时,把它追加到当前题目的选项列表;命中ANS_PATTERN则记录答案。

实际操作时,选项不一定每行都完整。原文档里出现过“A、某地 B、某地”写在同一行的情况,所以不能只按行切分。我会先按题目拆分,再把选项部分用re.split[A-D]切段,最后补上缺失的选项字母。

2.3 正则清理与特殊字符处理

从 docx 里读出的文本常带着全角空格、不换行空格、弯引号这类字符。答案比对时,全角顿号和半角逗号会直接影响【答案】AB这种多选答案的解析。我习惯在建表前做一层归一化。

def normalize(text: str) -> str: text = text.replace('\u3000', ' ') # 全角空格转半角 text = text.replace('\xa0', ' ') # 不换行空格转半角 text = text.replace(':', ':') text = text.replace('(', '(').replace(')', ')') text = re.sub(r'[ \t]+', ' ', text) return text.strip()

normalize在读取段落之后、进入业务解析之前调用。注意不要用str.lower()统一选项字母,因为多选题答案“AB”和选项字母“A、B”大小写含义不同,统一小写反而会让后续展示层多一步转换。全角括号统一为半角,是为了让“单项选择题(共3题)”这类题组说明能被正确识别。

2.4 解析效果的验证:抽样比对

解析完成不等于解析正确。一份 46 页的 docx,人工数题号都要花几分钟,脚本跑完却只有几秒,必须验证结果。我常用的办法是:统计解析出的题目总数、每种题型的数量、答案覆盖率,再和原文档的目录或题目标号做交叉核对。

校验项判断标准出现异常时排查方向
题号连续性从 1 递增到 N,不能缺号看是否把解析中的数字误判为题号
题干非空每个题目必须有题干检查Q_START是否匹配了空行
选项数量单选/多选应有 2-4 个选项,判断题可为 0看选项是否跨页被截断
答案覆盖率覆盖率应接近 100%,允许“略”检查ANS_PATTERN是否漏掉“答案:”格式
答案格式A-D 或 正确/错误,不含多余字符清理时是否误删了括号

抽样时不要只抽前几页,docx 后半部分的排版往往更乱。我一般会抽第 1 题、第 N/2 题和最后 3 题,分别确认题号、选项、答案三部分都完整。

3. 结构化存储与题型分类:单选题、多选题、判断题的自动判别

文本切分完成后,还要解决“这题到底属于什么题型”的问题。原文档里虽然没有显式写“单选题”“多选题”,但题干末尾或选项形式已经给出了信号。判断题没有选项,答案只能是“正确”或“错误”;多选题答案至少有两个字母;单选题答案只有一个字母。

3.1 题型识别特征与分类模型

不引入机器学习,用规则判定就足够。我把识别逻辑拆成三个特征:题干中是否含“多选”“多项”;答案长度是否大于 1;选项数量是否为 0。按优先级组合就能分类。

def classify_question(q: dict) -> str: stem = q.get('stem', '') if '多选' in stem or '多项' in stem: return 'multiple' if q.get('options'): if len(q.get('answer', '')) > 1: return 'multiple' return 'single' if q.get('answer') in ('正确', '错误', '对', '错'): return 'judge' return 'unknown'

这里返回的是英文类型,方便写进数据库。len(q['answer']) > 1有一个前提:答案字符串必须已经被 normalize 过,否则“A, B”这种会被算成 4 个字符。所以在进入classify_question之前,要把答案里的逗号、顿号全部去掉,只保留字母。

3.2 落库到 SQLite 的表结构与字段设计

题库数据建议先落到 SQLite,后续导出 JSON 或接入 Web 接口都方便。我设计两张表:questions存题目主体,options存选项。选项拆开存是为了支持动态渲染,也方便统计选项分布。

CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id TEXT, q_no INTEGER, q_type TEXT, stem TEXT, answer TEXT, analysis TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS options ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER, opt_key TEXT, opt_text TEXT, FOREIGN KEY (question_id) REFERENCES questions(id) );

字段说明:doc_id存源文档的标识,比如“2019lz-three-support”这种自定义编码;q_no保留原文档题号,方便和纸面对照;q_typesinglemultiplejudgeanalysis原样保存解析文本,即使只有“略”也保留。选项表用opt_key存“A”“B”,opt_text存选项内容。这样无论后续做网页展示还是接口输出,都不用再重新写清洗逻辑。

3.3 从题库到在线练习接口的映射

结构化之后,生成一个刷题接口的 JSON 响应就非常直接了。把数据库里的记录按题目维度组装成嵌套结构,前端只需要按字段渲染。

def questions_to_json(db_path: str): import sqlite3, json conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(""" SELECT q.*, o.opt_key, o.opt_text FROM questions q LEFT JOIN options o ON o.question_id = q.id ORDER BY q.q_no, o.opt_key """) rows = cur.fetchall() items = {} for row in rows: qid = row['id'] if qid not in items: items[qid] = { "id": qid, "no": row['q_no'], "type": row['q_type'], "stem": row['stem'], "answer": row['answer'], "analysis": row['analysis'], "options": [] } if row['opt_key']: items[qid]["options"].append({"key": row["opt_key"], "text": row["opt_text"]}) conn.close() return json.dumps(list(items.values()), ensure_ascii=False, indent=2)

这段代码用一条 SQL 完成联表查询,再在 Python 里按question_id聚合。注意row_factory设置为sqlite3.Row,这样字段名可以直接用,比元组下标清晰。接口返回的answer字段要不要隐藏,取决于业务需求;如果做练习模式,通常由前端控制显示,后端仍返回原文。

4. 常见解析陷阱:题号断页、缺答案、选项换行、全角符号

即使上面这套流程跑通了,真正处理真实文档时仍然会掉进几个深坑。这些坑在别的题库文档里也普遍存在,值得单独列出来。

4.1 46 页长文档的断页与题号错位

长文档从第 10 页开始,Word 会自动把题目和选项拆到两页。比如第 12 题题干在第 8 页末尾,选项跑到第 9 页开头。python-docx 读到的段落本身是连续的,所以不会真的“丢内容”,但如果你用 PDF 转文本再处理,就会遇到断页问题。处理方案是:优先使用 docx 源文件而不是 PDF;如果只有 PDF,需要按页码保留段落,然后做“跨页拼接”——判断一个段落是否以选项字母开头,若是则追加到上一题解析缓冲区的末尾。

对于 docx,还需要注意分页符和分节符。有的文档会在题目中间插入分页符,读出来是一个空段,过滤空段即可,但如果分页符出现在“A、”和选项正文之间,就要把下一段非空文本也归入选项。

4.2 答案缺失与“略”处理

原文档里出现过“【答案】略”,这是出题方为了省篇幅写的。解析时不能把“略”当作答案存进answer字段,否则刷题程序会认为判断题答案是“略”。我的处理策略是:当答案正则匹配到“略”时,将answer置为空字符串或NULL,同时在analysis里保留“略”字;另外记录一个标志字段answer_missing,方便后续人工补录。

ans_match = ANS_PATTERN.search(text) if ans_match: raw = ans_match.group(1) if raw == '略': q['answer'] = '' q['answer_missing'] = True else: q['answer'] = ''.join(re.findall(r'[A-D]', raw)) # 去掉顿号逗号

注意re.findall(r'[A-D]', raw)只保留大写字母,但原文档也可能出现“abcd”小写。稳妥的做法是先对答案片段做一次upper(),再来提取。判断题的“正确”“错误”不会被这个正则提取到,因为[A-D]匹配不到汉字。

4.3 全角半角归一化

docx 文本里经常混入全角括号、全角数字、全角字母。全角数字“1”用isdigit()判断是 True,但int()转换会报错;全角字母“A”和半角选项“A”在正则里显然是不同的字符。最省事的方案是做一个全角到半角的转换表。

def fullwidth_to_halfwidth(s: str) -> str: result = [] for ch in s: code = ord(ch) if code == 0x3000: code = 0x20 elif 0xFF01 <= code <= 0xFF5E: code -= 0xFEE0 result.append(chr(code)) return ''.join(result)

这段代码覆盖了常用全角字符,包括全角括号、全角句号、全角字母数字。把它放在normalize之前执行,后续所有正则和题目判断都基于统一字符集。注意不要对中文汉字做转换,这个函数只处理0xFF010xFF5E的范围,刚好避开 CJK 统一汉字区。

4.4 清洗后的质量校验清单

我给自己定了一份固定的校验清单,每次解析完都要过一遍:

  • 题目数:解析出的题号是否连续无断号。
  • 选项完整性:每个单选/多选题目是否有且仅有 2-4 个选项,选项字母是否连续。
  • 答案格式:单选题答案长度为 1,多选题答案长度在 2 以上,判断题答案为“正确/错误”或“对/错”。
  • 解析文本:是否含有“解析”关键字,允许为空但不应把“略”当作解析主体。
  • 原文回查:随机抽 5 个题目,用段落索引在 Word 里定位,人工比对内容。

这份清单不放在脚本里也行,但至少要在导出数据之前人工过一次。题库数据的正确性比代码优雅更重要,因为一道题的答案错了,整个练习功能就失去了意义。

5. 解析失败时的排错手段:日志、样本回放与差异对比

脚本解析完一批文档后,如果统计出来的题目数和预期不符,需要快速定位问题。最常见的做法是在解析管道的每个阶段写日志,记录关键计数和异常样本。

5.1 分阶段日志与断言

我习惯用标准库logging记录三个阶段:读取段落、切分题目、清洗答案。每个阶段结束后输出当前累计的题目数和最后一个题号。

import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') def parse_docx(docx_path): paras = extract_paragraphs(docx_path) logging.info(f"parsed {len(paras)} non-empty paragraphs") questions = split_questions(paras) logging.info(f"split {len(questions)} questions") clean_questions(questions) logging.info(f"cleaned {len(questions)} questions, last_no={questions[-1]['q_no'] if questions else None}") # 也可以加上断言 assert len(questions) >= 1, "no question found" for q in questions: assert q['q_no'] > 0, f"invalid q_no: {q}" return questions

断言只做基本一致性检查,不要在这时候验证答案正确性,因为“略”和人工改动都可能触发误报。日志的作用是让你快速判断是哪个阶段的处理出了问题:如果paragraphs很少,说明 docx 路径不对或文件是加密的;如果questions数量比预期少,大概率是Q_START正则匹配不到某些题号写法。

5.2 人工复核样本的选择

让程序自己报告“完成了”,比不上让程序把可疑样本打印出来。我一般会收集三类样本:题号不连续的题目、选项数量异常(超过 4 个或只有 1 个)的题目、答案为空但原文档有答案的题目。把这些样本按原文档顺序输出到一个文本文件,人工逐个核对。

suspects = [] for q in questions: if q['q_no'] != expected_no: suspects.append((q['para_idx'], q['stem'])) expected_no += 1

expected_no从 1 开始,如果某个题号跳过了,就记录当前的段落索引和题干前 20 个字符。这样人工回看时,直接搜索题意就能定位原文档位置。

5.3 对比原文档与解析结果的差异报告

如果确认脚本逻辑没有大问题,但总有一两道题解析不对,可以做一次“全量差异导出”。做法是写一个脚本,把原文档段落和解析结果按段落索引对齐,输出一个 Markdown 或 HTML 表格,左右两列分别是原文和解析后的结构化字段。

我不建议在这种报告里用复杂代码,直接用 Python 写个迭代器遍历就行。关键点在于排序:原文档段落顺序和析出的question_id不能混排,否则差异报告看了也白看。差异报告主要看三个地方:题干是否被截断、选项是否错位、答案是否采集到相邻题目的内容。如果发现采集到相邻题目的答案,通常是Q_START匹配到了“10、”这种含全角数字的题号,导致一道题被拆成两段。

6. 批量处理同类考试补录文档的脚本骨架

本地一份文档解析成功后,后续会碰到格式类似的文档,比如其他年份、其他地市的补录试题。把解析流程封装成一个可重复调用的 pipeline,能省下大量重复劳动。

6.1 一个可复用的 pipeline 函数

下面这段代码把所有阶段串起来,输入 docx 路径,输出一个 JSON 文件,同时把解析统计打到标准输出。

import re, json, logging from docx import Document def pipeline(docx_path, output_json): paras = extract_paragraphs(docx_path) questions = split_questions(paras) for q in questions: normalize_question(q) with open(output_json, 'w', encoding='utf-8') as f: json.dump(questions, f, ensure_ascii=False, indent=2) logging.info(f"done: {len(questions)} questions -> {output_json}") return questions

split_questionsnormalize_question需要按前面第二、三章的逻辑实现,这里不再展开。pipeline 函数本身不做文件校验,由调用方决定是否提前检查 docx 大小等。这样设计的好处是,每个环节可以单独调试:如果不确定某一步,就在函数内部加日志或断点,而不是调整整个管道。

6.2 参数化配置与输出格式

批量处理时,不同文档的题号分隔符可能不同。上一份是“1、”,下一份可能是“1.”或“第1题”。我一般把这些正则模式放到一个配置字典里,由 pipeline 的参数接收。

PATTERNS = { "q_start": r'^(\d{1,3}[、..])', "opt_start": r'^([A-DA-D][、..])', "answer_marker": r'【答案】|答案[::]' } def pipeline(docx_path, output_json, patterns=None): if patterns: PATTERNS.update(patterns) # ...

这样做的好处是,遇到新格式时不需要改核心代码,只要改配置。比如某份文档的答案写成了“参考答案:B”,只需要把answer_marker的正则替换成对应格式即可。注意,不要为了参数化而把正则字符串直接暴露给非技术人员,配置字典最好放到一个单独的config.py文件里。

输出 JSON 的格式也应保持稳定。我通常会输出一个包含metaquestions的对象,meta里记录源文件名、解析时间、题目总数,便于后续审计。这样无论哪个脚本消费这份 JSON,都能拿到足够的上下文信息。

6.3 验证:用新文档跑一遍

封装好 pipeline 后,建议立刻找一份新的同类 docx 跑一遍,不要只用原来那份做验证,否则容易过拟合到已知格式。新文档跑完,看三件事:

  1. 题目总数是否落在合理区间(比如 40-60 道之间)。
  2. 单选题、多选题、判断题的占比是否正常。
  3. 答案字段中是否出现空值,空值比重是否超过 5%。

如果空值过多,优先检查新文档的答案标记,很可能不是“【答案】”,而是“答:”。这时候只需要更新配置里的answer_marker,重新跑一遍 pipeline,不需要改动解析逻辑。把这次调整记录下来,顺便扩充你的PATTERNS字典,下次遇到类似格式就直接命中。

这套处理方式做完以后,从 docx 到结构化题库的时间基本能压缩到几分钟以内,剩下的工作集中在校验和人工补录,而不是反复和 Word 排版搏斗。最后再提醒一句,原始 docx 最好保留一份只读副本,解析脚本永远在副本上操作,避免误修改源文件。

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

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

ResNet残差结构实战解析:从退化问题到工业级微调

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

作者头像 李华
网站建设 2026/9/18 16:05:42

Claude Code 跨会话又“失忆”?TaoToken 供 Key 后 Memory 索引照旧跑

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

作者头像 李华
网站建设 2026/9/18 16:04:51

AT89C51密码锁:矩阵键盘与AT24C02掉电存储设计

简介&#xff1a;这份面向单片机课程设计与电子制作入门的 Word 文档&#xff0c;围绕 AT89C51 单片机电子密码锁展开&#xff0c;适合电子信息、自动化等专业学生及嵌入式初学者参考。内容以 AT89C51 最小系统为核心&#xff0c;串联 44 矩阵键盘、LCD1602 显示与报警模块&…

作者头像 李华
网站建设 2026/9/18 16:04:25

电力系统优化:蒙特卡洛与Copula在可再生能源调度中的应用

1. 项目背景与核心价值这个项目本质上是在解决一个现代电力系统面临的复杂优化问题&#xff1a;如何在高比例可再生能源接入和电动汽车大规模普及的背景下&#xff0c;实现电网的经济高效运行。我去年参与过某省级电网的类似项目&#xff0c;深刻体会到这类问题的挑战性——你不…

作者头像 李华