简介:面向备战中考的初三学生及语文教师,这份2021-2022学年上海市嘉定区名校中考语文模拟试卷合集,聚焦模拟考试场景下的题型适应与应试能力提升。内容围绕积累与运用、字音字形辨误、成语与病句修改、语言表达得体、古诗词理解与默写、名著阅读《骆驼祥子》、文言文对比阅读及诗词鉴赏等核心板块展开,并附有详细解析,便于对照答案梳理考点、定位薄弱环节。资源以单个doc文档封装,共1个文件,压缩包约2.64MB,内含19套试卷及解析,结构紧凑,适合按套练习或按专题检索。目前已有73人学习下载,可作为上海地区中考语文冲刺阶段的补充题库,也适合教师选编模拟题、学生自测与错题复盘使用。
1. 19套中考语文模拟考试卷,为什么比数理化卷子更难做成题库
不少人第一反应是:语文卷子全是文字,解析起来肯定比带公式的物理卷容易。实际情况正好反过来。物理题的题干和答案由符号、单位、数字构成,边界天然清晰;语文卷的题干本身就是一段阅读材料,答案里充斥着"赏析""表达效果""有什么作用"这类主观表述,切分点只能靠题号和行文习惯去猜。这份「2021-2022学年上海市嘉定区名校中考语文模试卷含解析【19套合集】.doc」的价值不在"19套"这个数量,而在于同区、同学年、同一批命题思路,六类题型反复出现:积累与运用、古诗文默写、诗词鉴赏、文言文比较阅读、现代文阅读、名著与写作。同一道题在不同套卷里换汤不换药,做纵向比对时考点分布看得特别清楚。适合三类人:要给学生做专项训练的一线教师、正在做教育类工具的开发者、想拿真实文档验证解析链路的工程师。下面的拆解按「格式判定 → 结构切分 → 建库检索 → 去重校验」四步走。
2. .doc 到结构化文本:格式判定、批量转换与编码处理
拿到手的是.doc,但它未必是老版二进制格式。文件名后缀可以随手改,Word 另存为、网盘转存、压缩工具解包,任何一环都可能让后缀和实际内容对不上。直接丢给python-docx会抛PackageNotFoundError,然后你会花半小时怀疑自己的环境装错了。第一步永远是验明正身,而不是先写解析代码。
2.1 用文件头判定真实格式
老版 Word 文档是 OLE2 复合文档,头 8 字节固定为D0 CF 11 E0 A1 B1 1A E1;docx 是 zip 容器,头 4 字节PK\x03\x04;少数文件实际是 RTF 文本。只读前 8 字节做嗅探,比整体读入内存快几个数量级,19 套卷子一扫而过。
# sniff_office.py —— 按文件头判定真实格式,不信任后缀 from pathlib import Path SIGNATURES = { b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1": "ole2", # 老版 .doc/.xls/.ppt b"PK\x03\x04": "zip", # docx/xlsx/odt b"{\\rtf": "rtf", # 富文本 b"%PDF": "pdf", # 误混入的 PDF } def sniff(path: Path) -> str: with path.open("rb") as f: head = f.read(8) for sig, name in SIGNATURES.items(): if head.startswith(sig): return name return "unknown" if __name__ == "__main__": for p in sorted(Path("raw").rglob("*.doc")): # rglob 会进子目录 print(f"{sniff(p):8s} {p}")逻辑说明:startswith而不是==,因为 zip 后面还有版本字节。参数说明:目录按区县或年份分了子目录时用rglob,平铺结构用glob就够;read(8)这个数字别改成read(),19 个文件无所谓,上千份时差异明显。
| 嗅探结果 | 实际格式 | 处理方式 |
|---|---|---|
ole2 | Word 97-2003 | 必须先用 LibreOffice 转换 |
zip | docx / odt | 直接上python-docx |
rtf | 富文本 | striprtf抽纯文本,或一并交给 LibreOffice |
pdf | 走pdfplumber,注意双栏排版会串行 | |
unknown | 未知或损坏 | 单独归档,别混进主流程 |
2.2 LibreOffice 无头模式批量转换
手工另存为 19 次没有意义。soffice --headless --convert-to是这条链路里最稳的一段,坑集中在一个地方:同一台机器上多个转换进程抢同一个用户配置目录,第二个进程会静默失败,输出目录里少几个文件,你还以为是源文件坏了。
#!/usr/bin/env bash # convert_all.sh —— 批量转 docx,每个进程独立 profile 避免互锁 set -euo pipefail SRC="raw"; OUT="docx"; LOG="convert.log" mkdir -p "$OUT" find "$SRC" -name '*.doc' -print0 | while IFS= read -r -d '' f; do base=$(basename "$f" .doc) # -env:UserInstallation 是关键参数,profile 目录必须唯一 timeout 120 soffice --headless --norestore \ -env:UserInstallation="file:///tmp/lo_$$_${base}" \ --convert-to docx:"MS Word 2007 XML" \ --outdir "$OUT" "$f" >>"$LOG" 2>&1 \ && echo "OK $base" \ || echo "FAIL $base" done逻辑说明:--convert-to docx:"MS Word 2007 XML"显式指定过滤器,避免转换器按默认目标格式猜错。timeout 120兜住个别排版复杂的卷子卡死的情况。--norestore关掉崩溃恢复弹窗,无头模式下这个弹窗会挂住进程。参数说明:file:///tmp/lo_$$_${base}里的$$是 shell 进程号,保证并发时 profile 不撞车。转换完执行一次ls docx | wc -l与源文件数量对账,数量对不上就翻convert.log。
提示:如果卷子里有大量公式域或者嵌入的图片文本框,转换后段落顺序可能错乱,这类文件单独挑出来人工看一眼。
2.3 清洗:全角字符、不可见空白与私用区字符
转换出来的文本里混着不少"看着一样、比较起来不等"的字符。中文标点全角是常态,但题号后面的间隔符可能被 Word 自动替换成半角点、全角点或者顿号,三种形态都出现过。搜索替换时用一条正则覆盖全部变体,比逐字符判断省事。
import re, unicodedata CLEAN = [ (re.compile(r"[\u00a0\u200b\u200c\u200d\ufeff]"), ""), # NBSP/零宽字符 (re.compile(r"[\ue000-\uf8ff]"), ""), # 私用区残留符号 (re.compile(r"[ \t\u3000]+"), " "), # 连续空白归一 (re.compile(r"\n{3,}"), "\n\n"), ] def clean(text: str) -> str: text = unicodedata.normalize("NFKC", text) # 全角字母数字转半角 for pat, rep in CLEAN: text = pat.sub(rep, text) return text.strip()逻辑说明:NFKC归一化会把全角字母、全角数字统一成半角,但不会动中文标点,这一点正合需要——题号里的.要保留到下一章的状态机里去判断。参数说明:私用区\ue000-\uf8ff常常是 Word 的自动编号符号或特殊项目符号,留着重会影响题号正则的匹配。注意NFKC也会把㈠这类字符展开成(一),如果卷子里真有带圈序号,得在归一化之前单独抽出来。
3. 用 python-docx 拆出题干、选项、答案与解析
转换后的 docx 是可解析的 XML,段落顺序基本等于阅读顺序。真正的麻烦在于:卷面结构靠视觉层级区分(大题加粗居中,小题左缩进),而 XML 里这些信息退化成了"都在段落里"。所以只能靠文本里的编号模式反推层级,这就是状态机要做的事。
3.1 段落流里恰好保留了题号
先看这份卷子的实际排版特征:大题是一、积累与运用,小题是1.下列句子中加点词语使用不正确的一项是( )。,选项是A.伊丽莎白落落大方……。三层的编号模式互不冲突,用三条正则就能区分。但有个陷阱:阅读理解的材料正文里也常出现"1."这种数字加点的写法(年份、序号、法条),所以选择题号时要加长度和上下文限制——题干段落通常不超过 80 字,且后面跟着提问词。
# parse_paper.py —— 按题号状态机切分单个 docx import re from docx import Document SECTION_RE = re.compile(r"^([一二三四五六七八九十]+)\s*[、..]\s*(\S.{0,15})$") QNO_RE = re.compile(r"^(\d{1,2})\s*[、..]\s*(.+)$") OPT_RE = re.compile(r"^([A-D])\s*[、..]\s*(.+)$") def parse_docx(path): doc = Document(path) sections, cur_sec, cur_q = [], None, None for para in doc.paragraphs: text = para.text.strip() if not text: continue sec = SECTION_RE.match(text) if sec: # 大题行,长度受控 cur_sec = {"name": sec.group(2), "questions": []} sections.append(cur_sec) cur_q = None continue q = QNO_RE.match(text) if q and cur_sec is not None: cur_q = {"no": int(q.group(1)), "stem": q.group(2), "options": [], "answer": "", "analysis": ""} cur_sec["questions"].append(cur_q) continue opt = OPT_RE.match(text) if opt and cur_q is not None and len(text) < 200: cur_q["options"].append(f"{opt.group(1)}.{opt.group(2)}") continue if cur_q is not None: # 续行,粘回题干 cur_q["stem"] += text return sections if __name__ == "__main__": for sec in parse_docx("docx/2021JD-03.docx"): print(sec["name"], len(sec["questions"]))逻辑说明:SECTION_RE里的\S.{0,15}是个廉价但有效的长度闸门,避免把正文里自然段的"一、二、三"误判成新大题。OPT_RE后面加了len(text) < 200,是因为选项一般短,而正文里以 A. 开头的英文段落会被误吞。参数说明:QNO_RE里的[、..]同时覆盖顿号、半角点、全角点三种写法,缺一种就会漏题——这份卷子用的是全角点.,换个区县可能就变成顿号。
| 层级 | 真实样例 | 正则 | 常见误判来源 |
|---|---|---|---|
| 大题 | 一、积累与运用 | ^[一二三…]+[、..] | 文言文材料里的序数词 |
| 小题 | 5.下列加点的成语… | ^\d{1,2}[、..] | 阅读材料里的年份、地名编号 |
| 选项 | A.从铁路梦、大桥梦… | ^[A-D][、..] | 英文缩写、字母编号小标题 |
| 答案 | 1、B【解析】… | 见 3.3 | 答案与解析挤在同一段 |
3.2 加点的字、下划线这类格式信息会丢
第 1、2、5 题都是"下列句子中加点词语使用不正确的一项是","加点"在 Word 里是着重号或者字符底纹,属于 run 级属性。只取para.text得到的是纯文本,加点位置全部消失,题目就废了——学生看不到哪个词被加点,老师也没法据此生成答案解释。
常见做法是遍历paragraph.runs,检查run.font上的强调属性,把带格式的片段用标记包起来。
from docx import Document def mark_emphasis(para): """把带着重号/下划线/加粗的片段用 [[ ]] 包起来,保留考点位置""" out = [] for run in para.runs: t = run.text if not t: continue f = run.font hit = (f.emphasis is not None) or bool(f.underline) or bool(f.bold) out.append(f"[[{t}]]" if hit else t) return "".join(out)逻辑说明:f.emphasis对应 Character Emphasis Mark,中文排版里的"着重号"就存在这里,值可能是None,所以用is not None判断而不是布尔判断。参数说明:f.underline在 docx 里是个三态值,普通下划线返回True,没有下划线返回None,bool()转换后行为正确。这套标记会污染纯文本检索,所以入库时建议拆成两列:stem存干净文本,stem_marked存带标记版本。
注意:部分转换后的文件会把着重号转成字符底纹或者颜色,
emphasis抓不到,得加一层run.font.highlight_color的兜底判断。
3.3 从文末参考答案里对齐答案与解析
这份卷子把答案和解析集中放在文末,格式是参考答案一、积累与运用1、B【解析】……2、C【解析】……——注意,多道题的答案挤在同一个段落里,按段落切是不行的,必须按题号再切一次。而且答案是"1、B"这种压缩写法,不是每题一段。这是整条链路里最容易出错的一段。
做法是先按大题关键字把答案区粗切成块,再在块内用题号做前瞻分割。
import re # 答案区特征:以"参考答案"起头,之后单行内串联多条答案 ANS_BLOCK = re.compile(r"参考答案(.*)", re.S) # 题号后跟任意字符,直到下一个题号或字符串结束 ANS_ITEM = re.compile(r"(\d{1,2})[、..](.*?)(?=\d{1,2}[、..]|$)", re.S) PARSE_SPLIT = re.compile(r"【解析】(.*)$", re.S) def parse_answers(raw: str) -> dict: m = ANS_BLOCK.search(raw) if not m: return {} body = m.group(1) result = {} for no, chunk in ANS_ITEM.findall(body): chunk = chunk.strip() ans, ana = chunk, "" sp = PARSE_SPLIT.search(chunk) if sp: ans = chunk[:sp.start()].strip() ana = sp.group(1).strip() result[int(no)] = {"answer": ans[:20], "analysis": ana} return result逻辑说明:前瞻断言(?=\d{1,2}[、..]|$)保证切分点落在下一个题号之前,但不消费它,避免漏掉相邻题。result[int(no)]用字典按题号对齐,所以即使答案区里题号缺号或者重号,也不会错位到别的题上——这一点比按顺序 zip 更安全。参数说明:ans[:20]截断是因为有些题号后面紧跟着一长串解析文字,没有【解析】标记时可读性差,截断后人工复审一眼就能发现。对齐完成后跑一次一致性检查:题干以"下列……不正确"结尾的选择题,答案必须是单个字母;答案区里答非所问的,八成是切分点漂移了。
4. SQLite FTS5 加 jieba 建本地题库存检索
19 套卷子手工翻当然能看,但"把所有考病句的题挑出来"这种需求,翻起来就是折磨。语料规模不大(19 套 × 20 多题 = 500 题上下),SQLite 完全够用,装上就能跑,不用起服务。
4.1 表结构:正文一列、检索一列
原始文本和分词文本要分开存。分词结果是给人看不见的中间产物,混进stem会让导出试卷时出现"下列 句子 中 加点 词语"这种断词。用一个 JSON 字段存选项,比再开一张表简单得多。
-- schema.sql CREATE TABLE questions ( id INTEGER PRIMARY KEY, set_id TEXT NOT NULL, -- 套卷编号,如 2021JD-03 section TEXT NOT NULL, -- 大题名:积累与运用/阅读/写作 qno INTEGER NOT NULL, stem TEXT NOT NULL, options TEXT, -- JSON 数组 answer TEXT, analysis TEXT, tags TEXT, -- 逗号分隔考点标签 UNIQUE(set_id, section, qno) ); -- 独立 FTS5 表,rowid 与 questions.id 对齐 CREATE VIRTUAL TABLE questions_fts USING fts5( stem_seg, analysis_seg, tags_seg, tokenize = 'unicode61' );逻辑说明:UNIQUE(set_id, section, qno)让重复导入变成INSERT OR IGNORE直接跳过,不用写去重逻辑。FTS5 用独立表而不是content=外部内容表,是因为要写入的是 jieba 分词后的文本,跟主表内容不一致,外部内容表会要求两者严格对应,反而麻烦。参数说明:tokenize = 'unicode61'对中文是按 Unicode 字符类别切的,切出来是单字,配合预分词空白分隔,检索就变成了词组级。
| 字段 | 来源 | 是否入索引 | 说明 |
|---|---|---|---|
stem | 状态机输出 | 否 | 导出、展示用原始文本 |
stem_seg | jieba 分词 | 是 | 空格分隔,供 FTS5 使用 |
analysis | 文末答案区 | 否 | 主观题解析较长,单独展示 |
tags | 关键词映射 | 是 | 考点标签,见 4.3 |
4.2 预分词写入与 MATCH 查询
jieba 的默认词典对语文教学场景缺词,"文言实词""语言运用""名著阅读"这些会被切散,所以先补自定义词典,再统一分词入库。
import json, sqlite3, jieba jieba.load_userdict("dict_yuwen.txt") # 每行一词:病句 / 字音字形 / 诗词鉴赏 … conn = sqlite3.connect("yuwen.db") def seg(text: str) -> str: return " ".join(w for w in jieba.lcut(text or "") if w.strip()) for qid, stem, analysis, tags in conn.execute( "SELECT id, stem, IFNULL(analysis,''), IFNULL(tags,'') FROM questions"): conn.execute( "INSERT INTO questions_fts(rowid, stem_seg, analysis_seg, tags_seg) " "VALUES (?,?,?,?)", (qid, seg(stem), seg(analysis), seg(tags))) conn.commit()逻辑说明:rowid显式绑定questions.id,后面 JOIN 时直接用。seg()里过滤空白,避免连续空格被 FTS5 当成空词项。参数说明:dict_yuwen.txt一行为一个词,按学科术语写就行;如果不补词典,语言运用会被切成语言/运用,用MATCH '语言运用'查的时候就漏了。
查询时注意 FTS5 的MATCH语法:OR必须大写,()用来分组,*做前缀匹配。查"病句、成分残缺、搭配不当"这类同族考点:
SELECT q.set_id, q.qno, q.section, snippet(questions_fts, 0, '[', ']', '…', 12) AS hint FROM questions_fts f JOIN questions q ON q.id = f.rowid WHERE questions_fts MATCH '病句 OR 成分残缺 OR 搭配不当' ORDER BY rank LIMIT 20;逻辑说明:snippet()的第 2 个参数是列序号,0 对应stem_seg,高亮标记返回文本位置以便前端渲染。ORDER BY rank用 FTS5 内置的相关性排序,比按 id 排更符合"最相关先出"的直觉。参数说明:LIMIT 20在题量 500 左右的场景下够用,如果直接把 LIMIT 设得很大,snippet的重复计算会明显变慢。
4.3 按考点打标签,让检索变成筛选题
关键词检索能命中,但不好用——用户想找的是"所有考字音的题",不是"包含'读音'两个字的题"。所以要在入库后跑一遍标签映射,把题干特征翻译成考点名,写进tags字段。
| 考点标签 | 触发词(正则) | 典型来源小题 |
|---|---|---|
| 字音 | 加点字读音|读音全部正确 | 第 2 题 |
| 字形 | 词语书写完全正确|没有错别字 | 第 4 题 |
| 成语运用 | 成语使用|加点成语 | 第 5 题 |
| 病句 | 病句|修改意见 | 第 6 题 |
| 名句默写 | 默写|____ | 第 7 题 |
| 名著阅读 | 名著|《.*》加作者关键词 | 第 8 题 |
| 诗词鉴赏 | 诗歌|赏析.*字.*妙 | 第 9 题 |
| 文言实词 | 解释下列加点词 | 第 10 题 |
标签命中后,检索路径从"关键词匹配"升级为"考点筛选 + 关键词排序",做专项训练卷时就按标签聚合,再随机抽题。
5. 相似题去重与组卷抽样校验
19 套同区同年的卷子,重合度比想象中高。第 9 题李益《诣红楼院寻广宣不遇留题》和第 10 题文言文比较阅读,在不同套卷里很可能只在选项顺序上做了微调。要组一份没有重复题的训练卷,得先能识别出"同一道题"。
5.1 用 SimHash 找近重复题
题干整段做哈希对短文本太敏感,一个字不同就完全不同。SimHash 把文本映射成 64 位指纹,再用汉明距离衡量相似度,对换词、调序这类改动宽容度较高,实现也就二十行。
import hashlib, jieba def simhash(text: str, bits: int = 64) -> int: weights = [0] * bits for word in jieba.lcut(text): h = int(hashlib.md5(word.encode("utf-8")).hexdigest(), 16) for i in range(bits): weights[i] += 1 if (h >> i) & 1 else -1 return sum(1 << i for i in range(bits) if weights[i] > 0) def hamming(a: int, b: int) -> int: return bin(a ^ b).count("1") # 汉明距离 <= 3 视为疑似同题,落库后人工复核逻辑说明:加权累加时每出现一次词就 +1 或 -1,最终正位记 1、负位记 0,所以高频词对指纹影响更大,符合近重复检测的直觉。参数说明:阈值取 3 是经验值,题量小可以调紧到 2 减少误报,卷面文字越长(如整篇阅读材料)越应该放宽。注意 SimHash 对"选项顺序调换"不敏感,但对"换了一道阅读材料、题型相同"会判为不同,这正好符合需求——同题型不算重复题。
提示:去重前先把题干里的年份、学校名、"2021-2022学年"这类元信息剥掉,否则时间信息会拉低相似度。
5.2 抽 10 题回原文比对,把错位的答案拦在入库前
自动解析最怕的不是报错,是静默错位——第 7 题解析挂到了第 8 题下面,卷子看上去正常,答案对不上。抽样校验要固定查这几项:
| 校验项 | 检查方法 | 判定为失败的表现 |
|---|---|---|
| 题号连续性 | 每个大题内qno是否递增无缺 | 出现 6 后面直接是 8 |
| 选项完整性 | 选择题options长度为 4 | 只有 3 个或 5 个 |
| 答案与题型匹配 | 选择题答案是否落在 A-D | 答案是"家长要理解孩子" |
| 解析归属 | 解析里是否复述了题干关键词 | 解析讲的是另一道题的内容 |
| 套卷题量 | 每套题数是否在合理区间 | 某套只有 3 题 |
抽样时不要只抽前几题——积累与运用部分的题号规整,出错率低;真正容易出问题的是文言文 перевод 和阅读理解后面那些跨段落的长题干。从每套卷的第 9 题往后各抽一题,10 分钟左右能覆盖全部套卷,比全量人工校对划算得多。
本文还有配套的精品资源,点击获取