Python解析docx补录试题:从文档到结构化题库
简介这是一份2019年广西柳州市三支一扶考试补录试题及答案解析文档集中面向报考广西地区“三支一扶”岗位、需要系统刷题与快速提分的考生。文档共收录46页笔试题目内容覆盖政治理论、法律基础、公文写作、计算机应用、时政热点及事业单位综合知识等常见模块每题均提供参考答案与解析方便对照错题原因强化记忆。整套文档体积仅64KB包含1个docx格式文件排版整齐支持手机、电脑多端阅读也便于打印后集中练习。目前已有94人浏览学习是短期内了解柳州市补录考试题型、命题侧重和难度梯度的实用素材。通过完整练习和答案解析考生可以查漏补缺厘清易混考点提升备考效率与应试信心。1. 从 docx 到结构化题库这道“补录试题”文档要解决的三件事拿到一份名为“2019年广西柳州市三支一扶考试补录试题及答案解析.docx”的文档第一反应是先打开看看但工程问题往往在打开之后才出现题干和选项挤在同一个段落答案行用三种不同标点解析跨页还插着表格甚至有些题目只有图片。真正要做的是把这份 docx 变成可检索、可统计、可刷题的结构化数据。这需要先摸清 docx 的内部结构再用 python-docx 和正则把内容拆成题干、选项、答案、解析四张表。下面这条路径从解包 docx 开始到题号切分、异常处理、最后做文档校验和回写是我处理这类考试文档的标准流程。2. 先拆 docx正文、表格、批次段落在 python-docx 里的真实边界2.1 判断文件类型与命名空间doc、docx、docm 的处理分支后缀名不一定可信。.docx本质是 ZIP 包而.doc是 OLE 复合文档python-docx 只能处理前者。如果来源文件是.doc先转成.docx再用下面这套流程。验证后缀和 ZIP 头是最省钱的一步。import zipfile from pathlib import Path path Path(2019年广西柳州市三支一扶考试补录试题及答案解析.docx) is_zip zipfile.is_zipfile(path) print(is zip:, is_zip) with zipfile.ZipFile(path) as z: names z.namelist() print(entry count:, len(names))这段代码做两件事确认文件是 ZIP 格式排除伪造后缀列出压缩包内条目确认存在word/document.xml和docProps/core.xml。前者是正文所在位置后者在后文做文档身份校验时要用。如果条目标题不是word/开头大概率是模板或者受保护格式先修复再解析。2.2 按 Body 顺序遍历 paragraph 和 table避免题目乱序python-docx 的Document.paragraphs只返回正文段落Document.tables只返回表格两个列表的索引无法反映文档真实顺序。考试文档经常出现“一段题目、一个表格、一段空行”交替排列的情况只取其中一半题目顺序必乱。正确做法是遍历document.element.body的所有子元素按w:p与w:tbl的出现顺序依次处理。from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.ns import qn doc Document(path) body doc.element.body blocks [] for child in body.iterchildren(): if child.tag qn(w:p): blocks.append((p, Paragraph(child, doc))) elif child.tag qn(w:tbl): blocks.append((t, Table(child, doc))) for kind, obj in blocks: if kind p: print(p:, obj.text.strip()) else: print(table: rows, len(obj.rows), cols, len(obj.columns))这里的iterchildren()遍历的是w:body直接子元素顺序完全可靠。Paragraph(child, doc)把底层 lxml 元素包成 python-docx 对象直接调用.text和.style。为什么不用body的通用 xpath因为这里要优先保序iterchildren()不递归、不排序正好贴合“按文档自然流读取”的需求。拿到blocks之后后续的题目切分、表格提取都基于这个有序列表而不是各自遍历。2.3 利用 style 和 outline 层级定位“补录批次”的章节边界补录文档常用标题样式区分“第一套”“第二套”或“2019年补录真题”。段落样式名保留在paragraph.style.name里可以借助它把内容切到不同 bucket。比如Heading 1是套卷名Heading 2是题型名。下面这段代码将标题段落单独摘出来作为后续解析的分组标记。for kind, obj in blocks: if kind p: style obj.style.name if obj.style else text obj.text.strip() if Heading in style or 标题 in style: print(section marker:, style, text) else: print(content:, text[:60])这里只打印标记不急着解析。真实项目里这一步会产出sections字典{section_title: [paragraph, ...]}。后面做答案匹配时可以把“答案”和“解析”限定在同一个 section 内避免跨套卷串题。注意很多历史文档的样式名是中文比如“标题 1”所以判断条件要同时兼容Heading和标题。遇到空段落也不要直接跳过某些文档用空段作为题目之间的分隔而空段的样式可能带着“标题 3”属性这种排版错误在补录文档里并不少见。如果题目里出现加粗的“答案”那w:rPr里会带w:b可以通过run.bold快速捕获。判断答案标记时除了正则加粗 是一个很强的辅助信号因为很多排版人员习惯把“答案”两个字加粗。可以写一个生成器把加粗的 run 文本提取出来。def iter_bold_runs(paragraph): for run in paragraph.runs: if run.bold: yield run.text这个辅助信号不能替代正则但可以把“疑似答案行”的候选范围缩小很多。我也见过用下划线或阴影代替加粗的所以这只是一个排序因子不是硬条件。3. 试题与答案解析用正则与结构化规则把题干、选项、答案拆干净3.1 先人工抽样 10 题归纳题块边界与标点变体不要一上来写大正则。处理这类历史下载的补录试题我会先随机抽 10 道题把题号前缀、选项标签、答案标记三种格式抄下来。常见题号有1、1.1三种变体选项有A、A.A三种答案行则可能是【答案】D、答案D、参考答案: D。把这些变体列成清单写正则时只需要两三个模式而不是在文本里反复试探。3.2 用正则切题号、选项和答案标记基于上面的抽样给出一个通用模式。题号匹配^(\d{1,3})\s*[\.、]选项匹配^([A-H])\s*[\.、]答案匹配答案\s*[:]?\s*([A-H])。注意要把“答案”限定在行首附近否则正文中出现的“正确答案”会被误抓。import re num_re re.compile(r^(\d{1,3})\s*[\.、]\s*(.*)) opt_re re.compile(r^([A-H])\s*[\.、]\s*(.*)$) ans_re re.compile(r^答案\s*[:]?\s*([A-H])) def split_questions(lines): questions [] cur None for line in lines: m num_re.match(line.strip()) if m: if cur: questions.append(cur) cur {no: int(m.group(1)), body: [m.group(2)], answers: []} elif cur: cur[body].append(line.strip()) if cur: questions.append(cur) return questions这个函数以题号行作为切分点把文档按顺序切成题块。为什么用line.strip()而不是直接处理全文因为跨行的题干和解析常有缩进剥掉首尾空白再匹配正则更稳定。\d{1,3}限定题号最多三位防止把年份2019当成题号[\.、]把半角点、全角点、顿号全纳入分隔符([A-H])覆盖单选和多选多选答案可连续出现所以匹配部分用了[A-H]。这里cur[answers]初始为空列表后面会把答案拼接进去。3.3 用延迟匹配处理“答案与解析”分离的题型有些题目把答案放在解析里比如“本小题主要考查……故选D”。这时行首匹配失效必须扫整个题块文本用最小匹配原则找“选”后面的字母。case_re re.compile(r故选\s*([A-H])|答案为\s*([A-H])|正确选项是\s*([A-H]), re.S) def extract_answer_from_case(block_text): m case_re.search(block_text) if m: return next(g for g in m.groups() if g is not None) return None延迟匹配的核心是re.S让点号匹配换行同时用“故选”“答案为”这种短前缀做锚点。为什么不贪婪因为贪婪模式会从题块中间抓到最后一个“选”把前一题的答案带进来。这里search返回第一个命中符合“答案紧跟语境词”的排版习惯。如果这个函数返回None则该题进入缺答案队列。3.4 把结果灌进 pandas DataFrame形成题库底表题干、选项、答案、解析分别抽取后统一放进 DataFrame。字段建议这样设计question_id、section、stem、options、answer、analysis、source_file。options用 JSON 字符串保存因为不同题目的选项数量可能不同直接用列存会拉爆表格。import pandas as pd, json rows [] for q in questions: rows.append({ question_id: q[no], section: current_section, stem: q[body][0], options: json.dumps(q.get(options, {})), answer: q.get(answer, ), analysis: q.get(analysis, ), source_file: str(path), }) df pd.DataFrame(rows) print(df.shape) print(df[df[answer] ].head())这步之后df[df[answer] ]直接捞出没抓到答案的题就是下一阶段要处理的异常队列。source_file字段是给批量解析准备的一旦手里有十份历年真题这个字段可以区分题目来源做查重和版本比对。字段与用途的关系如下字段名来源示例用途question_id题号正则1排序与关联section标题样式一、单项选择题分层检索stem题干首行下列关于行政行为正确的是题库展示options选项字典 JSON{A: ..., ...}渲染选项answer答案行D对错判断analysis解析段本题考查……复习讲解source_file传入文件名2019...docx多文档溯源4. 补录试题解析中的三类异常缺答案、多解析、题干含图片和公式4.1 缺答案解析文本反推与人工复核队列补录试题由于是后期排版答案缺失常见两种情况整个答案行被注释掉或者答案行与题干之间插入了分页符。前者ans_re匹配不到任何内容后者虽然匹配到了D但题块已经被分页符切碎。处理方式是在题块拼接时先清理分页符再把缺失答案的题放进复核队列。clean_text block_text.replace(\x0c, ) if not q.get(answer): q[status] pending_review这里pending_review是一个状态标记后续可以导入到人工复核 Excel 里。补录时优先看解析文本里是否隐含答案用上一章的extract_answer_from_case兜底。如果连“故选”都没有那就标UNKNOWN不要猜。有些答案在补录时被写成了“答案D。”结尾多一个句号此时[A-H]会匹配成功但后续清洗时要把.和。去掉避免答案字符串带脏字符。4.2 多解析按题号聚合与超长块防串题多选或材料题经常把解析拆成两段两个“解析”标签出现在同一题块里。直接把所有解析文本拼起来会混入下一题的题干。我一般按题号做聚合并且设定一个超长阈值如果当前题块累计字符数大于 800且下一行又是题号行就强制截断把超长内容丢进日志。MAX_BODY_LEN 800 if len(cur[body]) MAX_BODY_LEN: overflow.append(cur) continue这个数值不是随便定的。选择题题干加选项一般在 200 到 500 字之间超过 800 基本说明题块切错了。当然如果是材料题单题可能超过 1000 字所以这个阈值要作为参数暴露出来而不是写死在函数里。实际使用时MAX_BODY_LEN可以放在配置对象中并随每份文档的字体大小、行距做微调。4.3 图片和 OMML 公式解包 media 与 latex 转换的取舍历史版 docx 里地图题、图表题很常见。图片无法通过 python-docx 直接读文字inline_shapes只能给出数量。如果题干是图片那么stem会是空字符串此时先把图片解包到本地目录再做 OCR 或人工标注。from docx import Document import zipfile doc Document(path) print(inline shapes:, len(doc.inline_shapes)) with zipfile.ZipFile(path) as z: for entry in z.infolist(): if entry.filename.startswith(word/media/): z.extract(entry, media_export)这段代码把word/media/下的所有图片导出到media_export文件夹。infolist()比namelist()更细保留了文件大小和压缩信息方便在导出时跳过零字节文件。至于公式docx 里的公式通常以 OMMLOffice Math Markup Language存在于m:oMath节点下paragraph.text读不到。要转 LaTeX 需要额外工具链我的取舍是解析结果里只保留“公式所在位置”的占位符${eq}不追求把公式完整还原。补录考试的大多数公式只出现在选项里OCR 或人工补更快。5. 用 core.xml 校验文档身份把题库回写为 Markdown 刷题版本5.1 读 docProps/core.xml核对创建时间和修订次数处理这种带年份和城市名的文档第一步不是信文件名而是看docProps/core.xml。如果文档声称是 2019 年的补录试题但创建时间显示 2023 年说明它可能被重新编辑过内容可信度要打个折扣。解析核心属性用正则就够。import zipfile, re with zipfile.ZipFile(path) as z: core z.read(docProps/core.xml).decode(utf-8) created re.search(rdcterms:created[^]*(.*?)/dcterms:created, core) modified re.search(rdcterms:modified[^]*(.*?)/dcterms:modified, core) revision re.search(rcp:revision(\d)/cp:revision, core) print(created:, created.group(1) if created else None) print(modified:, modified.group(1) if modified else None) print(revision:, revision.group(1) if revision else None)dcterms:created在 Word 里一直保留首次创建时间即使另存也常常不变。cp:revision表示修订次数如果数值大于 3说明经过多轮改动答案可能被删改过。这个信息比文件属性里的“修改时间”可靠因为文件系统的 mtime 可以被touch改掉而 core.xml 里的时间戳一般还保留原值。5.2 用 SHA-256 在分发前后做完整性对比如果这份文档在同事或转手渠道之间传来传去文件名会改得五花八门。这时需要记住的是哈希值而不是文件名。解析前先算一次文件哈希再对解压后的word/document.xml算一次哈希可以确认解析过程没有意外改动源文件。import hashlib, zipfile file_sha hashlib.sha256(path.read_bytes()).hexdigest() with zipfile.ZipFile(path) as z: doc_xml z.read(word/document.xml) doc_sha hashlib.sha256(doc_xml).hexdigest() print(file sha:, file_sha[:16]) print(document.xml sha:, doc_sha[:16])这里只用代码读取没有写回操作所以两个哈希在重复运行时应保持不变。如果第二次运行时doc_sha变了说明有程序后台修改了文档比如同步网盘在更新元数据此时需要回头检查工作目录避免后续清洗结果被脏文件污染。5.3 将解析结果回写成 Markdown 保留批注与标签最后把解析结果导出成 Markdown 刷题版方便直接放进阅读器或笔记软件。回写时用json还原选项显示未结题的题目带pending_review标签。这样处理过一遍后docx 只保留一份原始底料后续刷题、做成绩统计、生成错题本都从这一份 Markdown 或 JSON 派生。import json, pathlib out pathlib.Path(题库.md) with out.open(w, encodingutf-8) as f: for r in df.to_dict(records): f.write(f### {r[question_id]}. {r[stem]}\n\n) opts json.loads(r[options]) if r[options] else {} for k, v in opts.items(): f.write(f- {k}. {v}\n) ans r[answer] or 待复核 f.write(f\n**答案**{ans}\n\n) if r.get(status) pending_review: f.write( 该题缺答案需要人工复核\n\n)回写过程中的json.loads必须包一层异常处理因为历史文档的选项可能混入\n或制表符直接解析会抛JSONDecodeError。我在读取阶段会先做一次ensure_asciiFalse的清洗确保所有键值都是字符串。这一步做完整份“2019年广西柳州市三支一扶考试补录试题及答案解析.docx”的原始内容就成了可检索的本地知识库后续想加标签体系、统计知识点分布都可以直接在这个 Markdown 或 DataFrame 上继续做。本文还有配套的精品资源点击获取