资讯动态

大学日本语期末考试题.doc解析:从半结构化文档到题库的工程化处理

发布时间:2026/10/21 9:02:08 来源:尧图企业网站定制
简介这份《大学日本语期末考试题》doc文档面向高校日语学习者与任课教师用于期末复习、自测与出题参考覆盖初级到中级语法与词汇的检验需求。资源包共1个doc文件约29KB内容为一份完整的日语期末试卷题型涵盖汉字读音、平假名转汉字、片假名释义、助词与语法选择、情境表达及两篇阅读理解并附有中文翻译题可帮助读者系统梳理「企業」「にんぎょう」「ピアノ」等基础词汇的读音与书写对应关系。目前已有140人学习下载。通过逐题练习读者能集中检验助词用法、外来语理解与短文信息提取能力适合作为考前查漏补缺的实战材料也便于教师参考题型分布与分值设置。1. 一份“大学日本语期末考试题.doc”背后藏着多少工程化处理空间一份名为“大学日本语期末考试题.doc”的文件表面看只是某门课程的试卷存档但如果你做过教育类文档的批量处理、题库抽取或格式转换就会意识到它其实是一个典型的“半结构化文档”样本标题层级混乱、题干与选项混排、假名与汉字夹杂、表格与文本框嵌套甚至还有手写批注的扫描页。很多开发者第一次拿到这类文件时第一反应是直接复制粘贴到 Excel结果发现换行符和全角空格把数据搅成一锅粥。真正能落地的做法是先把它当成一个“文档解析与结构化”的小型工程问题而不是一次性的手工劳动。这篇文章面向的是需要批量处理日语试卷、建设题库或做教学资源数字化的从业者我会把从 .doc 解析到题干拆分、从假名标注到答案对齐的完整路径拆开讲中间穿插我踩过的坑和能直接抄的代码。如果你手头正好有一批类似的日语试卷文档这套思路可以直接迁移。2. 先搞清楚 .doc 和 .docx 的解析差异选错库后面全白干2.1 为什么不能直接用 python-docx 打开 .doc很多教程一上来就让你pip install python-docx然后Document(大学日本语期末考试题.doc)结果直接报PackageNotFoundError或者乱码。原因很简单python-docx 只认 OOXML 格式也就是 .docx 的 zip 包结构而老式 .doc 是二进制复合文档格式两者底层完全不同。我见过一个实验室的批量处理脚本因为没做格式判断跑了一晚上只处理了 3 个文件剩下的全部抛异常。正确的做法是先做文件头识别.docx 的前两个字节是PK.doc 的前八个字节是D0 CF 11 E0 A1 B1 1A E1。识别之后.docx 走 python-docx.doc 走 LibreOffice 无头转换或者 antiword 这类工具。下面这段代码是我常用的格式分流逻辑直接拿去用import os def detect_doc_type(filepath): 通过文件头判断是 doc 还是 docx with open(filepath, rb) as f: header f.read(8) # docx 是 zip 格式开头为 PK if header[:2] bPK: return docx # doc 是 OLE 复合文档 if header b\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1: return doc return unknown # 使用示例 for fname in os.listdir(./papers): if fname.endswith((.doc, .docx)): ftype detect_doc_type(os.path.join(./papers, fname)) print(fname, -, ftype)这段逻辑的关键在于只读文件头不加载整个文件速度极快。参数上没有什么可调的但要注意有些 .doc 文件被改过后缀名实际还是二进制格式所以不能只看扩展名。如果你拿到的是 .doc我一般会先用 LibreOffice 转成 .docx命令是soffice --headless --convert-to docx --outdir ./converted ./papers/*.doc。这个转换过程会丢失一部分文本框和艺术字但对于试卷题干来说正文段落和表格基本能保留。转换完之后再用 python-docx 读取稳定性会高很多。2.2 用 python-docx 提取段落时怎么保住题干和选项的边界python-docx 把文档拆成paragraphs和tables两个主要集合但试卷里经常出现“题干在段落里选项在表格里”或者“题干和选项都在同一个段落用全角空格分隔”的情况。如果你直接遍历doc.paragraphs得到的是一堆没有语义的文本行。我的做法是先按段落顺序遍历同时记录每个段落的样式名和是否在表格内然后根据日语试卷的常见模式做规则匹配。日语试卷的题干通常以「次の文の に入れる最もよいものを、A・B・C・Dから一つ選びなさい」这类固定句式开头选项则常见「A」「B」「C」「D」或者「1」「2」的编号。下面是一个提取并初步分块的示例from docx import Document import re def extract_blocks(docx_path): doc Document(docx_path) blocks [] current_question None for para in doc.paragraphs: text para.text.strip() if not text: continue # 匹配题干起始常见日语试卷题干特征 if re.match(r^[(]?\d[)]?[.、], text) or 選びなさい in text: if current_question: blocks.append(current_question) current_question {stem: text, options: []} # 匹配选项A B C D 或 1 2 3 4 elif re.match(r^[A-D-1-4][.、], text): if current_question is not None: current_question[options].append(text) else: # 其他文本追加到当前题干 if current_question: current_question[stem] text else: current_question {stem: text, options: []} if current_question: blocks.append(current_question) return blocks # 输出结构示例 blocks extract_blocks(./converted/大学日本语期末考试题.docx) for b in blocks[:3]: print(题干:, b[stem][:50]) print(选项数:, len(b[options]))这里的正则^[(]?\d[)]?[.、]是为了兼容“1”“1”“1、”这几种编号。参数上re.match只从行首匹配避免把题干中间的编号误判。逻辑说明先判断是否为新题干如果是就把上一个题干存入结果再判断是否为选项行剩下的文本归入当前题干。这个规则不是万能的比如有些试卷把选项放在表格里doc.paragraphs根本读不到这时候需要额外遍历doc.tables。我一般会先把表格里的文本也按行拆出来合并到同一个处理流里再跑上面的规则。2.3 表格和文本框里的题干怎么捞出来日语试卷特别喜欢用表格来排版选项尤其是“次の文の に入れる最もよいものを”这种题四个选项经常放在一个 2x2 或 4x1 的表格里。python-docx 的doc.tables可以拿到表格对象但单元格里的段落需要再遍历一次。更麻烦的是文本框python-docx 默认不解析文本框内容需要走 XML 层去捞w:txbxContent节点。我通常的做法是写一个递归函数把段落、表格、文本框里的文本全部按顺序拉平成一个列表再统一做规则匹配。下面这段代码展示了如何把表格内容并入处理流def iter_table_text(doc): 遍历文档中所有表格按行返回文本 for table in doc.tables: for row in table.rows: row_text [] for cell in row.cells: cell_text .join(p.text.strip() for p in cell.paragraphs if p.text.strip()) if cell_text: row_text.append(cell_text) if row_text: yield | .join(row_text) # 合并段落和表格文本 all_lines [p.text.strip() for p in doc.paragraphs if p.text.strip()] all_lines.extend(iter_table_text(doc))这里用 | 连接同一行的单元格是为了保留选项之间的边界。如果你发现选项被拆成了多行可以在后续处理中用正则把A到D之间的内容重新拼接。文本框的处理需要用到doc.element.body.iter()去查找{http://schemas.openxmlformats.org/wordprocessingml/2006/main}txbxContent标签代码稍长但思路是一样的找到节点后把里面的w:t文本抽出来。我建议先把所有文本拉平再统一做题干和选项的切分这样比边遍历边判断要稳得多。3. 把题干、选项、答案拆成结构化数据正则和状态机怎么配合3.1 日语题干里的全角空格和换行符先做归一化再匹配日语试卷的文本里全角空格\u3000、半角空格、制表符、换行符经常混在一起直接拿正则去匹配「A」很容易漏掉。我一般会先做一轮归一化把全角空格替换成半角空格把连续空白压缩成一个空格但保留换行符作为段落边界。注意不要把所有换行都干掉因为题干和选项有时候就是靠换行分隔的。下面这个归一化函数是我反复用过的import unicodedata def normalize_jp_text(text): 日语试卷文本归一化 # 全角空格转半角 text text.replace(\u3000, ) # 全角字母数字转半角可选视后续匹配需求 text unicodedata.normalize(NFKC, text) # 连续空格压缩 text re.sub(r[ \t], , text) # 去掉行首行尾空白但保留换行 lines [line.strip() for line in text.split(\n)] return \n.join(line for line in lines if line)unicodedata.normalize(NFKC, text)会把全角英数字转成半角这样就变成了A正则写起来更统一。但要注意NFKC 也会把一些日语半角片假名转成全角如果你后续要做假名标注这一步可能会影响标注结果。我的建议是如果只是做题干拆分NFKC 可以放心用如果要做形态素分析归一化之后要再确认一遍假名是否被意外转换。3.2 用状态机把“题干-选项-答案”三段式拆开试卷文档里答案通常出现在两个位置一是每道题后面紧跟「答えB」二是整份试卷末尾有一个答案表。这两种情况需要不同的处理策略。我一般会用一个简单的状态机初始状态为SEEK_STEM遇到题干起始模式就切到IN_STEM遇到选项模式就切到IN_OPTIONS遇到答案模式就切到IN_ANSWER。状态机的好处是能处理选项数量不固定、答案位置不固定的情况。下面是一个简化版实现def parse_questions(lines): questions [] state SEEK_STEM current None for line in lines: # 答案行答えB 或 正解B ans_match re.search(r(?:答え|正解|答案)[:]\s*([A-D-1-4]), line) if ans_match: if current: current[answer] ans_match.group(1) questions.append(current) current None state SEEK_STEM continue # 题干起始 if re.match(r^[(]?\d[)]?[.、], line) or 選びなさい in line: if current: questions.append(current) current {stem: line, options: [], answer: None} state IN_STEM continue # 选项行 if re.match(r^[A-D-1-4][.、], line): if current is not None: current[options].append(line) state IN_OPTIONS continue # 其他行归入当前题干 if current is not None and state IN_STEM: current[stem] line if current: questions.append(current) return questions这个状态机的关键参数是答案正则(?:答え|正解|答案)[:]\s*([A-D-1-4])它兼容了全角冒号和半角冒号。逻辑说明每遇到一个答案行就把当前题目收尾并存入结果每遇到题干起始就把上一题收尾并新建题目选项行追加到当前题目的 options 列表。注意这里没有处理“答案在末尾答案表”的情况那种需要先解析答案表再按题号回填。我通常会把答案表单独抽出来存成一个{题号: 答案}的字典最后统一回填。3.3 答案表回填题号和答案的对应关系怎么建如果答案集中在试卷末尾格式通常是「1B 2C 3A」或者一个两列的表格。这时候需要先把答案表解析成字典再根据题干里的题号回填。题号的提取要注意有些题干编号是「1」有些是「1」有些是「問1」。我一般会写一个extract_question_number函数用多个正则依次尝试取第一个匹配到的数字。下面是对应的代码def extract_question_number(stem): 从题干中提取题号 patterns [ r^[(]?(\d)[)]?[.、], r^問\s*(\d), r^第\s*(\d)\s*問, ] for pat in patterns: m re.match(pat, stem) if m: return int(m.group(1)) return None def build_answer_map(answer_lines): 从答案表行构建题号到答案的映射 answer_map {} for line in answer_lines: # 匹配 1B 或 1-B 或 1 B for m in re.finditer(r(\d)\s*[.、\-]?\s*([A-D-1-4]), line): answer_map[int(m.group(1))] m.group(2) return answer_mapbuild_answer_map里用了re.finditer而不是re.match因为一行里可能挤了好几个题号的答案。参数上[.、\-]?允许题号和答案之间有分隔符也可以没有。回填的时候遍历题目列表用extract_question_number拿到题号再去answer_map里查。如果题号提取失败就按顺序号回填但要在日志里标记出来方便人工复核。我踩过的坑是有些试卷的答案表用的是全角数字\d在 Python 正则里默认能匹配全角数字但如果你用了re.ASCII标志就会漏掉所以不要随便加re.ASCII。4. 避坑与排查处理日语试卷文档时最容易翻车的 5 个地方4.1 现象转换后的 docx 里题干和选项顺序错乱原因LibreOffice 在转换 .doc 时会把文本框和浮动表格重新排版导致原本在视觉上属于同一题的选项被拆到不同段落甚至跑到题干前面。解决转换后不要直接信任段落顺序先用doc.element.body遍历所有块级元素按 XML 中的实际顺序重建文本流。如果发现顺序仍然不对就退回用 antiword 提取纯文本再靠正则做后处理。我一般会在转换后跑一个校验脚本统计题干数量和选项数量如果选项数明显少于题干数乘以 4就说明有选项丢失。4.2 现象正则匹配「A」时把题干里的「A」也匹配进去了原因题干里可能出现「A社」「Aさん」这样的词如果正则写成^A[.]不加行首锚定就会误匹配。解决选项匹配一定要加^并且最好在归一化之后、按行处理。如果选项和题干在同一行比如「A東京 B大阪 C京都 D神戸」那就不能用行首锚定而要改成(?:^|\s)([A-D])[.]同时用re.findall一次性提取所有选项。我通常先判断一行里是否包含两个以上的选项标记如果是就按标记切分而不是按行切分。4.3 现象答案回填后题号和答案对不上原因题干编号和答案表编号的起始值不一致比如题干从「問1」开始答案表从「1」开始或者中间有缺号。解决不要假设题号连续回填时以题干提取到的题号为准答案表只作为字典查询。如果题干题号提取失败就按题干在列表中的顺序号回填但要在结果里加一个number_source字段标记是「提取」还是「顺序」。这样后续人工复核时能快速定位问题。我一般还会输出一个对账表列出每道题的题号、答案来源和匹配状态。4.4 现象假名标注后汉字和假名的对应关系错位原因日语形态素分析器如 MeCab对试卷里的专有名词或错误拼写可能切分不准导致标注结果和原文字符位置对不上。解决标注前先做一次文本清洗去掉多余的空格和标点标注后不要直接按字符位置替换而是用「原词-读音」对做映射再在原文里查找替换。如果原文里有重复词要按出现顺序依次替换不能全局替换。我一般会保留一份未标注的原文标注结果单独存一个字段避免污染原始数据。4.5 现象批量处理时个别文件导致整个脚本崩溃原因某个 .doc 文件损坏或者包含加密内容python-docx 或 LibreOffice 转换时抛异常没有做异常捕获。解决每个文件的处理都包在try...except里记录失败文件名和异常信息继续处理下一个。同时设置超时避免 LibreOffice 转换卡死。我通常会把失败文件单独移到一个failed目录处理完一批后统一看日志。下面是一个批量处理的骨架import traceback def batch_process(file_list, output_dir): failed [] for fpath in file_list: try: # 你的解析逻辑 result parse_paper(fpath) save_result(result, output_dir) except Exception as e: failed.append((fpath, str(e))) print(f[FAIL] {fpath}: {e}) traceback.print_exc() return failed这个骨架的关键是traceback.print_exc()能把完整堆栈打出来方便定位是解析问题还是 IO 问题。参数上output_dir要提前创建避免保存时再报错。5. 从单份试卷到题库批量处理与质量校验的进阶做法5.1 用配置文件管理不同试卷的解析规则不同课程、不同年份的日语试卷题干格式可能不一样。如果每换一份试卷就改一次代码维护成本太高。我的做法是把正则模式和状态机参数抽到一个 YAML 配置文件里每个试卷模板对应一组规则。比如template_a: stem_patterns: - ^[(]?\\d[)]?[.、] - 選びなさい option_pattern: ^[A-D-][.、] answer_pattern: (?:答え|正解|答案)[:]\\s*([A-D-1-4]) template_b: stem_patterns: - ^問\\s*\\d option_pattern: ^\\d[.、] answer_pattern: 正解[:]\\s*(\\d)解析时根据文件名或文档头部的课程名选择模板。这样新增一种试卷格式只需要加一段配置不用动核心代码。参数上YAML 里的反斜杠要转义写的时候注意\\d而不是\d。我一般还会给每个模板加一个sample_file字段方便回归测试。5.2 质量校验用统计指标发现解析异常批量处理最怕的是“静默失败”——脚本跑完了但结果里缺了很多题。我一般会计算几个指标题干数量、选项总数、答案数量、选项数不等于 4 的题目比例。如果某份试卷的选项数明显偏低或者答案数量对不上题干数量就标记为可疑。下面是一个简单的校验函数def validate_questions(questions): 返回校验指标 total len(questions) option_counts [len(q[options]) for q in questions] answer_count sum(1 for q in questions if q.get(answer)) abnormal sum(1 for c in option_counts if c ! 4) return { total_questions: total, total_options: sum(option_counts), answered: answer_count, abnormal_option_count: abnormal, abnormal_ratio: abnormal / total if total else 0, }如果abnormal_ratio超过 0.2我就会把这份试卷挑出来人工看一眼。常见原因是选项被拆到了表格里或者题干里混入了其他内容。这个指标不追求 100% 准确但能帮你快速定位问题文件比逐份打开检查效率高得多。5.3 一个具体技巧用 difflib 做答案表的模糊匹配有时候答案表的格式很乱比如「1 B 2 C」中间用了全角空格或者「1B」和「1 B」混用。与其写一堆正则不如用difflib.SequenceMatcher做模糊匹配。我的做法是先把答案表行归一化成「题号答案」的候选对再和题干题号做匹配。如果题号完全匹配不上就用编辑距离找最接近的。这个技巧在处理扫描版 OCR 结果时特别有用因为 OCR 经常把「1」识别成「l」或者「I」。下面是一个示例import difflib def fuzzy_match_number(target, candidates): 在候选题号中找最接近的 matches difflib.get_close_matches(str(target), [str(c) for c in candidates], n1, cutoff0.8) return int(matches[0]) if matches else Nonecutoff0.8是相似度阈值低于这个值就不匹配。参数上n1表示只取最接近的一个。这个函数适合在题号提取失败时做兜底但不要作为主逻辑否则容易把「1」和「7」这种形近数字搞混。我一般只在人工复核阶段用它来辅助定位。5.4 我自己的习惯先跑通一份再批量最后留后悔药处理这类文档我最大的教训是不要一上来就写批量脚本。先拿一份最典型的试卷手动跑通从解析到结构化的全流程把中间结果存成 JSON 看一眼。确认题干、选项、答案都对得上之后再套到批量框架里。批量跑的时候一定要保留原始文件和中间结果万一后面发现规则有问题还能回滚重跑。我一般会在输出目录里建三个子目录raw存原始文件intermediate存解析中间结果final存最终结构化数据。这样即使某一步翻车也不用从头再来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