ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

用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-D-])[、.](.*)$) # 匹配选项行 ANS_PATTERN re.compile(r【答案】([A-D]|正确|错误|对|错|略))参数说明\d{1,3}限制题号长度避免把解析里的页码误判为题目[、.]同时兼容中文和英文标点[A-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_type存single、multiple、judgeanalysis原样保存解析文本即使只有“略”也保留。选项表用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_asciiFalse, indent2)这段代码用一条 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 文本里经常混入全角括号、全角数字、全角字母。全角数字“”用isdigit()判断是 True但int()转换会报错全角字母“”和半角选项“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之前执行后续所有正则和题目判断都基于统一字符集。注意不要对中文汉字做转换这个函数只处理0xFF01到0xFF5E的范围刚好避开 CJK 统一汉字区。4.4 清洗后的质量校验清单我给自己定了一份固定的校验清单每次解析完都要过一遍题目数解析出的题号是否连续无断号。选项完整性每个单选/多选题目是否有且仅有 2-4 个选项选项字母是否连续。答案格式单选题答案长度为 1多选题答案长度在 2 以上判断题答案为“正确/错误”或“对/错”。解析文本是否含有“解析”关键字允许为空但不应把“略”当作解析主体。原文回查随机抽 5 个题目用段落索引在 Word 里定位人工比对内容。这份清单不放在脚本里也行但至少要在导出数据之前人工过一次。题库数据的正确性比代码优雅更重要因为一道题的答案错了整个练习功能就失去了意义。5. 解析失败时的排错手段日志、样本回放与差异对比脚本解析完一批文档后如果统计出来的题目数和预期不符需要快速定位问题。最常见的做法是在解析管道的每个阶段写日志记录关键计数和异常样本。5.1 分阶段日志与断言我习惯用标准库logging记录三个阶段读取段落、切分题目、清洗答案。每个阶段结束后输出当前累计的题目数和最后一个题号。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s %(levelname)s %(message)s) def parse_docx(docx_path): paras extract_paragraphs(docx_path) logging.info(fparsed {len(paras)} non-empty paragraphs) questions split_questions(paras) logging.info(fsplit {len(questions)} questions) clean_questions(questions) logging.info(fcleaned {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, finvalid 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 1expected_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, encodingutf-8) as f: json.dump(questions, f, ensure_asciiFalse, indent2) logging.info(fdone: {len(questions)} questions - {output_json}) return questionssplit_questions和normalize_question需要按前面第二、三章的逻辑实现这里不再展开。pipeline 函数本身不做文件校验由调用方决定是否提前检查 docx 大小等。这样设计的好处是每个环节可以单独调试如果不确定某一步就在函数内部加日志或断点而不是调整整个管道。6.2 参数化配置与输出格式批量处理时不同文档的题号分隔符可能不同。上一份是“1、”下一份可能是“1.”或“第1题”。我一般把这些正则模式放到一个配置字典里由 pipeline 的参数接收。PATTERNS { q_start: r^(\d{1,3}[、.]), opt_start: r^([A-D-][、.]), answer_marker: r【答案】|答案[:] } def pipeline(docx_path, output_json, patternsNone): if patterns: PATTERNS.update(patterns) # ...这样做的好处是遇到新格式时不需要改核心代码只要改配置。比如某份文档的答案写成了“参考答案B”只需要把answer_marker的正则替换成对应格式即可。注意不要为了参数化而把正则字符串直接暴露给非技术人员配置字典最好放到一个单独的config.py文件里。输出 JSON 的格式也应保持稳定。我通常会输出一个包含meta和questions的对象meta里记录源文件名、解析时间、题目总数便于后续审计。这样无论哪个脚本消费这份 JSON都能拿到足够的上下文信息。6.3 验证用新文档跑一遍封装好 pipeline 后建议立刻找一份新的同类 docx 跑一遍不要只用原来那份做验证否则容易过拟合到已知格式。新文档跑完看三件事题目总数是否落在合理区间比如 40-60 道之间。单选题、多选题、判断题的占比是否正常。答案字段中是否出现空值空值比重是否超过 5%。如果空值过多优先检查新文档的答案标记很可能不是“【答案】”而是“答:”。这时候只需要更新配置里的answer_marker重新跑一遍 pipeline不需要改动解析逻辑。把这次调整记录下来顺便扩充你的PATTERNS字典下次遇到类似格式就直接命中。这套处理方式做完以后从 docx 到结构化题库的时间基本能压缩到几分钟以内剩下的工作集中在校验和人工补录而不是反复和 Word 排版搏斗。最后再提醒一句原始 docx 最好保留一份只读副本解析脚本永远在副本上操作避免误修改源文件。本文还有配套的精品资源点击获取
返回列表