简介: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, start=1): # extract_text 控制同一行字符合并容忍度 text = page.extract_text(x_tolerance=2, y_tolerance=3) # extract_words 保留坐标,用于判断选项是否同行 words = page.extract_words( use_text_flow=False, keep_blank_chars=False, 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_flow=False表示按坐标而不是 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_type=0是文本,1是图片。答案页如果有截图,就靠这个字段发现:
import fitz doc = fitz.open(pdf_path) for pno in range(len(doc)): page = doc[pno] blocks = page.get_text("blocks") blocks.sort(key=lambda 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_type=1,说明答案是图片或公式截图,需要单独导出该区域再做 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~5:BBCAC、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区分 | | 全角半角 | A.B.C. |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", encoding="utf-8") as f: full = f.read() bank = build_bank(full) with open("bank.json", "w", encoding="utf-8") as f: json.dump(bank, f, ensure_ascii=False, indent=2)build_bank把选择题固定为 20 题,每题 3 分,choice_std里没有答案的题号会留空,方便后面报警。问答题第 6、7 题标记为qa_raw,因为表格和列举题一旦被正则拆坏,恢复成本比保留原文高。输出 JSON 用ensure_ascii=False保留中文,indent=2方便人工抽查。运行前需要先把 PDF 抽出的文本存成paper_text.txt,这一步可以用第二章的 pdfplumber 脚本完成。
4. 自动判卷与错题归因:答案映射、模糊匹配和评分脚本
题库 JSON 建好后,判卷本身不复杂,难的是答案归一化和错题归因。这份卷子选择题 20 题每题 3 分,共 60 分;问答题 30 分;阅读理解 30 分。如果只做选择题判分,一个精确匹配函数就够;如果要把问答题也纳入,必须引入模糊匹配和人工复核,不然同义表达会被判错。
4.1 标准答案归一化与选择题评分
学生答案可能来自答题卡识别、手动输入或网页表单,常见问题有全角字母、空格、小写。先归一化再比较:
NORMALIZE_MAP = str.maketrans({ "A": "A", "B": "B", "C": "C", "D": "D", "a": "a", "b": "b", "c": "c", "d": "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是人工整理的关键词列表,比如“歌舞”“演故事”“王国维”“宋元戏曲考”。threshold=0.7表示命中率低于 70% 就进复核队列;score_hint只是参考分,不直接写回总成绩。这样做的边界是:同义表达仍可能漏判,比如“表演故事”和“演故事”语义接近但字面不同,所以关键词表要持续维护。
| 题型 | 判分策略 | 关键参数 | 风险 | | 选择题 | 精确匹配 |per_score=3| 多选需改集合比较 | | 问答题 | 关键词命中+相似度 |threshold=0.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, start=1): 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固定后,同一批题每次抽出的顺序一致,方便比对不同标签策略;tag=None表示不限标签,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, pagesize=A4) 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/...下的字体,缺失时会抛TTFError;canvas.Canvas的pagesize=A4控制纸张,y < 60触发翻页,drawString的坐标原点在左下角。验证方式:抽 5 套卷,用第 4 章评分脚本跑一遍标准答案,选择题总分应为 60;再随机改 3 个答案,检查错题标签统计是否落在“计算机网络”和“计算机组成原理”。导出后用pdffonts确认中文字体已嵌入,避免在机房电脑上显示成方框。
本文还有配套的精品资源,点击获取