Python处理.doc题库:格式转换、结构化抽取与FTS5检索
2026/9/17 22:38:15 网站建设 项目流程

简介:这份资料是面向江西省高校教师岗前培训学员的《高等教育心理学》题库文档,围绕心理学概论与教育心理学两大部分梳理考点,适合备考教师岗前培训笔试、需要快速刷题巩固概念的学员使用。压缩包内共1个doc文件,约100KB,内容以章节化题库形式编排,包含选择题与填空题两类题型,方便按章节顺序自测与背诵。题库覆盖心理动力、心理过程、心理状态、心理特征四大个体心理维度,以及动机、需要、兴趣、意志过程等基础概念辨析,并延伸到唯理论、经验论、构造主义等心理学流派代表人物与核心主张的对比。第二章则聚焦教育心理学与高等教育心理学,涉及桑代克理论在中国的改造期、客观性与发展性等研究原则,观察法、实验法、问卷法、个案法等方法的特点与适用,以及高校教学、科研、服务社会三大职能和大学生学习与教学心理研究范畴。目前已有65人学习,题目均附参考答案选项,可直接用于背诵与查漏补缺。

1. 一份 .doc 题库文档,为什么值得用脚本处理

"江西省高校教师岗前培训高等教育心理学题库1资料全.doc"这个名字信息量很大:前缀是地域加培训项目,中段是科目,结尾的"题库1资料全"说明它大概率由多个分卷拼在一起。多数人拿到手就直接翻页背题,可题目一旦过千,翻页找题就变成低效劳动——想按考点筛、想按题型刷、想统计哪几章题量偏薄,肉眼都做不了。

真正卡住人的不是心理学知识本身,而是文档形态。老版二进制 .doc 里的全角标点、混排表格、贴在题尾的答案,会让"复制到 Excel"这种土办法彻底失效:一列塞进四行、选项和题干黏在一起、答案串到下一题去。

把这份题库拆成"题干、选项、答案、解析"四段式结构,再落到能检索的数据表里,后面无论做错题本还是做自测脚本,都只是加几行代码的事。下面的路线是:判格式、转文本、抓结构、入库检索、校验导出。

2. 格式判定与转换:让 .doc 变成可解析的文本

转换这一步的返工率最高,原因往往不是脚本写得差,而是根本没确认手里那份文件到底是什么。扩展名是最不可信的信息:有人从 PDF 另存出来直接改后缀,有人把 .docx 改成 .doc 发给同事,还有人上传的"资料全"版本其实是压缩包里套了三个文件。先把格式验明,再决定用 python-docx、LibreOffice 还是文本流处理,能省掉一整轮调试。

2.1 先做格式体检:真 .doc、伪 .doc 与 PDF 转存

文件头就是最直接的证据,一条file加一条xxd基本能定性。

# 查看文件类型(会读魔数,不信扩展名) file "江西省高校教师岗前培训高等教育心理学题库1资料全.doc" # 直接看前 8 个字节 xxd -l 8 "江西省高校教师岗前培训高等教育心理学题库1资料全.doc" # 如果是压缩包,顺手看看里面有什么 unzip -l "江西省高校教师岗前培训高等教育心理学题库1资料全.doc" | head -20

file的输出里如果出现 Composite Document File,说明是老版 OLE 二进制;出现 Microsoft Word 2007+ 或 Zip archive,说明它其实是 docx 换了名字;出现 PDF document 就更直接。xxd的作用是补刀:d0cf 11e0是 OLE 的固定头,504b 0304是 zip 的固定头,2550 4446就是%PDF

文件头字节真实格式推荐处理路径
D0 CF 11 E0OLE 二进制 .docLibreOffice 转 docx 或 UTF-8 文本
50 4B 03 04zip 容器(docx/xlsx)改回正确扩展名,用 python-docx
25 50 44 46PDF先看有没有文字层,无文字层才考虑 OCR
EF BB BF 开头UTF-8 文本直接按行解析,注意 BOM
7B 5C 72 74 66RTF用 striprtf 或转换器,不要硬写正则

判断清楚之后有个常见坑:伪 docx 直接改回.docx就能用 python-docx 打开,但别丢进 LibreOffice 批量转换列表里,否则会产生一堆同名的垃圾输出。

2.2 用 LibreOffice 无头模式批量转换文件

确认是老版 .doc 之后,最省事的做法是走 LibreOffice 的无头模式,它比商业库便宜,比手写 OLE 解析现实得多。

