
简介《暨南大学无机化学复习题集》是一份面向高校化学专业低年级学生及考研复习者的PDF文档紧扣无机化学课程核心考点可用于期末备考、课堂同步练习与知识自测。文档以选择题为主覆盖化学反应速率与活化能、化学平衡与反应商、缓冲溶液、溶解度与溶度积常数、分子轨道理论、电极电势与平衡常数关系、氢键以及标准生成焓等高频章节题目设置贴近课程重点与常见易错点。整包仅含1个PDF文件约375KB轻量易携带支持打印与标注适合反复刷题与错题整理。目前已有167人学习下载可作为无机化学基础概念的快速复盘材料帮助读者在有限时间内串联零散知识点、定位薄弱环节。1. 从《无机化学复习题集》PDF 到可检索题库卡点在哪期末前后很多人手上会多一份「暨南大学无机化学复习题集-0615.pdf」这样的文件几百道选择、填空、判断和计算题挤在几十页里文件名后缀的日期说明它只是某一版。用阅读器翻没问题一旦想按知识点筛题就卡住了——CtrlF 搜H2O搜不到写成H₂O的那批搜「配位」又会把一堆化合物名称一起带出来题号和 A/B/C/D 选项复制进 Word 常串行参考答案在文末还得手动对号。更麻烦的是 0615 之后还会有新版本手工整理的表格撑不过一次更新。要打通的是这么一条链路判版式、抽文本、切题、归一化化学式、入库、建检索、按章节组卷最后一环是版本更新时不推倒重来。适合自己搭题库的助教、做教学工具的前后端也适合任何需要把 PDF 试卷转成结构化数据的人。2. 解析《无机化学复习题集》PDF先判版式再选抽取工具2.1 用字符数和图片数判断复习题集是文本层还是扫描件复习题集一般有两个来源用排版软件写好直接导出PDF 里带完整文本层或者纸质印刷品扫描后合成里面的字全是像素。这两条路差别很大前者十几分钟能抽干净后者得先 OCR还要接受上下标识别出错、再人工校对的成本。动手写解析脚本前先花一分钟做判断。判据是「每页能抽出多少字符」配合「每页有几张图」。带文本层的页面通常有几百到几千字符扫描件的字符数接近于零但图片数稳定在一到两张。# probe_layout.py # 统计每页可抽取字符数、图片数和页面尺寸, 用于判断 PDF 类型 import fitz # PyMuPDF, pip install pymupdf doc fitz.open(暨南大学无机化学复习题集-0615.pdf) for i, page in enumerate(doc): text page.get_text(text) # 纯文本模式, 速度最快 images page.get_images(fullTrue) # fullTrue 返回图片的完整元信息 w, h page.rect.width, page.rect.height print(fpage{i:03d} chars{len(text):5d} images{len(images):2d} size{int(w)}x{int(h)})chars长期低于 50、images稳定为 1 的按扫描件处理chars在 800 以上、图片偶尔出现的说明正文有文本层插图是装置图或结构式可以跳过 OCR。顺带看一眼size如果长宽比接近 1:1.41 而宽度只有 400 多说明这份 PDF 被缩放或重排过后面按坐标切栏的阈值要跟着调。提示文件名里的 0615 这类日期后缀建议在脚本里单独解析成file_version字段存下来做增量比对时它就是版本号不用再猜。2.2 pdfplumber 抽带坐标的词保住题号和选项边界纯文本模式抽出来是一整块字符串题号、A.、空格和换行糊在一起靠正则切题会非常痛苦。要保住版面信息用 pdfplumber 的extract_words每个词都带x0/top/x1/bottom坐标可以按行、按栏还原阅读顺序。# dump_words.py # 抽取第 11 页的每个词及其坐标和字体属性, 用于摸清题干与选项的版式差异 import pdfplumber with pdfplumber.open(暨南大学无机化学复习题集-0615.pdf) as pdf: page pdf.pages[10] # 页码从 0 开始 words page.extract_words( keep_blank_charsFalse, # 空格不单独成词, 减少噪声 use_text_flowFalse, # 按页面坐标排序, 而不是 PDF 内部内容流顺序 extra_attrs[size, fontname], # 额外带上字号和字体, 用于识别题干层级 ) for w in words[:40]: print(f{w[top]:7.1f} {w[x0]:7.1f} {w[size]:5.1f} {w[fontname][:18]:18s} {w[text]})use_text_flowFalse是关键双栏排版下它不会把左右两栏串在一起。extra_attrs里的字号和字体名用来区分层级——题干常比选项大一号或加粗节标题又比题干大据此刻画「这是节标题 / 这是题干 / 这是选项」的规则比只靠正则稳得多。常见参数与取值参数作用建议取值踩坑点keep_blank_chars空格是否单独成词False设True会多出大量空格 token聚类时还要再过滤use_text_flow是否按内容流顺序False双栏文档设True会左右串行extra_attrs附加字体属性[size,fontname]属性名写错不报错只是取不到值x_tolerance词间水平合并阈值默认 3双栏调 2调大会把相邻两个选项粘成一个词y_tolerance词间垂直合并阈值默认 3上标数字可能被并进上一行2.3 按 top 坐标聚类成行清掉页眉页脚再谈切题拿到词列表后先把同一行的词聚起来否则A. H2O B. CO2这种一行两个选项的排版会被拆散。做法是按top排序差值小于阈值的并进同一行。# lines.py # 把词按纵向坐标聚成行, 并按 x0 排序, 还原阅读顺序 def group_lines(words, y_tol3.0): rows, cur [], [] for w in sorted(words, keylambda w: (round(w[top], 1), w[x0])): if cur and abs(w[top] - cur[-1][top]) y_tol: rows.append(sorted(cur, keylambda x: x[x0])) cur [] cur.append(w) if cur: rows.append(sorted(cur, keylambda x: x[x0])) return [ .join(w[text] for w in row) for row in rows] # 页眉页脚过滤: 位置靠边且含固定关键词的行直接丢掉 def is_noise(line, top, page_height, keywords(暨南大学, 无机化学, 第, 页)): near_edge top 60 or top page_height - 60 return near_edge and any(k in line for k in keywords)y_tol取 3.0 是经验值太小会把同一行拆成两行字号混排的选项最容易中招太大则会把相邻两行合并。判断标准很直接——聚类后的行数应该和你在阅读器里看到的行数差不多。页脚过滤不能只看关键词必须叠加位置条件。因为「无机化学」四个字在正文里也会出现只判关键词会把整段正文删掉。near_edge把范围压到上下各 60 像素基本只命中页眉页脚。清完之后剩下的行序列才是切题的输入。2.4 扫描件兜底OCR 只做局部化学式交给归一化补探测结果显示是扫描件时就得走 OCR。但整页识别对这份材料并不友好下标数字容易丢离子电荷的正负号经常混结构式根本无法用文字表达。务实的做法是「文字区域 OCR 结构图保留原图」。# 把第 12 页按 300dpi 转成 PNG, 再交给 tesseract 做中文英文识别 pdftoppm -r 300 -png -f 12 -l 12 暨南大学无机化学复习题集-0615.pdf page tesseract page-12.png out -l chi_simeng --psm 6-r 300是 OCR 的下限分辨率低于 200 时下标数字基本识别不出来。--psm 6表示「假设页面是一整块统一排版的文本」适合题干连排的页分栏页改成--psm 3让它自动分析版式。-l chi_simeng同时加载中英文模型因为题干是中英混排加化学符号只加载中文模型会把NaCl认成汉字。OCR 结果仍要跑一遍第 3 章的归一化流程再人工抽检十到二十页。如果错误率超过 5%与其花时间调参数不如换回文本层版本或者重新扫描。3. 切题与归一化把《无机化学复习题集》切成可入库的记录3.1 先用节标题和题型锚点摸清结构复习题集的层级通常很规整按章节或专题分块块内按「一、选择题 二、填空题 三、判断题 四、简答题 五、计算题」排列。所以切题分两层——先按节标题切块再在块内按题号切题。锚点特征可以整理成这样锚点类型行首特征落到哪个字段注意章节块第一章、一、选择题chapter/qtype中文数字和阿拉伯数字混用要归一题干1.12、3qno/stem_raw全角点和半角点都要覆盖选项A.B、C)options_json同一行可能有两个选项答案区参考答案、答案与解析answer/analysis常在文末需要按题号回填import re SECTION re.compile(r^\s*(?:第[一二三四五六七八九十][章节]|[一二三四五六七八九十])[、\.]\s*(\S{1,12}?题)) NUMBER re.compile(r^\s*(\d{1,3})\s*[\.、,]\s*(?\S)) OPTION re.compile(r^\s*([A-D])\s*[\.、\)]\s*(?\S))NUMBER末尾的(?\S)是前瞻断言作用是排除「1.5 mol」这类以数字开头的小数——它们后面跟的是空白或其他符号不满足「点号后紧跟非空白字符」的条件不会被误判成题号。3.2 用状态机切题从题号行开始到下一个题号或节标题结束有了锚点就能写一个简单的状态机。它维护「当前题型」和「当前题目」两个状态遇到节标题更新题型遇到题号就收束上一题、开启新题。# split_questions.py # 状态机切题: 节标题换 qtype, 题号行开始新题, 其余行归入当前题的题干或选项 def split_questions(lines): records, cur, qtype [], None, None for ln in lines: sec SECTION.match(ln) if sec: if cur: records.append(cur); cur None qtype sec.group(1) # 例如 选择题 填空题 continue num NUMBER.match(ln) if num: if cur: records.append(cur) cur {qtype: qtype, qno: int(num.group(1)), stem: ln[num.end():].strip(), options: []} continue opt OPTION.match(ln) if opt and cur is not None: cur[options].append(ln.strip()) # 选项整行保留, 后面再解析 elif cur is not None: cur[stem] ln.strip() # 跨页续行归上一题 if cur: records.append(cur) return records跨页是这里最容易出问题的地方。上一题的最后一行和下一题的第一行之间如果夹着页眉页脚续行会被错误地拼进题干所以lines.py里的噪声过滤必须在切题之前跑完。另外题号常常每章从 1 重排qno不能当唯一键必须用(chapter, qtype, qno)三元组。3.3 化学式与上下标归一化raw 和 norm 各留一份同一道题在 PDF 里可能是H₂O抽取后变成H2O或H 2 OOCR 出来的又可能是H₂0下标 0 被认成字母 O。检索列必须统一写法但展示时用户想看原样所以两份都留。原始形态归一化结果处理方式H₂O、Fe²⁺H2O、Fe2上下标字符映射到 ASCIIH 2 O、Fe 3H2O、Fe3删除元素符号与数字间的空格NaClNFKC 全角转半角[Cu(NH₃)₄]²⁺[Cu(NH3)4]2括号统一半角后整体归一import re, unicodedata SUB str.maketrans(₀₁₂₃₄₅₆₇₈₉, 0123456789) SUP str.maketrans(⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻, 0123456789-) def normalize(text: str) - str: t unicodedata.normalize(NFKC, text) # 全角转半角, 兼容字符统一 t t.translate(SUB).translate(SUP) t t.replace(, ().replace(, )) t re.sub(r(?[A-Za-z\)\]])\s(?\d), , t) # 化学式里多余空格: Fe 3 - Fe3 t re.sub(r\s, , t).strip() return t有一条红线不要做大小写折叠。CO是一氧化碳Co是钴Hf和HF也完全不同。归一化只处理编码、空格和括号字母大小写原样保留。3.4 合并参考答案并落库到 SQLite答案区通常在文末格式是题号加答案判断题还会写「对/错」。用正则扫一遍按(chapter, qno)回填。# merge_answers.py answer_pat re.compile(r(\d{1,3})\s*[\.、]\s*([A-D]|对|错|正确|错误)) answers {int(m.group(1)): m.group(2) for m in answer_pat.finditer(answer_text)} for rec in records: rec[answer] answers.get(rec[qno]) # 找不到就是 None, 后面用自检脚本暴露 rec[stem_norm] normalize(rec[stem])建表时把唯一索引压在三元组上脚本重复跑也不会写重CREATE TABLE questions ( id INTEGER PRIMARY KEY, file_version TEXT NOT NULL, -- 来自文件名, 例如 0615 chapter TEXT, qtype TEXT CHECK(qtype IN (选择题,填空题,判断题,简答题,计算题)), qno INTEGER, stem_raw TEXT, -- 展示用, 保留原始写法 stem_norm TEXT, -- 检索用, 已归一化 options_json TEXT, -- json.dumps(options) answer TEXT, analysis TEXT, source_page INTEGER, fp TEXT -- 题目指纹, 用于版本比对 ); CREATE UNIQUE INDEX idx_q ON questions(file_version, chapter, qtype, qno);file_version放进唯一索引有两个好处不同版本的同一道题可以共存方便对比同一版本重复导入直接触发冲突改成INSERT OR REPLACE就变成幂等操作。options_json用 JSON 字符串存避免为选项单独建表查询时在应用层json.loads。4. 检索与组卷让《无机化学复习题集》按知识点调得出来4.1 SQLite FTS5 建全文索引中文按字切也够用题干和解析加起来通常只有几万字没必要上独立搜索引擎SQLite 的 FTS5 足够。CREATE VIRTUAL TABLE q_fts USING fts5( stem_norm, answer, analysis, contentquestions, content_rowidid, tokenizeunicode61 remove_diacritics 2 ); INSERT INTO q_fts(rowid, stem_norm, answer, analysis) SELECT id, stem_norm, answer, analysis FROM questions; -- 查配位相关的单选, 按相关度排序并返回高亮片段 SELECT q.id, q.chapter, q.qtype, snippet(q_fts, 0, [, ], …, 12) AS hit, bm25(q_fts) AS score FROM q_fts JOIN questions q ON q.id q_fts.rowid WHERE q_fts MATCH 配位 OR 络合 AND q.qtype 选择题 ORDER BY score LIMIT 20;snippet(表, 列号, 前缀, 后缀, 省略号, 片段长度)里的列号 0 对应stem_norm返回的片段会带上下文直接拿去做搜索结果的预览。bm25()返回的分数越小越相关排序方向别写反。unicode61对中文是按字切分的搜「配位」等于「配」和「位」两个字同时命中召回比英文分词松但对两三个字的中文短查询反而正好。SQLite 3.34 以上可以用tokenizetrigram支持任意子串匹配代价是索引体积涨几倍、查询变慢题库规模在一万题以内可以接受。查询写法命中效果适用场景MATCH 配位含「配」和「位」的题全出宽召回先看大概分布MATCH 配位化合物精确短语定位专有名词MATCH 配位 NOT 命名排除命名题缩小范围stem_norm LIKE %Cu2%走全表扫描题量小、条件复杂时的兜底4.2 化学式查询直接 LIKE 会误伤先加边界再匹配LIKE %H2O%会把H2O2里的片段也捞出来搜CO2会命中CO2-、Na2CO3。简单有效的对策是在入库时把化学式识别出来前后补空格做词边界。import re # 匹配由两个以上元素符号加可选系数组成的式子, 末尾允许带电荷 CHEM re.compile(r(?:[\(\[]?[A-Z][a-z]?\d*[\)\]]?){2,}(?:\d*[-])?) def mark_chem(text: str) - str: return CHEM.sub(lambda m: f {m.group(0)} , text)sub里前后加空格的作用是给 FTS 的分词器一个断点这样H2O独立成一个 token不会和H2O2混在一起。注意这条规则用起来有误伤——普通英文单词里连续的大写字母组合也可能被当成化学式水合物写法CuSO4·5H2O里的点号也需要额外处理。所以只把mark_chem的结果写进stem_norm展示和导出时一律用stem_raw误伤的代价仅仅是某次搜索高亮位置略偏。4.3 按章节和题型配比随机组卷组卷本质上是一次带约束的分层抽样。约束有三个每章出多少题、题型怎么配、总题量多少。import sqlite3, random def build_paper(db, plan, version, seedNone): plan: [{chapter: 第三章, qtype: 选择题, n: 10}, ...] rng random.Random(seed) # 固定 seed 保证同一份卷子可复现 conn sqlite3.connect(db) paper [] for item in plan: rows conn.execute( SELECT id, stem_raw FROM questions WHERE file_version? AND chapter LIKE ? AND qtype?, (version, f%{item[chapter]}%, item[qtype]) ).fetchall() if len(rows) item[n]: raise ValueError(f{item[chapter]} {item[qtype]} 只有 {len(rows)} 题) paper.extend(rng.sample(rows, item[n])) return paperchapter LIKE ?用模糊匹配是为了容忍「第三章」和「第 3 章」两种写法抽题前最好先跑一次章节名的取值统计确认没有把「第三章」和「第三章 化学反应速率」拆成两批。数量不足时直接抛异常而不是静默少出题——一份缺题的卷子比报错更难排查。组卷参数建议值说明总题量3050超过 50 题一份卷子的完成率明显下降选择题占比40% 左右批改成本低适合做覆盖计算题占比20% 左右数量受题库限制别硬凑seed日期或学号同一 seed 同一份卷方便复盘错题章节覆盖每章不少于 15%保证不出现整章缺失4.4 导出成可打印的试卷抽完题直接渲染成 Markdown再交给 pandoc 之类的工具转 PDF。模板里用stem_raw保留读者熟悉的上下标写法。TPL ## {chapter} {qtype} {qno}. {stem} {options} def render(paper, options_map): out [] for r in paper: opts \n.join(options_map.get(r[id], [])) out.append(TPL.format(chapterr.get(chapter, ), qtyper.get(qtype, ), qnor[qno], stemr[stem_raw], optionsopts)) return \n.join(out)答案单独出一份文件避免一份文档里既能看题又能看答案打印时按需选择。导出前记得按章节和题号排序抽样过程打乱了顺序直接渲染会得到一份跳来跳去的卷子。5. 校核与增量更新从 0615 到下一版不用重做5.1 对账法题号集合求差集漏题一眼可见切题脚本最怕的是静默漏题。校验方式很朴素把 PDF 里扫出来的题号集合和数据库里的题号集合各算一份求差集。# 从原始行序列里扫出所有 (chapter, qno), 与库中记录比对 seen {(ch, qno) for ch, qno in scan_raw(lines)} stored {(ch, qno) for ch, qno in db_rows} print(漏题:, sorted(seen - stored)) # 有输出说明切题规则漏判 print(多出:, sorted(stored - seen)) # 有输出说明跨页续行被误判成新题漏题集合不为空先去看这些位置的原始行是不是题号后跟了特殊符号或者题号和题干之间夹了图片多出集合不为空通常是上一题的续行里出现了形如「2.5 mol」的片段被NUMBER误判。5.2 用题目指纹做版本比对拿到新一版复习题集时不想重做全部标注就用指纹比对。指纹取题目归一化文本的哈希去掉所有空白后计算这样排版微调不会触发变更。import hashlib, re def fp(text: str) - str: return hashlib.sha1(re.sub(r\s, , text).encode(utf-8)).hexdigest()[:16]变更类型判定方式处理动作新增题新版本三元组不在旧版本里插入标记待校对答案修改题三元组相同但fp不同保留旧标注只更新题干并标记删除题旧版本三元组不在新版本里软删除错误记录可能还引用它仅答案变化题干fp相同、答案fp不同直接覆盖答案字段比对时一定要用(chapter, qtype, qno)而不是题号单独做主键因为每章都从 1 开始编号只用题号会让整本书的题撞在一起。5.3 入库前跑一次自检把低级错误挡在外面切题结果落地前跑一个校验脚本比事后翻几十页 PDF 找问题便宜得多。检查项包括题干长度是否低于 15 个字符多半是切残了、选择题选项数是否在 2 到 6 之间、判断题答案是否是「对/错/正确/错误」之一、答案字段为空的比例是否超过阈值、归一化后是否残留[₀-₉⁰-⁹]这类上下标字符、以及fp是否有重复。def self_check(records, empty_answer_limit0.15): problems [] for r in records: if len(r[stem].strip()) 15: problems.append((stem_too_short, r[qno])) if r[qtype] 选择题 and not 2 len(r[options]) 6: problems.append((option_count, r[qno])) if r[qtype] 判断题 and r.get(answer) not in (对, 错, 正确, 错误): problems.append((judge_answer, r[qno])) empty sum(1 for r in records if not r.get(answer)) / max(len(records), 1) if empty empty_answer_limit: problems.append((answer_missing_ratio, round(empty, 3))) return problems把结果写成check_YYYYMMDD.csv下次更新题库只 diff 前后两份报告真正需要人看的通常就是新增和变更的那几十行剩下的交给INSERT OR REPLACE自动完成。本文还有配套的精品资源点击获取