用Python解析docx电工题库并构建SQLite刷题命令行

发布时间:2026/9/17 12:18:38
用Python解析docx电工题库并构建SQLite刷题命令行 简介面向备考2025年初级电工证的在职人员、职业院校学生与转岗培训学员这份题库汇编用于考前刷题、考点自查与错题复盘。题库按单选、多选、判断和实操分类理论部分覆盖延边三角形启动、自耦变压器减压启动、绕线式异步电动机转子串电阻启动、电磁抱闸与回馈制动、二极管反向电压及桥式整流等电机与电子技术考点安全与工具部分则涉及低压验电器原理、验电笔使用禁忌、电压互感器与电流互感器二次侧接线、钳形电流表与游标卡尺操作、喷灯维护、工频摆脱电流以及未成年工特殊保护并单列职业道德的功能与作用。包内为1个docx文档压缩包约49KB轻量便携目前已有114人学习。适合按题型分节练习借助答案解析定位薄弱环节在记忆理论的同时训练实操判断与安全规范意识。1. 一份 docx 格式的初级电工证考试题库为什么不能直接拿来刷题很多人拿到2025初级电工证考试题库及答案大全.docx 的第一反应是打开 Word 按 CtrlF 搜关键词再一页页往下翻。题量一旦过八百这种用法很快会崩想按章节练、想随机组卷、想把做错的题攒起来重做Word 一样都给不了。卡住人的不是题目本身而是这份题库的排版是给人读的不是给程序读的。要把初级电工证考试题库变成能查、能练、能记错题的东西中间得补一层解析与建模先把正文拆成题干、选项、答案、解析四个字段再落进 SQLite 之类的本地库最后套一个命令行壳子。这套路子适合自己备考的开发者也适合需要给学员批量组卷的培训讲师一次投入后面每套卷子都是几秒钟的事。2. 用 python-docx 解析初级电工证考试题库的段落与表格解析这一步决定后面所有环节的上限。docx 里的文字不可能只以一种形态存在同一份初级电工证考试题库前一半可能是普通段落排版后一半可能整整齐齐排成表格甚至有些版本的答案单独放在文末答案区。先摸清结构再动手写正则比拿到文件就闷头拆要省掉大量返工。2.1 docx 的文本藏在哪段落、表格与容易被漏掉的文本框docx 本质上是个 zip 包正文在word/document.xml里。python-docx 的doc.paragraphs只覆盖 body 下面直接挂的w:p节点表格里的文字必须走doc.tables单独遍历而文本框w:txbxContent和部分艺术字里的内容python-docx 默认读不到。先做一次体量比对能立刻判断有没有漏料。pip install python-docx # 题库文档就是个 zip先看内部结构 unzip -l 2025初级电工证考试题库及答案大全.docx | grep -E document.xml|header|footnotefrom docx import Document doc Document(2025初级电工证考试题库及答案大全.docx) body_text .join(p.text for p in doc.paragraphs) tbl_text .join(c.text for t in doc.tables for r in t.rows for c in r.cells) print(段落字符数, len(body_text), 表格字符数, len(tbl_text)) print(段落总数, len(doc.paragraphs), 表格总数, len(doc.tables)) # 段落样式分布样式名往往比正则更稳比如题干固定用 Heading 3 from collections import Counter print(Counter(p.style.name for p in doc.paragraphs).most_common(15))段落加表格的字符数如果明显小于 document.xml 解出来的纯文本量说明有文本框或图片里的题干被漏掉了这种情况直接用zipfile读出 XML 再做字符串截取更省事。提示先把样式分布打出来。很多题库的题干用标题样式、选项用正文样式靠p.style.name切段落流比靠正则硬猜准确率高得多。2.2 题干、选项、答案、解析的正则锚点表中文文档里的题号写法极其随意1.、1、、(1)、第1题都可能出现选项前缀半角点、全角句点、顿号混着用。先把全角字符统一转半角再按固定锚点匹配能省掉一半的脏数据。位置常见写法建议正则题号1.1、第1题(1)^\s*(?:第)?(\d{1,4})\s*[.、)]\s*(?\S)选项A.A、^\s*([A-Da-d-])\s*[.、)]\s*(.)$答案答案A参考答案:ABC(?:参考|正确)?答案\s*[:]?\s*([A-Da-d-对错√×正确错误])解析解析分析说明(?:解析|分析|说明)\s*[:]\s*(.*)$全角转半角用 Unicode 码位差值一次搞定到A、到1都在同一个区间import re def halfwidth(s: str) - str: 全角转半角0xFF01~0xFF5E 与 0x21~0x7E 一一对应 return .join( chr(ord(ch) - 0xFEE0) if 0xFF01 ord(ch) 0xFF5E else ch for ch in s ).replace(\u3000, ).strip()2.3 状态机解析把段落流切成一道一道题单靠正则替代不够题号是唯一可靠的分隔符用它做状态机比逐行判断更稳。核心逻辑就一条遇到题号就结算上一题、开启新题其余行按选项、答案、解析的优先级归位。RE_Q re.compile(r^\s*(?:第)?(\d{1,4})\s*[.、)]\s*(?\S)) RE_OPT re.compile(r^\s*([A-Da-d-])\s*[.、)]\s*(.)$) RE_ANS re.compile(r(?:参考|正确)?答案\s*[:]?\s*([A-Da-d-对错√×正确错误])) RE_EXP re.compile(r(?:解析|分析|说明)\s*[:]\s*(.*)$) def parse_paragraphs(lines, chapterNone): items, cur [], None def flush(): nonlocal cur if cur and cur[stem]: # 题型判定答案长度大于 1 是多选答案是对/错是判断题 cur[qtype] (multiple if len(cur[answer]) 1 else judge if cur[answer] in (对, 错, 正确, 错误) else single) items.append(cur) cur None for ln, raw in enumerate(lines, 1): text halfwidth(raw) if not text: continue if RE_Q.match(text): # 新题开始先结算上一题 flush() cur {stem: RE_Q.sub(, text, count1).strip(), options: [], answer: , explain: , chapter: chapter, line: ln} continue if cur is None: # 题号之前的封面、目录文字直接丢弃 continue mo, ma, me RE_OPT.match(text), RE_ANS.search(text), RE_EXP.search(text) if mo: cur[options].append((mo.group(1).upper(), mo.group(2).strip())) elif ma: cur[answer] ma.group(1).upper() elif me: cur[explain] me.group(1).strip() else: # 续行题干换行、选项换行、解析换行 if cur[options] and not cur[answer]: label, content cur[options][-1] cur[options][-1] (label, content text) elif cur[explain]: cur[explain] text else: cur[stem] text flush() return itemsflush()里的题型判定是整个流程里最容易出错的地方判断题的答案是「对」「错」而不是字母如果不单独识别后面判分时会把「对」当选项标签去查永远查不到。另外注意elif的顺序一行里同时出现「答案A 解析xxx」时只会命中答案那一支稳妥做法是先用RE_ANS.split把行切开再分别匹配或者干脆在解析前做一次行内切分。2.4 表格型题库与合并单元格的处理表格版题库的坑在合并单元格row.cells遇到纵向合并时会把同一个w:tc元素重复吐出来直接用会得到一片重复文本。按底层元素 id 去重是可靠做法。def iter_table_rows(table): 按行吐单元格文本合并单元格在 row.cells 里会重复用底层元素 id 去重 for row in table.rows: seen, cells set(), [] for cell in row.cells: key id(cell._tc) # 合并时同一个 w:tc 被复用 if key in seen: continue seen.add(key) cells.append(cell.text.strip()) if any(cells): yield cells HEADER_ALIAS {题号: no, 题目: stem, 题干: stem, 选项: opts, 答案: answer, 正确答案: answer, 解析: explain, 章节: chapter}拿到行之后先在第一行找表头命中HEADER_ALIAS就把列号映射成字段名没有表头就按列数兜底——4 列通常是「题号/题干/选项/答案」6 列通常是「题干/A/B/C/D/答案」。映射表写好之后同一套代码能吃下市面上大多数排版版本。3. 把初级电工证考试题库落进 SQLite三张表加去重入库解析出来的题目是个 Python 列表直接拿来做刷题也行但一旦要统计章节正确率、要跨次记录错题、要增量重导一份新版题库列表就撑不住了。落库这一步只做一次后面所有查询都变成一行 SQL。3.1 用 SQLite 而不是 Excel 或纯 JSON 的理由方案适合场景明显短板纯 JSON题量小、只读、方便塞进 git全量读写无法建索引统计要自己写循环CSV/Excel交付给非技术人员人工核对无类型无外键题干里的换行和逗号极易破坏结构SQLite本地刷题、错题统计、多次增量导入需要写 SQL多人同时写要换服务型数据库初级电工证考试题库这种规模几百到几千题正好落在 SQLite 的舒适区单文件、零部署、ORDER BY RANDOM()抽题几千条也就毫秒级。用 JSON 存每次加一条错题记录都要重写整个文件中途崩了就是半个文件。3.2 三张表的结构设计题目、选项、作答记录拆成三张表选项独立成表的好处是后面要检测「答案标签在选项里不存在」这类脏数据时一条 JOIN 就能查出来。CREATE TABLE questions ( id INTEGER PRIMARY KEY, qtype TEXT NOT NULL CHECK (qtype IN (single,multiple,judge)), stem TEXT NOT NULL, stem_norm TEXT NOT NULL, -- 归一化题干用于去重 answer TEXT NOT NULL, explain TEXT DEFAULT , chapter TEXT, source_line INTEGER, created_at TEXT DEFAULT (datetime(now,localtime)) ); CREATE UNIQUE INDEX ux_stem ON questions(stem_norm, qtype); CREATE TABLE options ( qid INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, label TEXT NOT NULL, content TEXT NOT NULL, is_correct INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (qid, label) ); CREATE TABLE attempts ( id INTEGER PRIMARY KEY, qid INTEGER NOT NULL REFERENCES questions(id), user_answer TEXT, is_right INTEGER, ts TEXT DEFAULT (datetime(now,localtime)) ); CREATE INDEX ix_attempts_qid ON attempts(qid, ts DESC);ux_stem这个唯一索引是全篇最值钱的一行。同一份题库反复导入时靠它把「新增」和「更新」自动区分开不用自己写比对逻辑。3.3 归一化题干去重键怎么生成去重键不能直接用原题干因为同一道题在不同版本里标点、空格、全半角经常有微小差异。做法是只保留中文、字母、数字其余全部剥掉后转小写。import re PUNC re.compile(r[\s。、“”‘’()\[\]【】《》.,;:?!\\-—_/\\]) def stem_norm(stem: str) - str: 题干归一化剥掉标点与空白用于唯一索引去重 return PUNC.sub(, halfwidth(stem)).lower()注意这里保留了字母和数字因为「10kV」和「10KV」需要归一到同一条但「10」和「100」必须保持区分。如果连数字都剥掉一批数字不同、表述相同的计算题会被误判成重复。3.4 增量入库脚本与 UPSERT 写法import sqlite3 def open_db(pathdg.sqlite): conn sqlite3.connect(path) conn.execute(PRAGMA foreign_keysON) conn.execute(PRAGMA journal_modeWAL) # 边刷题边导入时减少锁冲突 return conn INSERT_Q INSERT INTO questions(qtype, stem, stem_norm, answer, explain, chapter, source_line) VALUES(?,?,?,?,?,?,?) ON CONFLICT(stem_norm, qtype) DO UPDATE SET answer excluded.answer, explain CASE WHEN excluded.explain THEN excluded.explain ELSE questions.explain END RETURNING id def save(conn, items, chapterNone): n 0 for it in items: norm stem_norm(it[stem]) if not norm or not it[answer]: # 没答案的题不入库留给人工核对 continue qid conn.execute(INSERT_Q, ( it[qtype], it[stem], norm, it[answer], it[explain], chapter or it.get(chapter), it[line] )).fetchone()[0] conn.execute(DELETE FROM options WHERE qid?, (qid,)) conn.executemany( INSERT INTO options(qid,label,content,is_correct) VALUES(?,?,?,?), [(qid, lb, ct, int(lb in it[answer])) for lb, ct in it[options]] ) n 1 conn.commit() return nON CONFLICT后面的字段必须和唯一索引完全对应写错顺序会直接报错而不是静默失败。RETURNING需要 SQLite 3.35 以上低于这个版本时走INSERT ... ON CONFLICT DO UPDATE之后再用SELECT id FROM questions WHERE stem_norm? AND qtype?查一次 id多一次查询但兼容性更好。选项先 DELETE 再 INSERT 是有意为之——同一道题的选项在不同版本里可能从四个变五个增量更新比逐条比对简单得多。3.5 FTS5 全文检索关键词查题题干里的「熔断器」「漏电保护」「安全距离」这类词用LIKE %关键词%在几千题里查已经够快但想要分词、排序和高亮就得用 FTS5。中文常见做法是配trigram分词器它按三字符滑窗建索引对中文短词命中率不错。CREATE VIRTUAL TABLE IF NOT EXISTS q_fts USING fts5( stem, explain, contentquestions, content_rowidid, tokenizetrigram ); -- 首次建表或题库批量更新后重建索引 INSERT INTO q_fts(q_fts) VALUES(rebuild);SELECT q.id, q.stem FROM q_fts f JOIN questions q ON q.id f.rowid WHERE q_fts MATCH ? ORDER BY rank LIMIT 20;参数上有两个硬约束trigram分词器要求 SQLite 3.34 以上MATCH的关键词至少要三个字符搜「电容」这种两字词会查不到东西得退回LIKE。题量不大时直接LIKE % || ? || %反而更省心。4. 用 Python 写一个初级电工证考试题库刷题命令组卷、判分、错题本数据进了库剩下就是把查询包装成一个能天天用的命令。命令行比网页更适合这个场景不用起服务脚本丢进任意目录都能跑输出重定向到文件就是一份可打印的卷子。4.1 抽题与组卷按题型等比例分配随机抽题不能一把ORDER BY RANDOM()完事否则多选和判断题的占比完全不可控。做法是先按题型分组统计总数再按目标比例分配名额最后合并。import random, sqlite3 def pick(conn, types, count, chapterNone, seedNone): 按题型组卷seed 固定时可复现同一套卷子 rnd random.Random(seed) rows [] per_type max(1, count // len(types)) # 每种题型的基础名额 for t in types: args, where [t], [qtype?] if chapter: where.append(chapter?); args.append(chapter) sql (SELECT id,qtype,stem,answer,explain FROM questions WHERE AND .join(where) ORDER BY RANDOM() LIMIT ?) rows.extend(conn.execute(sql, args [per_type]).fetchall()) # 名额没凑够就从剩余题库补齐 if len(rows) count: have {r[0] for r in rows} args list(types) more conn.execute( SELECT id,qtype,stem,answer,explain FROM questions WHERE qtype IN (%s) ORDER BY RANDOM() LIMIT ? % ,.join(? * len(types)), args [count * 3]).fetchall() rows.extend(r for r in more if r[0] not in have) rnd.shuffle(rows) # 打散题型顺序避免一整套全是判断题 return rows[:count]per_type取count // len(types)是均分策略如果想让单选占大头、判断题只占两成把这里改成权重字典{single:0.6,multiple:0.2,judge:0.2}再乘count即可。seed参数的实际用途是复现学员说某套卷子有问题报个种子数就能在本地还原出完全一样的题目顺序。4.2 命令行参数表参数类型默认值作用--dbpathdg.sqlite题库文件路径--countint50抽题数量超出库内总量自动截断--typescsvsingle,judge题型过滤可选 single/multiple/judge--chapterstrNone按章节抽题值与questions.chapter完全一致--seedintNone固定随机种子复现同一套卷子--wrong-onlyflagFalse只抽错题本里做错过的题--timeoutint0每题限时秒数0 表示不限时import argparse def build_parser(): ap argparse.ArgumentParser(description初级电工证考试题库本地刷题) ap.add_argument(--db, defaultdg.sqlite) ap.add_argument(--count, typeint, default50) ap.add_argument(--types, defaultsingle,judge) ap.add_argument(--chapter, defaultNone) ap.add_argument(--seed, typeint, defaultNone) ap.add_argument(--wrong-only, actionstore_true) ap.add_argument(--timeout, typeint, default0) return ap4.3 交互刷题循环与判分判分要处理两个细节多选答案与顺序无关判断题的输入要能接受字母和汉字两种写法。def judge(qtype, answer, user_input): ua .join(sorted(user_input.strip().upper())) # 多选答案与顺序无关 if qtype judge: ua {A: 对, B: 错, T: 对, F: 错, √: 对, ×: 错}.get(ua, ua) return ua .join(sorted(answer.upper())) def run(conn, rows): right 0 for i, (qid, qtype, stem, answer, explain) in enumerate(rows, 1): print(f\n[{i}/{len(rows)}] {stem}) for label, content in conn.execute( SELECT label,content FROM options WHERE qid? ORDER BY label, (qid,)): print(f {label}. {content}) hint 对/错 if qtype judge else 答案字母多选连写如 ABC user input(f你的答案{hint}).strip() ok judge(qtype, answer, user) right ok # 每次作答都落库错题本和正确率统计都靠这张表 conn.execute(INSERT INTO attempts(qid,user_answer,is_right) VALUES(?,?,?), (qid, user, int(ok))) if not ok: print(f 错。正确答案{answer}) if explain: print(f 解析{explain}) conn.commit() print(f\n得分 {right}/{len(rows)}正确率 {right / len(rows):.1%})sorted()那一步是关键。多选答案在题库里可能写作ABC用户输入CBA不排序就会误判。判断题的映射表把T/F、√/×一并接住避免用户习惯性输字母反而被判错。4.4 错题本从 attempts 表反查未掌握的题错题本不需要单独建表attempts已经记着每次作答。要「只刷错题」按题目聚合错误次数倒序取就行。-- 做错过且错误次数最多的题优先重做 SELECT q.id, q.qtype, q.stem, q.answer, q.explain, SUM(1 - a.is_right) AS err_cnt FROM questions q JOIN attempts a ON a.qid q.id GROUP BY q.id HAVING err_cnt 0 ORDER BY err_cnt DESC, MAX(a.ts) DESC;只关心「最近一次是否仍错」时把聚合换成窗口函数取每组最新一条ROW_NUMBER() OVER (PARTITION BY qid ORDER BY ts DESC)外层筛rn1 AND is_right0。窗口函数要 SQLite 3.25 以上取不到就用聚合版本的近似逻辑差别只在连续做对几次之后错题会不会自动消掉——按错误次数排序的版本会一直留着适合考前反复过一遍。5. 题库维护的几个校验技巧答案冲突、题干重复与解析缺失题库用一段时间之后必然要面对版本更新新导一份 2025 版进来很可能和旧数据打架。与其人工翻不如把三类问题写成固定检查。第一类是同一道题出现不同答案这通常来自合并了多个来源的题库。用归一化题干分组答案种类大于一就是冲突SELECT stem_norm, COUNT(DISTINCT answer) AS c, GROUP_CONCAT(DISTINCT answer) AS answers, COUNT(*) AS rows_cnt FROM questions GROUP BY stem_norm HAVING c 1;第二类是答案标签在选项里根本不存在典型成因是解析时答案行和选项行错位或者答案是ABCD而选项只解析出三个SELECT q.id, q.stem, q.answer FROM questions q WHERE q.qtype IN (single,multiple) AND NOT EXISTS ( SELECT 1 FROM options o WHERE o.qid q.id AND instr(q.answer, o.label) 0 );第三类是表述几乎相同、只差一两个字的重复题标点归一化挡不住这种。用difflib做相似度扫描最直接import difflib, sqlite3 conn sqlite3.connect(dg.sqlite) rows conn.execute(SELECT id, stem_norm FROM questions).fetchall() buckets {} for qid, s in rows: buckets.setdefault(s[:4], []).append((qid, s)) # 按前 4 字分桶避免 O(n^2) 全量比 for bucket in buckets.values(): for i in range(len(bucket)): for j in range(i 1, len(bucket)): r difflib.SequenceMatcher(None, bucket[i][1], bucket[j][1]).ratio() if r 0.92: print(bucket[i][0], bucket[j][0], round(r, 3))阈值 0.92 是个偏保守的起点只捞几乎完全重合的题调到 0.88 能多抓一批换行位置不同导致的伪重复但也会把「熔断器的作用」和「熔断器的选用」误报出来需要人工扫一眼再决定是否合并。分桶那一步不能省几千题两两比较是千万级次数按前 4 字分桶后单桶通常只有几十条。解析缺失的统计更简单SELECT COUNT(*) FROM questions WHERE explain一跑就知道缺解析的题在刷题时表现为「答完只给答案不给理由」考前突击时体验很差值得优先补。本文还有配套的精品资源点击获取