# 先清掉可能残留的进程,否则第二次执行会静默失败 pkill -f soffice.bin # 转成 docx,保留段落样式和表格结构 soffice --headless \ -env:UserInstallation=file:///tmp/lo_profile_1 \ --convert-to docx \ --outdir ./out \ ./src/*.doc # 同时导一份纯文本作为交叉校验,显式指定 UTF-8 soffice --headless \ -env:UserInstallation=file:///tmp/lo_profile_1 \ --convert-to "txt:Text (encoded):UTF8" \ --outdir ./txt \ ./src/*.doc # 检查产出数量与输入是否一致,少一个就说明有文件转换失败 ls ./src/*.doc | wc -l && ls ./out/*.docx | wc -l

参数逐个说清楚:--headless表示不弹图形界面,服务器上必须加;-env:UserInstallation指定独立的用户配置目录,批量并发时每个进程用不同目录,否则会互相抢锁;--convert-to的过滤器写法是格式名:过滤器名:参数,写错会直接退回默认输出;--outdir在目录不存在时不会自动创建,先mkdir -p。中文文件名在这里没问题,但如果路径里带空格,记得整个引起来。

批量跑完一定要做数量比对,这是最容易忽略的一步。LibreOffice 遇到损坏分卷时会跳过但不报错,返回码依然是 0,只看数量才看得出来。

2.3 转换后的文本清洗:全角标点与隐藏字符

直接拿转换结果去写正则,八成会被全角括号和零宽字符教做人。先做一轮归一化,把可控的差异压平。

import re import unicodedata # 需要保留的全角符号,避免被统一压成半角后影响选项识别 KEEP = set("()【】《》") def normalize(text: str) -> str: """把题库文本压成便于正则处理的形态。""" # 1. 去掉零宽字符和 BOM,它们肉眼不可见但会破坏行首匹配 text = text.replace("\ufeff", "").replace("\u200b", "").replace("\u3000", " ") # 2. 换行统一,Word 里常见的垂直制表符也当成换行 text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\x0b", "\n") # 3. 全角转半角,保护 KEEP 里的符号 out = [] for ch in text: if ch in KEEP: out.append(ch) continue out.append(unicodedata.normalize("NFKC", ch)) text = "".join(out) # 4. 连续空行压缩,页眉页脚残留通常表现为孤立短行 text = re.sub(r"\n{3,}", "\n\n", text) return text

这段逻辑的关键在第三步:直接对全文做 NFKC 会把中文顿号、全角括号一起压掉,导致(A)(A)混在一起后又和a)打架。保留少数符号、只压数字和字母是最省心的折中。第四步压空行是为后面按行切题做准备,页眉页脚在多份资料合并后会形成大量孤立短行,留到解析阶段靠长度过滤更准。

清洗完先别急着入库,抽十页出来用grep -c统计一下题号行数量,和肉眼数出来的题量对得上,再往下走。

3. 解析题干与答案:高等教育心理学题库的四段式抽取

一份岗前培训心理学题库的题型其实很固定:单选、多选、判断、简答,前三种占绝大多数。难点不在题型识别,而在版式不统一——有的分卷写"答案:",有的写"参考答案",有的把答案放在整节末尾的统一对照表里,还有的选项后面跟着一行解析。硬写一条大正则必然崩,比较可靠的做法是先统计、再切分、最后用状态机拼装。

3.1 版式统计:先数清楚有几种行首模式

拿到清洗后的文本,第一步不是写解析器,而是把所有行首模式跑一遍计数。

import re from collections import Counter HEAD_PATTERNS = { "题号_数字点": re.compile(r"^\s*\d{1,4}\s*[.、).]"), "选项_字母": re.compile(r"^\s*[A-Da-d]\s*[.、).]"), "答案_中文": re.compile(r"^\s*(参考答案|正确答案|答案)\s*[::]"), "解析_中文": re.compile(r"^\s*(解析|答案解析|考点)\s*[::]"), "章节标题": re.compile(r"^\s*(第[一二三四五六七八九十]+[章节]|[一二三四五六七八九十]+、)"), } def profile(lines): stat = Counter() for line in lines: for name, pat in HEAD_PATTERNS.items(): if pat.match(line): stat[name] += 1 break else: stat["未识别"] += 1 return stat if __name__ == "__main__": with open("./txt/题库1.txt", encoding="utf-8") as f: lines = f.read().split("\n") for k, v in profile(lines).most_common(): print(f"{k}\t{v}")

