资讯动态

Python PDF解析实战:单招计算机试卷题库化与自动判卷

发布时间:2026/9/20 1:55:56 来源:尧图企业网站定制
简介2023年单独考试招生文化考试综合卷计算机方向以PDF形式呈现面向中职升高职的单招考生及计算机基础课程复习者可用于考前模拟自测与知识点查漏补缺。整份试卷满分120分、考试时间90分钟含20道选择题60分、问答题30分与阅读理解30分三大板块选择题覆盖FTP协议、输入输出设备、二进制存储、信息资源管理方式等计算机基础并穿插科举制度始于隋朝、《天演论》译者严复、全民国防教育日、地球公转与四季更替、世界杯举办周期等人文常识问答题聚焦王国维的戏曲定义、现存最古剧本以及青少年合理利用网络资源的认识等阅读理解选取西餐礼仪案例与《阿长与山海经》选段考查信息提取与修辞分析。压缩包仅1个PDF文件约973KB末尾附完整参考答案及部分解析便于对照批改。已有72人学习下载。1. 2023年单招计算机考试综合卷 PDF 的结构化切口2023年单招计算机考试综合卷 PDF 这类资源表面是 120 分、90 分钟的纸卷实际对 IT 人更像一份半结构化数据前面 20 道选择题中间 7 道问答题后面两段阅读理解参考答案单独压在文件末尾。直接把整份试卷喂给题库系统题号、选项、答案会混在一起手工录入又容易把“1-5:BBCAC”这种连续答案抄错。比较务实的做法是先用 pdfplumber 或 PyMuPDF 做 PDF 解析把文本流拆成题目区、答案区再按题号回填答案最后落成 JSON。适合做教育工具、考试系统、计算机二级刷题小程序的开发者也适合想把历年单招卷批量数字化的老师。下面按抽取、切分、判卷、组卷四段走。2. PDF 文本抽取pdfplumber 与 PyMuPDF 的选型与坐标策略这份综合卷是典型的文本型 PDF不是扫描件所以第一反应不应该是上 OCR而是把字符、行、块坐标拿稳。选型时别只看“能不能读文字”要看能不能保住 A/B/C/D 选项的边界以及答案区“1-5:BBCAC”这种连续串不被拆散。常见做法是 pdfplumber 做行级抽取和坐标清洗PyMuPDF 做整本快速扫描与图片兜底两者互补不互相替代。2.1 先判断文本型 PDF 还是扫描件判断方法很便宜读取第一页的文本层即可。如果返回大量可读中文说明字体对象还在直接抽取如果返回空白、乱码或只有零散数字大概率是图片型 PDF需要走 OCR。下面这段用 PyMuPDF 做快速探测import fitz # PyMuPDF用于快速读取 PDF 文本层 pdf_path 2023年单招计算机考试综合卷答案在最后.pdf doc fitz.open(pdf_path) # 打开文件不一次性渲染页面 first_text doc[0].get_text(text) # 读取第一页纯文本 print(first_text[:300]) print(pages:, len(doc)) # 输出总页数方便后续按页循环fitz.open只加载文件句柄不会一次性把整本渲染成图片内存开销小get_text(text)返回按阅读顺序拼接的纯文本适合快速看有没有文本层len(doc)用来确认页数后面按页循环时不会越界。如果第一页输出空白不要立刻放弃先翻到答案页再试一次有时题干是图片、答案页是文本这种情况要分开处理。2.2 pdfplumber 行级抽取与坐标清洗pdfplumber 的优势在于extract_text和extract_words都能带坐标。选择题最怕的是“A. HTTPS B. FTP”被合并成一行后面用正则拆选项时把英文缩写里的字母也当成选项标记。先按行取文本再按单词取坐标是更稳的路径import pdfplumber pdf_path 2023年单招计算机考试综合卷答案在最后.pdf with pdfplumber.open(pdf_path) as pdf: for page_no, page in enumerate(pdf.pages, start1): # extract_text 控制同一行字符合并容忍度 text page.extract_text(x_tolerance2, y_tolerance3) # extract_words 保留坐标用于判断选项是否同行 words page.extract_words( use_text_flowFalse, keep_blank_charsFalse, extra_attrs[size, fontname] ) print(f--- page {page_no} ---) print(text[:200]) for w in words[:8]: print(w[text], round(w[x0], 1), round(w[top], 1), w[size])x_tolerance控制同一行内字符横向合并的容忍度值越小越不容易把相距较远的字粘在一起y_tolerance控制纵向换行判断中文试卷行距小给 3 左右通常够用。extract_words里的use_text_flowFalse表示按坐标而不是 PDF 内部流顺序返回extra_attrs把字号和字体名带出来后面可以用字号区分题干和选项。注意如果选项 A/B/C/D 在同一行top会接近x0会依次增大这正是拆分选项的依据。2.3 PyMuPDF 做整本定位与图片兜底整本 20 道选择题加答案区pdfplumber 逐页抽已经能跑但如果你想先定位“参考答案”在第几页PyMuPDF 的块级接口更快。get_text(blocks)返回的块带(x0, y0, x1, y1, text, block_no, block_type)block_type0是文本1是图片。答案页如果有截图就靠这个字段发现import fitz doc fitz.open(pdf_path) for pno in range(len(doc)): page doc[pno] blocks page.get_text(blocks) blocks.sort(keylambda b: (b[1], b[0])) # 按 top 再按 x0 排序还原阅读顺序 for b in blocks: text b[4].replace(\n, ).strip() if text: # b[6] 为 0 表示文本块1 表示图片块 print(pno 1, b[6], round(b[0], 1), round(b[1], 1), text[:60])b[6]是块类型文本块为 0图片块为 1排序键(b[1], b[0])先看纵向位置再看横向位置能还原大多数单栏试卷的阅读顺序。如果答案区出现block_type1说明答案是图片或公式截图需要单独导出该区域再做 OCR不能硬走文本正则。工具选型可以按下面这张表快速决策| 工具 | 优势 | 适合场景 | 注意 | | pdfplumber | 行/字符坐标细表格接口好 | 选择题选项对齐、答案区行定位 | 大文件慢逐页打开 | | PyMuPDF | 速度快块/图片信息全 | 整本扫描、答案页定位 | 文本顺序按块可能跳行 | | pdfminer.six | 布局对象细 | 复杂多栏排版 | API 层级多上手慢 | | PaddleOCR/Tesseract | 图片型 PDF 可读 | 扫描件、截图答案 | 中文后处理成本高 |注意先判断文本层再决定是否 OCR直接上 OCR 会把本来干净的文本变成噪声。依赖安装用虚拟环境隔离避免和系统里的旧版 PDF 库冲突# 创建并激活隔离环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install pdfplumber PyMuPDF参数说明source与venv\Scripts\activate是不同系统的激活方式pdfplumber依赖pdfminer.six如果项目里已经装了旧版先pip list看版本再决定是否升级。抽取阶段的目标不是一次拿到完美文本而是拿到带坐标的原始素材给下一章的切分留出修正空间。3. 题目切分与答案回填正则、状态机和结构化 JSON文本抽出来后最麻烦的不是识别文字而是把“题号、题干、选项、答案”四者对齐。这份卷子的答案区是“1-5:BBCAC 6-10:BCCCE 11-15:CBCCC 16-20:ACAAC”这种连续串问答题和阅读理解答案又是段落式。切分策略要分两层先按“参考答案”把试卷切成题目区和答案区再在题目区按题号切选择题、问答题、阅读子题。3.1 定位答案区并解析连续答案答案区定位用rfind而不是find因为题干里可能也出现“参考答案”四个字最后一个才是真正的答案段。解析连续答案时正则要同时兼容1-5:、1~5、1—5等写法import re # 兼容 1-5:BBCAC、1~5BBCAC、1—5 等写法 ANSWER_RANGE re.compile(r(\d)\s*[-~—]\s*(\d)\s*[:]\s*([A-D])) def split_paper(full_text: str): key 参考答案 idx full_text.rfind(key) # 取最后一个防止题干里的“参考答案”干扰 if idx -1: raise ValueError(未找到参考答案区检查 PDF 是否抽到文本层) return full_text[:idx], full_text[idx len(key):] def parse_choice_answers(block: str) - dict[int, str]: result {} for m in ANSWER_RANGE.finditer(block): start, end, letters int(m.group(1)), int(m.group(2)), m.group(3).upper() expected end - start 1 if len(letters) ! expected: raise ValueError(f答案区间 {start}-{end} 应有 {expected} 个实际 {len(letters)} 个) for offset, ch in enumerate(letters): result[start offset] ch return resultsplit_paper返回(题目区, 答案区)rfind保证取最后一个“参考答案”ANSWER_RANGE的三个分组分别是起始题号、结束题号、答案字母串。parse_choice_answers里用expected做长度校验这是防抄错的关键如果 PDF 里少了一个字母这里会直接报错而不是把第 6 题答案错位成第 5 题的。常见坑是答案串里混入小写字母或全角冒号正则里已经用[:]兼容字母统一upper()。3.2 选择题题干与选项切分题目区的选择题从“1、文件传输协议...”到“20、北京大钟寺...”题干和选项可能在同一行也可能分行。不能只用换行切要用“题号顿号/点号”作为主切分再用 A/B/C/D 标记切选项QUESTION_SPLIT re.compile(r(?m)^\s*(\d{1,2})\s*[、.]\s*) def split_by_question_no(text: str) - dict[int, str]: parts QUESTION_SPLIT.split(text) questions {} for i in range(1, len(parts), 2): qno int(parts[i]) body parts[i 1].strip() questions[qno] body return questions # 负向后顾避免把 HTTPS 里的 A、FTP 里的 B 误判为选项 OPTION_MARK re.compile(r(?![A-Za-z])([A-D])\s*[、.]?\s*) def parse_choice_body(body: str): marks list(OPTION_MARK.finditer(body)) if len(marks) 4: return {stem: body.strip(), options: {}} stem body[:marks[0].start()].strip() options {} for i, m in enumerate(marks): end marks[i 1].start() if i 1 len(marks) else len(body) options[m.group(1)] body[m.end():end].strip() return {stem: stem, options: options}QUESTION_SPLIT用(?m)开启多行模式^匹配行首\s*吃掉缩进(\d{1,2})捕获题号[、.]兼容中文顿号、英文点和全角点。OPTION_MARK里的(?![A-Za-z])负向后顾很重要否则HTTPS里的A、FTP里的B都可能被误判成选项标记。parse_choice_body先找第一个选项标记的位置它前面的就是题干后面按标记切出四个选项。这段代码在这份卷子上会遇到两个真实脏数据一是“A. HTTPSB. FTPC. TCP/IPD. www.”这种选项之间没有空格二是第 12 题“网址中的表示”和第 13 题“从网址中可以判断该网站属于”在原文里可能有换行断裂。常见做法是先把body里的连续空格压成一个再跑选项正则如果len(marks) 4不要直接丢弃把该题塞进人工复核队列。| 脏数据类型 | 例子 | 处理方式 | | 选项粘连 | HTTPSB. FTP | 在[A-D]前插入空格或调小x_tolerance| | 题号重复 | 第 8 题与第 14 题内容重复 | 保留原始题号用source_no区分 | | 全角半角 | |str.maketrans归一化 | | 答案错位 | 答案串少字母 | 长度校验直接抛错 |3.3 问答题与阅读子题的结构化问答题区不能套选择题模板。第 1 到第 7 题里第 6 题带表格第 7 题是信息资源管理方式列举阅读理解又分一二每篇下面有小题。稳妥做法是保留原始块给每块打type标签不要强行拆成统一结构import json def build_bank(full_text: str): paper_text, answer_text split_paper(full_text) choice_std parse_choice_answers(answer_text) q_map split_by_question_no(paper_text) choices [] for no in range(1, 21): raw q_map.get(no, ) parsed parse_choice_body(raw) choices.append({ no: no, stem: parsed[stem], options: parsed[options], answer: choice_std.get(no, ), type: choice, score: 3 }) qa [] for no in range(1, 8): # 第 6、7 题含表格或列举保留原始文本避免正则拆坏结构 if no in [6, 7]: qa.append({no: no, type: qa_raw, raw: q_map.get(no, )}) else: qa.append({no: no, type: qa, raw: q_map.get(no, )}) return {choices: choices, qa: qa, answer_raw: answer_text} if __name__ __main__: with open(paper_text.txt, r, encodingutf-8) as f: full f.read() bank build_bank(full) with open(bank.json, w, encodingutf-8) as f: json.dump(bank, f, ensure_asciiFalse, indent2)build_bank把选择题固定为 20 题每题 3 分choice_std里没有答案的题号会留空方便后面报警。问答题第 6、7 题标记为qa_raw因为表格和列举题一旦被正则拆坏恢复成本比保留原文高。输出 JSON 用ensure_asciiFalse保留中文indent2方便人工抽查。运行前需要先把 PDF 抽出的文本存成paper_text.txt这一步可以用第二章的 pdfplumber 脚本完成。4. 自动判卷与错题归因答案映射、模糊匹配和评分脚本题库 JSON 建好后判卷本身不复杂难的是答案归一化和错题归因。这份卷子选择题 20 题每题 3 分共 60 分问答题 30 分阅读理解 30 分。如果只做选择题判分一个精确匹配函数就够如果要把问答题也纳入必须引入模糊匹配和人工复核不然同义表达会被判错。4.1 标准答案归一化与选择题评分学生答案可能来自答题卡识别、手动输入或网页表单常见问题有全角字母、空格、小写。先归一化再比较NORMALIZE_MAP str.maketrans({ : A, : B, : C, : D, : a, : b, : c, : d, : :, : ,, : , \t: }) def normalize_answer(s: str) - str: return s.translate(NORMALIZE_MAP).upper() def score_choice(student_ans: dict[int, str], std_ans: dict[int, str], per_score: int 3): total 0 detail [] for no in sorted(std_ans): stu normalize_answer(student_ans.get(no, )) std std_ans[no] ok stu std if ok: total per_score detail.append({no: no, student: stu, std: std, ok: ok}) return total, detailNORMALIZE_MAP用str.maketrans建立字符映射全角字母转半角、去空格、统一冒号normalize_answer再统一转大写避免b和B被判成不同答案。score_choice的per_score默认 3对应这份卷子选择题每题 3 分detail里保留学生答案、标准答案和是否正确后面做错题本和标签统计都靠它。注意如果以后遇到多选题ok不能只写stu std要先排序再比较集合。4.2 问答题关键词命中与模糊匹配问答题标准答案在原文里是段落式例如“戏曲是以歌舞演故事”“王国维 1902 年写成的《宋元戏曲考》”。完全字符串匹配会把“戏曲是以歌舞来演故事”判错所以常见做法是关键词命中加相似度只给参考分最终以人工复核为准from difflib import SequenceMatcher def fuzzy_ratio(a: str, b: str) - float: return SequenceMatcher(None, normalize_answer(a), normalize_answer(b)).ratio() def score_qa(student_text: str, keywords: list[str], threshold: float 0.7): text normalize_answer(student_text) hit [kw for kw in keywords if normalize_answer(kw) in text] hit_rate len(hit) / max(len(keywords), 1) return { hit: hit, hit_rate: round(hit_rate, 2), need_review: hit_rate threshold, score_hint: round(hit_rate * 10, 1) # 假设该小题 10 分 }SequenceMatcher的ratio()返回 0 到 1 的相似度适合短句兜底score_qa里的keywords是人工整理的关键词列表比如“歌舞”“演故事”“王国维”“宋元戏曲考”。threshold0.7表示命中率低于 70% 就进复核队列score_hint只是参考分不直接写回总成绩。这样做的边界是同义表达仍可能漏判比如“表演故事”和“演故事”语义接近但字面不同所以关键词表要持续维护。| 题型 | 判分策略 | 关键参数 | 风险 | | 选择题 | 精确匹配 |per_score3| 多选需改集合比较 | | 问答题 | 关键词命中相似度 |threshold0.7| 同义表达漏判 | | 阅读理解 | 子题号分离后分别判 | 题号映射 | 原文题号可能重复 | | 表格题 | 保留原文人工判 |qa_raw| 正则拆表易丢结构 |4.3 错题归因与标签统计错题本如果只记录“第 3 题错了”对学生价值有限。把题干和选项过一遍标签规则可以统计出“计算机网络错 2 题”“计算机组成原理错 3 题”。下面这段规则表可以按需扩展TAG_RULES [ ((FTP, 协议, TCP/IP, 网址, 域名, HTTPS), 计算机网络), ((音箱, 输入设备, 输出设备, 扫描仪, 鼠标, 键盘), 计算机组成原理), ((二进制, 八进制, 十进制, 十六进制), 计算机组成原理), ((信息资源管理, 数据库, 手工管理, 文件管理), 信息资源管理), ((戏曲, 王国维, 宋元戏曲考), 人文常识), ] def infer_tags(stem: str, options: dict) - list[str]: text stem .join(options.values()) tags [tag for kws, tag in TAG_RULES if any(kw in text for kw in kws)] return tags or [通识] def wrong_stats(detail: list[dict], bank: dict): stats {} for item in detail: if item[ok]: continue q next((x for x in bank[choices] if x[no] item[no]), None) if not q: continue tags infer_tags(q[stem], q.get(options, {})) for tag in tags: stats[tag] stats.get(tag, 0) 1 return statsinfer_tags把题干和选项拼成一个文本逐条规则匹配关键词命中多个标签就都返回wrong_stats只统计错题按标签累加次数。这份卷子里 FTP、TCP/IP、网址域名会落到“计算机网络”音箱和输入输出设备会落到“计算机组成原理”信息资源管理三种方式会落到“信息资源管理”。如果要做计算机二级刷题产品这套标签可以直接复用只是规则表要换成对应考纲。注意标签是弱推断不要用标签结果反向改判卷结果二者分开存储。5. 从题库到组卷标签体系、随机抽题与导出 PDF 的进阶技巧题库落成 JSON 后最实用的进阶动作是“按标签随机组卷”和“导出可打印 PDF”。这份单招综合卷只有 20 道选择题抽整套不够用但把多年试卷合并后可以按“计算机网络 / 计算机组成原理 / 信息资源管理”标签抽题生成新的练习卷。抽题脚本要把随机种子暴露出来否则每次调参都不可复现import random, json def pick_questions(bank: dict, n: int 20, tag: str | None None, seed: int 2023): rng random.Random(seed) # 固定种子保证组卷可复现 pool [ q for q in bank[choices] if tag is None or tag in infer_tags(q[stem], q.get(options, {})) ] return rng.sample(pool, min(n, len(pool))) # 池子不够时返回全部 def to_markdown(qs: list[dict]) - str: lines [] for i, q in enumerate(qs, start1): lines.append(f{i}. {q[stem]}) for key in ABCD: if q[options].get(key): lines.append(f {key}. {q[options][key]}) lines.append() return \n.join(lines)pick_questions的seed固定后同一批题每次抽出的顺序一致方便比对不同标签策略tagNone表示不限标签n是抽题数量池子不够时min会返回全部。to_markdown输出纯文本卷面选项按 A、B、C、D 固定顺序不依赖字典插入顺序。导出 PDF 时常见做法是 Markdown 转 HTML 再交给 WeasyPrint或者直接用 ReportLab 注册中文字体from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas pdfmetrics.registerFont(TTFont(SimSun, simsun.ttc)) # 字体路径按系统实际位置改 def export_pdf(lines: list[str], out_path: str): c canvas.Canvas(out_path, pagesizeA4) c.setFont(SimSun, 11) y 800 for line in lines: if y 60: c.showPage() c.setFont(SimSun, 11) y 800 c.drawString(50, y, line) y - 18 c.save()registerFont的第二个参数是字体文件路径Linux 上可能要用/usr/share/fonts/...下的字体缺失时会抛TTFErrorcanvas.Canvas的pagesizeA4控制纸张y 60触发翻页drawString的坐标原点在左下角。验证方式抽 5 套卷用第 4 章评分脚本跑一遍标准答案选择题总分应为 60再随机改 3 个答案检查错题标签统计是否落在“计算机网络”和“计算机组成原理”。导出后用pdffonts确认中文字体已嵌入避免在机房电脑上显示成方框。本文还有配套的精品资源点击获取

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

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

免费获取报价