未识别的数量是最有信息量的指标。如果它占比超过三成,说明版式和预设不符,先去翻这些行的原文,而不是继续加正则。常见原因是选项用了A、后面直接接换行、判断题答案写成(√)嵌在题干末尾。把统计结果当成"需求文档",比拍脑袋写规则高效得多。

3.2 用 python-docx 保留段落样式与表格结构

纯文本会丢掉两样东西:题干用的段落样式,和以表格形式排布的题目。前者能帮你区分题干与正文说明,后者在老版资料里非常常见。

from docx import Document from docx.document import Document as _Doc from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.table import Table from docx.text.paragraph import Paragraph def iter_block_items(parent): """按文档真实顺序遍历段落和表格,避免先出所有段落再出表格。""" if isinstance(parent, _Doc): parent_elm = parent.element.body else: parent_elm = parent._element for child in parent_elm.iterchildren(): if isinstance(child, CT_P): yield Paragraph(child, parent) elif isinstance(child, CT_Tbl): yield Table(child, parent) doc = Document("./out/江西省高校教师岗前培训高等教育心理学题库1资料全.docx") for block in iter_block_items(doc): if isinstance(block, Paragraph): # style.name 常见取值:Normal / Heading 1 / List Paragraph print("P", block.style.name, "|", block.text.strip()[:60]) else: # 表格先看行列数,再决定是按行解析还是直接拼接文本 print("T", len(block.rows), "x", len(block.columns))

iter_block_items这个写法是处理 Word 的必备套路,直接遍历doc.paragraphs再遍历doc.tables会打乱原始顺序,表格里的题目会被挪到文末。拿到顺序之后,段落用style.name判断层级,表格用rows逐行读单元格,两者的题号规则通常一致,可以复用同一套正则。

3.3 正则加状态机:把题目切成结构化记录

切题的核心思路是一个简单状态机:遇到题号就开新题,遇到选项就挂到当前题,遇到答案行就回填,遇到解析行就存进备注。

import re import json Q_START = re.compile(r"^\s*(\d{1,4})\s*[.、).]\s*(.+)$") OPTION = re.compile(r"^\s*([A-Da-d])\s*[.、).]\s*(.+)$") ANSWER = re.compile(r"^\s*(?:参考答案|正确答案|答案)\s*[::]?\s*([A-Da-d、,,√×对错\s]+)") EXPLAIN = re.compile(r"^\s*(?:解析|答案解析|考点)\s*[::]?\s*(.+)$") def parse(lines): items, cur = [], None for raw in lines: line = raw.strip() if not line: continue m = Q_START.match(line) if m and len(m.group(2)) > 4: # 长度过滤,避开"1. 第一章"这类章节行 if cur: items.append(cur) cur = {"no": int(m.group(1)), "stem": m.group(2).strip(), "options": {}, "answer": "", "explain": ""} continue if cur is None: continue m = OPTION.match(line) if m: cur["options"][m.group(1).upper()] = m.group(2).strip() continue m = ANSWER.match(line) if m: # 统一成大写、去分隔符,便于后面和选项做集合比较 cur["answer"] = re.sub(r"[、,,\s]", "", m.group(1)).upper() continue m = EXPLAIN.match(line) if m: cur["explain"] = m.group(1).strip() continue # 兜底:既不是题号也不是选项,多半是题干换行,接到题干尾部 cur["stem"] += line if cur: items.append(cur) return items

三个细节值得单独说。第一,题号正则必须加长度过滤,第一章前面可能带序号,1. 教育心理学的研究对象是才是真题干,用len > 4这个粗糙阈值能挡掉大部分误判。第二,答案归一化要顺手做,A、B、DABD在数据库里必须是同一种形态,否则统计正确率时会分裂成两类。第三,兜底分支很关键,Word 里题干经常被硬回车拆成两三行,直接追加比丢弃安全。

解析结果先落成 JSON,别急着入库,方便人工抽查。

# 抽查前 5 题,确认选项和答案都挂对了 python parse_bank.py > bank.json python -c "import json;d=json.load(open('bank.json'));print(json.dumps(d[:5],ensure_ascii=False,indent=2))"

3.4 表格题、图片题与多选答案的兜底策略

总有一部分题目不吃上面这套规则,提前把兜底方式定下来,比事后一题一题补要省时间。

异常类型典型表现兜底策略
表格排布题每行两列,左列题干右列答案按行读取单元格,左右拼成一条记录
题干含图片段落里只有题号和"如图"记录段落序号与图片关系,标记待人工补全
答案在末节对照表正文无答案行,文末集中列出单独解析对照表,按题号回填
判断题无选项只有题干和括号视为选项固定为 √/× 的特殊题型
多选答案连写答案为 ABCD 无分隔按字符拆成集合,长度大于 1 即多选

图片题在一份题库里通常不超过几十道,硬做 OCR 性价比很低,标记出来人工补是最实际的。答案对照表则相反,往往一次性覆盖几百道题,值得单独写一段解析逻辑:它的行首模式通常是1-5 ABDAC这种压缩写法,按题号区间展开即可。

4. 入库与检索:用 SQLite FTS5 支撑题库查询

到这一步,题库已经变成结构化的 JSON。但 JSON 只适合程序读,不适合你临时想查"教育心理学里关于记忆的题都有哪些"。换成 SQLite 之后,筛选、随机抽题、错题统计都能一条语句解决,而且单文件、免安装、方便随手拷走。全文检索用 FTS5,中文场景下比LIKE '%关键词%'快得多,也不会因为前缀通配而扫全表。

4.1 三张表把题目、选项和考点拆开

题目本体和选项分开存,是为了后面按选项内容检索和统计干扰项;考点标签单独一张表,方便按章节或知识点筛题。

-- 题目主表:一条记录一道题 CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, no INTEGER, -- 原文档题号,便于回溯 qtype TEXT NOT NULL, -- single / multi / judge / essay stem TEXT NOT NULL, -- 题干 answer TEXT, -- 归一化后的答案,如 ABD explain TEXT, -- 解析或考点说明 src_file TEXT, -- 来源文件名,多份资料合并时必留 src_block INTEGER, -- 在原文档中的块序号 created TEXT DEFAULT (datetime('now', 'localtime')) ); -- 选项表:一道题多行 CREATE TABLE IF NOT EXISTS options ( qid INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, label TEXT NOT NULL, -- A/B/C/D body TEXT NOT NULL, PRIMARY KEY (qid, label) ); -- 标签表:一个考点对多题,一题可挂多个标签 CREATE TABLE IF NOT EXISTS tags ( qid INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (qid, tag) ); CREATE INDEX IF NOT EXISTS idx_q_type ON questions(qtype);

src_file字段在合并多份资料时价值极高,一旦发现某批题答案明显不对,可以直接按来源回滚重导。src_block保存原始块序号,用于定位到 Word 里的具体位置。ON DELETE CASCADE保证删题时选项和标签一起走,不会留孤儿行。

4.2 FTS5 建表与中文分词的选择

FTS5 默认的unicode61分词器对中文是按字切分,也就是"心理学"会拆成"心""理""学"三个 token,好处是任意子串都能命中,坏处是短查询召回过多。较新的 SQLite 提供trigram分词器,按三字符滑窗切分,更适合中文短语匹配,代价是索引体积明显变大。

-- 外部内容表模式:FTS 只存索引,正文仍以 questions 为准,避免双份存储 CREATE VIRTUAL TABLE IF NOT EXISTS q_fts USING fts5( stem, explain, content='questions', content_rowid='id', tokenize='trigram' ); -- 触发器保持索引与主表同步 CREATE TRIGGER IF NOT EXISTS q_ai AFTER INSERT ON questions BEGIN INSERT INTO q_fts(rowid, stem, explain) VALUES (new.id, new.stem, new.explain); END; CREATE TRIGGER IF NOT EXISTS q_ad AFTER DELETE ON questions BEGIN INSERT INTO q_fts(q_fts, rowid, stem, explain) VALUES ('delete', old.id, old.stem, old.explain); END; CREATE TRIGGER IF NOT EXISTS q_au AFTER UPDATE ON questions BEGIN INSERT INTO q_fts(q_fts, rowid, stem, explain) VALUES ('delete', old.id, old.stem, old.explain); INSERT INTO q_fts(rowid, stem, explain) VALUES (new.id, new.stem, new.explain); END;

用外部内容表模式的好处是题干不会存两份,但代价是索引不同步时查询会静默返回空结果。导入完成后执行一次重建,是排错时第一个该做的动作:

INSERT INTO q_fts(q_fts) VALUES ('rebuild'); -- 体检:索引条数应当等于题目条数 SELECT (SELECT count(*) FROM q_fts) AS idx, (SELECT count(*) FROM questions) AS total;

如果trigram在你的环境里不可用,退回tokenize='unicode61'也能用,只是查询侧要接受更松的召回,通常再用一个stem LIKE做二次过滤就够了。

4.3 日常查询:按考点、按题型、按错题

入库之后,真正高频的查询其实就那几条,写成视图或脚本随时调用。

-- 1. 按关键词模糊查题,配合高亮片段快速定位 SELECT q.id, q.qtype, snippet(q_fts, 0, '[', ']', '…', 20) AS hit FROM q_fts JOIN questions q ON q.id = q_fts.rowid WHERE q_fts MATCH '教育心理学' ORDER BY rank LIMIT 20; -- 2. 按题型随机抽 30 道做自测 SELECT id, stem, answer FROM questions WHERE qtype = 'single' ORDER BY RANDOM() LIMIT 30; -- 3. 统计各题型题量,看看哪类题偏少 SELECT qtype, count(*) AS n, sum(CASE WHEN answer IS NULL OR answer = '' THEN 1 ELSE 0 END) AS no_answer FROM questions GROUP BY qtype ORDER BY n DESC; -- 4. 按考点标签筛题 SELECT q.stem, q.answer FROM questions q JOIN tags t ON t.qid = q.id WHERE t.tag = '记忆' AND q.answer IS NOT NULL;

第三条查询里的no_answer很有用,它直接把答案缺失的题量摊在台面上,比一题一题翻快得多。如果这个数字占总量的一成以上,说明答案解析阶段漏了某个分卷的答案对照表,回头补比入库后再改省事。

注意:ORDER BY RANDOM()在十万题以上会明显变慢,量级大时改成先随机取 id 区间再筛。

5. 校验、去重与导出:让题库真正能刷起来

结构化的题库最容易出的问题不是解析失败,而是"看起来对了"。同一道题在两个分卷里答案不一致,多份资料合并后出现三份重复题干,选项顺序被打乱后答案没跟着变——这些错误不校验根本发现不了。

第一道防线是重复检测。先对题干做归一化(去空格、去标点、统一全角半角),再用前 40 个字符做哈希分组,组内用difflib算相似度,超过 0.9 的挑出来人工确认。

import hashlib import re from difflib import SequenceMatcher def key_of(stem: str) -> str: s = re.sub(r"[\s,。、;:()()【】\[\]??!!]", "", stem) return hashlib.md5(s.encode("utf-8")).hexdigest()[:12] def find_dups(rows, threshold=0.9): """rows: [(id, stem, answer), ...] 返回疑似重复组。""" buckets = {} for qid, stem, ans in rows: buckets.setdefault(key_of(stem[:40]), []).append((qid, stem, ans)) groups = [] for _, items in buckets.items(): if len(items) < 2: continue used = set() for i, (qid, stem, ans) in enumerate(items): if qid in used: continue group = [(qid, ans)] used.add(qid) for oid, ostem, oans in items[i + 1:]: if oid in used: continue if SequenceMatcher(None, stem, ostem).ratio() >= threshold: group.append((oid, oans)) used.add(oid) if len(group) > 1: groups.append(group) return groups

重点是组内比对答案:如果同组记录的answer值不一致,那就是真问题,必须回到原始文档核对。哈希只用来缩小比对范围,真正判断相似的是SequenceMatcher,两者缺一个都会出现大量误报或漏报。

第二道防线是答案与选项的合法性校验。用一条 SQL 就能筛出越界答案:

-- 答案里出现了不存在的选项标签,或者单选题答案长度大于 1 SELECT q.id, q.stem, q.answer FROM questions q LEFT JOIN options o ON o.qid = q.id WHERE q.answer IS NOT NULL AND q.answer <> '' GROUP BY q.id HAVING sum(CASE WHEN instr(q.answer, o.label) > 0 THEN 1 ELSE 0 END) < length(q.answer) OR (q.qtype = 'single' AND length(q.answer) > 1);

导出环节有个小坑值得单独提:给背题工具用的 TSV 必须保证字段里没有制表符和换行,题干里残留的\t会让整行错位。

def to_tsv(rows, path="./anki.tsv"): def clean(s): return re.sub(r"[\t\r\n]+", " ", s or "").strip() with open(path, "w", encoding="utf-8", newline="\n") as f: for stem, options, answer, explain in rows: body = "<br>".join(f"{k}. {v}" for k, v in sorted(options.items())) f.write("\t".join([clean(stem), clean(body), clean(answer), clean(explain)]) + "\n")

newline="\n"是必须的,Windows 下默认会写成\r\n,部分背题工具会把它当成字段内的换行处理。写完先head -3 anki.tsv | cat -A,看到行尾只有$而没有^M才算干净。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询