用 pdfplumber+PyMuPDF 构建气象学 PDF 知识库与刷题系统
2026/9/17 18:49:28 网站建设 项目流程

简介:这份资料是山东农业大学气象学课程的主要复习内容整理,面向该校修读气象学及相关专业课程、需要应对期末或补考的学生。内容以课件为基础、教材为参考,梳理了课程的核心考点与思考题,涵盖天气与气候概念、太阳辐射与地面辐射、热量交换与土壤温度变化、空气湿度与水汽凝结、气压与风、气团锋面、气旋反气旋、霜冻寒潮台风等模块,并列出名词解释、填空、选择、判断、计算与作图等考试题型。资源包共1个PDF文件,约146KB,轻量便于手机或电脑随时翻阅。已有124人学习下载。读者可据此建立复习框架,明确太阳高度角、昼长、积温、气温直减率等计算题,以及等压线受力分析、海陆风山谷风等作图题的练习方向,减少盲目翻书,提高冲刺阶段的复习效率。

1. 把《气象学主要复习内容.pdf》当成数据源,而不是一份要背的讲义

期末前两周,农学、植保、园艺几个专业的群里都会出现同一份《气象学主要复习内容.pdf》——几十页,辐射、温度、大气水分、气压与风、天气系统、农业气候一路排下来,名词解释、公式、简答题混在一起。真正让人难受的不是内容多,而是它是一份 PDF:想确认「活动积温」和「有效积温」差在哪,得靠肉眼翻页;想查「饱和水汽压」的系数,翻到的那页往往只剩半行公式。

换个思路,把这份复习资料当数据源处理:先抽成干净文本,再切成分层的知识块,然后把辐射、温度、水分、风这四类考点变成可以查询、可以自动出题、可以回捞错题的本地小系统。山东农业大学气象学这门课的考点分布相对稳定,术语体系封闭,非常适合做结构化和参数化。

适合两类人:要考气象学的农科同学,能拿到一套按章节检索、按公式刷题的复习工具;做数据清洗、文档解析的工程师,能拿它练一遍从 PDF 到本地知识库的完整链路,场地小、反馈快、不用联网。

2. 用 pdfplumber 与 PyMuPDF 抽干气象学 PDF 的正文与公式

动手之前先做一件事:确认这份 PDF 到底是文本型、扫描型还是混合型。这一步决定后面所有工作量,判错了会白干半天。

2.1 三行命令判断 PDF 是文本型还是扫描型

最省事的办法是先抽三页看字符数。命令行有 poppler 的话直接:

# 抽第 1~3 页,-layout 保留粗略版面 pdftotext -f 1 -l 3 -layout "气象学主要复习内容.pdf" - | head -60 # 统计抽出的字符数,除以页数 pdftotext -f 1 -l 3 "气象学主要复习内容.pdf" - | tr -d ' \n' | wc -m

三页字符数低于 100,基本可以判定是扫描件,得走 OCR(常见做法是 PaddleOCR 或 tesseract 加中文包);几百到几千字符说明有文本层,可以直接抽取。注意一个高频情况:正文是文本层,公式是图片。这种「混合型」最坑,因为抽出来的文字里公式位置是空的,你会得到「饱和水汽压 es = ____」这种残句。判断办法是把某页的图片数量打出来。

# probe.py 统计每页文本量与图片量 import fitz # PyMuPDF doc = fitz.open("气象学主要复习内容.pdf") for i, page in enumerate(doc, 1): text_len = len(page.get_text().strip()) img_cnt = len(page.get_images(full=True)) print(f"p{i:>3} chars={text_len:>5} images={img_cnt}")

逻辑很直白:get_text()拿文本层长度,get_images(full=True)拿图片对象数。如果某一页chars很小但images有 2~5 个,那一页大概率是整页扫描或者公式图集,后续要单独处理。参数上full=True会返回 xref 等信息,方便后面按 xref 导出原图。

2.2 pdfplumber 与 PyMuPDF 怎么选

两个库都能抽文本,但脾气不一样,混用是常态:

维度pdfplumberPyMuPDF(fitz)
抽取速度慢,几十页可以接受快,适合批量
版面判断强,能拿到每个字符的坐标一般,需要自己算
表格线识别内置extract_table需手写
中文紧凑排版x_tolerance可调,效果好默认也稳,偶有字序错乱
图片导出支持但不如 fitz 方便get_images+pixmap很顺
典型用法正文 + 表格图片 + 全量快速抽取

常见做法是:正文和表格交给 pdfplumber,图片和整页渲染交给 PyMuPDF,最后按页码合并。

2.3 抽取正文并清理页眉页脚与中文断行

复习资料最脏的地方不是公式,而是页眉页脚和 PDF 换行。中文 PDF 经常把一个词拆成两行,直接拼接会得到「相对湿\n度」这种碎片。

# extract_text.py import re, json import pdfplumber # 页眉页脚常见形态:校名、章节名重复、纯页码 HEAD_FOOT = re.compile(r'^(山东农业大学|气象学.*(复习|讲义)|第\s*\d+\s*页|[-—]\s*\d+\s*[-—])') def clean(text: str) -> str: lines = [] for ln in text.split('\n'): ln = ln.replace('\u3000', ' ').strip() # 全角空格转半角 if not ln or HEAD_FOOT.match(ln): continue lines.append(ln) s = '\n'.join(lines) # 中文行尾被硬回车截断:上一行以汉字结尾、下一行以汉字开头 → 合并 s = re.sub(r'([\u4e00-\u9fa5,、;:])\n([\u4e00-\u9fa5])', r'\1\2', s) s = re.sub(r'[ \t]{2,}', ' ', s) return s out = [] with pdfplumber.open("气象学主要复习内容.pdf") as pdf: for i, page in enumerate(pdf.pages, 1): raw = page.extract_text(x_tolerance=1.5, y_tolerance=1.5) or '' out.append({"page": i, "text": clean(raw)}) json.dump(out, open("raw_pages.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print("pages:", len(out), "chars:", sum(len(p["text"]) for p in out))

参数说明:x_tolerancey_tolerance默认是 3,中文排版字距紧,调成 1.5 能减少整行被判成两段的情况;调太小会把同一行的字符拆散,一般 1~2 之间试两次就能定。extract_text默认关闭layout参数,输出是逐行文本,适合后面按行做正则;如果某一页是双栏,需要开layout=True或者用page.crop()左右各切一半分别抽。

抽取完做一次抽查:随机挑 3 页,把原文 PDF 和raw_pages.json对照读,重点看数字和单位有没有错位。6.11 hPa被拆成6.11 hPa这种事,后面所有公式计算都会跟着错。

提示:不要在抽取阶段就急着删空行。空行是章节分割的重要线索,留到结构化阶段再决定去留。

2.4 公式图和示意图怎么落地

公式在复习资料里通常有两种形态:可选中文本(少见),和图片(常见)。图片公式不要指望 OCR 直接还原成 LaTeX,上下标和希腊字母错一个,公式就废了。更划算的做法是把图导出来当索引用,公式本身人工补录。

# extract_img.py 把每页图片导出,按 页码-序号 命名 import os, fitz os.makedirs("figs", exist_ok=True) doc = fitz.open("气象学主要复习内容.pdf") for pno, page in enumerate(doc, 1): for idx, info in enumerate(page.get_images(full=True), 1): xref = info[0] pix = fitz.Pixmap(doc, xref) if pix.n - pix.alpha >= 4: # CMYK 转 RGB pix = fitz.Pixmap(fitz.csRGB, pix) pix.save(f"figs/p{pno:03d}-{idx}.png")

导出后按p012-1.png这样的命名建立「页码 → 公式编号」对照表,人工填一列latexsymbol。这门课的公式数量有限,几十条的量级,手工比调 OCR 快,而且准。真正需要 OCR 的是整页扫描件,那种情况先做整页渲染再识别,别只对嵌入图做识别。

3. 把辐射、温度、水分、风这四类考点结构化成可查询数据

抽出文本只是原料。气象学复习资料的组织逻辑和一般教材不同:它按考点类型混排,同一页可能既有名词解释又有计算题。要让它可检索,得先补一层结构。

3.1 用正则识别章节层级,别用纯文本切分

这类资料的一级标题通常带编号,比如「第一章 大气概况」「第二章 太阳辐射」,二级标题是「一、」「1.」「1.1」。纯按空行切会切碎,必须用行首模式匹配。

# outline.py import re H1 = re.compile(r'^\s*第[一二三四五六七八九十]+章\s*[、..]?\s*(.{2,30})$') H2 = re.compile(r'^\s*([一二三四五六七八九十]+)\s*[、..]\s*(.{2,30})$') H3 = re.compile(r'^\s*(\d+(?:\.\d+){1,2})\s*[、..]?\s*(.{2,40})$') def parse(pages): nodes, cur = [], {"h1": "未分类", "h2": "", "h3": ""} for p in pages: for ln in p["text"].split('\n'): if m := H1.match(ln): cur = {"h1": m.group(1).strip(), "h2": "", "h3": ""} elif m := H2.match(ln): cur = {**cur, "h2": m.group(2).strip(), "h3": ""} elif m := H3.match(ln): cur = {**cur, "h3": m.group(2).strip()} else: nodes.append({**cur, "page": p["page"], "text": ln}) return nodes

逻辑说明:每遇到标题行就更新当前路径,之后所有正文行都带上这条路径。H3用了{1,2}允许「3.2」和「3.2.1」两种层级,正则里的?:是非捕获组,避免取错 group。匹配失败的行不丢,归到当前路径下,宁可多带上下文也不要丢内容。

3.2 生成带章节路径的 JSONL 知识块

逐行存太碎,检索命中后只能看到一句话。按「标题 + 内容 + 页范围」聚合成块,块长度控制在 200~500 字,这个区间既够上下文完整,又不至于让关键词命中互相淹没。

# to_chunks.py import json from outline import parse MAXLEN = 500 pages = json.load(open("raw_pages.json", encoding="utf-8")) nodes = parse(pages) chunks, buf = [], {"path": "", "pages": [], "text": ""} for n in nodes: path = "/".join(x for x in (n["h1"], n["h2"], n["h3"]) if x) or "未分类" if buf["path"] == path and len(buf["text"]) + len(n["text"]) < MAXLEN: buf["text"] += "\n" + n["text"] buf["pages"].append(n["page"]) else: if buf["path"]: chunks.append(buf) buf = {"path": path, "pages": [n["page"]], "text": n["text"]} if buf["path"]: chunks.append(buf) for i, c in enumerate(chunks): c["id"] = f"c{i:04d}" c["pages"] = sorted(set(c["pages"])) with open("chunks.jsonl", "w", encoding="utf-8") as f: for c in chunks: f.write(json.dumps(c, ensure_ascii=False) + "\n") print("chunks:", len(chunks))

pages用列表并去重,是为了后面按页码回查原 PDF 截图核对。path字段是关键,它让「辐射」这个大词和「太阳高度角」这个小词不会混在同一层。

3.3 三类考点怎么打标

气象学的题就三类,打标之后复习策略完全不同:

考点类型识别信号复习动作检索关键词示例
名词解释「是指」「称为」「定义为」背定义,抓限定词相对湿度、露点、锋面
计算题含公式、单位、字母符号手算 + 代码验算饱和水汽压、积温、太阳高度角
简答/论述「试述」「简述」「影响…因素」记要点条数和顺序影响气温日变化的因素

打标不用模型,正则就够:句中出现「是指/称为/定义为」归为名词解释,出现「=」「hPa」「℃·d」「计算」归为计算题,出现「简述/试述/说明」归为简答。三类之外的全归「其他」,人工再看一遍,通常只占一成。

3.4 抽样校验:别让错误结构一路传下去

切块之后必须抽查。做法是从每个一级章节里随机抽两块,打印id + path + pages + text,回翻原 PDF 对应页比对。重点看三件事:章节路径有没有串(「大气水分」的内容被挂到「气压与风」下)、公式块是不是残句、页眉页脚有没有漏网。

抽查花十五分钟,能省掉后面所有环节的返工。如果发现某章的路径全错,基本是那一章的标题格式和正则不匹配,把标题行捞出来单独看一眼就能改。

4. 农业气象学公式落地:从饱和水汽压到积温的可复现代码

气象学复习里最容易失分的是计算题,公式记错一个系数,整题归零。把这些公式写成函数,既能验算手算结果,也能随机化参数做无限刷题。

4.1 常用符号、单位与取值

符号含义单位常见取值 / 范围
es饱和水汽压hPa0 ℃ 约 6.11
e实际水汽压hPa由 RH 反推
RH相对湿度%0~100
Td露点温度恒 ≤ 气温 T
γd干绝热递减率℃/100 m约 1.0
γm湿绝热递减率℃/100 m约 0.4~0.9,随温度变化
φ当地纬度°泰安一带约 36.2
δ太阳赤纬°-23.5~+23.5
ΣT活动积温℃·d因作物而异,查教材附表

单位是最容易翻车的地方:es 用百帕还是千帕,积温用日平均还是日最高最低平均,教材之间不统一。写代码时把单位写进函数文档,比事后对账省事。

4.2 饱和水汽压与露点:Magnus 形式的实现与校验

农业气象教材普遍采用 Magnus 型经验式,系数随版本略有差异,实现时把系数抽成参数,方便和教材对齐。

# meteo.py import math def es_magnus(t_c: float, a: float = 7.5, b: float = 237.3) -> float: """饱和水汽压 es(hPa),t_c 为摄氏温度。 a、b 为 Magnus 系数,若教材给的是 17.27/237.7 形式, 注意那是 e0=6.11 的另一套写法,别混用。""" e0 = 6.11 # 0 ℃ 时的饱和水汽压,hPa return e0 * 10 ** (a * t_c / (b + t_c)) def rh_from_td(t_c: float, td_c: float) -> float: """由气温和露点求相对湿度(%):RH = e(Td)/es(T)""" return 100.0 * es_magnus(td_c) / es_magnus(t_c) def dew_point(t_c: float, rh: float) -> float: """由气温和相对湿度反推露点(℃)""" e = es_magnus(t_c) * rh / 100.0 # es 反解:t = b*lg(e/e0) / (a - lg(e/e0)) lg = math.log10(e / 6.11) return 237.3 * lg / (7.5 - lg) if __name__ == "__main__": print(round(es_magnus(20), 2)) # 20 ℃ 约 23.4 hPa print(round(rh_from_td(25, 20), 1)) # 25 ℃ / 露点 20 ℃ print(round(dew_point(25, 70), 1)) # 与上式互为镜像,结果应接近 19 ℃

es_magnus10 **而不是math.exp,是因为教材里的 Magnus 系数是按常用对数拟合的,换成 e 指数得换系数。rh_from_tddew_point互为逆运算,这是个天然的自检:给一组 (T, RH),先算 Td 再算回 RH,误差应当在 0.5% 以内。dew_pointmath.log10(e/6.11)当 e 很小时会出问题,实际取值都在 0 ℃ 以上,不用额外防护,但要知道这个边界。

注意:不同教材的 Magnus 系数有 7.5/237.3、7.45/235、17.27/237.7 几套写法。手算时先看题干给了哪一套,代码里的默认值只作验算参照。

4.3 积温与太阳高度角:把参数写进函数签名

积温分两种,区别只在一个减法:活动积温累加高于界限温度的日平均气温,有效积温累加「日平均气温减去界限温度」。

def active_accum_temp(temps, t_base=10.0): """活动积温:只累加 >= 界限温度的日平均气温""" return sum(t for t in temps if t >= t_base) def effective_accum_temp(temps, t_base=10.0): """有效积温:累加(T - 界限温度),低于界限温度记 0""" return sum(max(t - t_base, 0.0) for t in temps) def solar_altitude_sin(lat_deg, decl_deg, hour_angle_deg): """太阳高度角正弦:sin(h)=sinφsinδ+cosφcosδcosω ω 为时角,正午为 0,每小时 15°""" r = math.radians return (math.sin(r(lat_deg)) * math.sin(r(decl_deg)) + math.cos(r(lat_deg)) * math.cos(r(decl_deg)) * math.cos(r(hour_angle_deg))) # 泰安 φ≈36.2°,夏至 δ≈23.5°,正午 ω=0 print(round(math.degrees(math.asin(solar_altitude_sin(36.2, 23.5, 0))), 1))

三个函数各有坑。积温的界限温度不是随便定的,小麦、玉米、水稻常用的界限温度不同,数值以教材附表为准,代码只负责累加。太阳高度角公式里ω用度、math.cos要弧度,忘转就全错,所以radians写在函数内部而不是让调用方处理。夏至正午泰安的高度角算出来应在 77° 左右,这个数可以直接当回归测试的断言值。

4.4 递减率与稳定度:判断结果先看符号

干绝热递减率 γd≈1.0 ℃/100 m,湿绝热递减率 γm 小于 γd,气温垂直递减率记为 γ。判断气层稳定度的规则就三条:γ > γd 为绝对不稳定,γ < γm 为绝对稳定,介于两者之间为条件性不稳定。

def stability(gamma, gamma_d=1.0, gamma_m=0.6): """gamma 为实际气温垂直递减率(℃/100m)""" if gamma > gamma_d: return "绝对不稳定" if gamma < gamma_m: return "绝对稳定" return "条件性不稳定"

gamma_m在代码里给了 0.6 这个常见中值,真实值随温度和湿度在 0.4~0.9 之间变化,做题时以题干给的为准。这个函数的价值在于把「介于两者之间」这种模糊描述固化成可判断的分支,刷题时不会因为忘了第三条而丢分。

5. 复习闭环:关键词检索、自动出题与错题回捞

前面几步产出了chunks.jsonl和一组建模函数,最后一公里是让它们能被反复用起来。最轻量的方案是 SQLite,不需要任何服务端。

5.1 用 SQLite FTS5 建索引,中文先分词

FTS5 默认按空白切词,中文整句会被当成一个 token,检索等于失效。做法是入库前用 jieba 把text切成空格分隔的形式,建表只用于检索,原文另存一列。

# index.py import sqlite3, json, jieba con = sqlite3.connect("meteo.db") con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS chunk USING fts5(path, body, raw UNINDEXED, page UNINDEXED)") for line in open("chunks.jsonl", encoding="utf-8"): c = json.loads(line) body = " ".join(jieba.cut(c["text"])) con.execute("INSERT INTO chunk(path, body, raw, page) VALUES(?,?,?,?)", (c["path"], body, c["text"], ",".join(map(str, c["pages"])))) con.commit() # 检索示例:查"饱和水汽压",按 BM25 排序取前 5 for row in con.execute( "SELECT path, page, snippet(chunk,1,'<<','>>','…',12), bm25(chunk) " "FROM chunk WHERE chunk MATCH ? ORDER BY bm25(chunk) LIMIT 5", ("饱和水汽压",)): print(row)

rawpage加了UNINDEXED,避免正文被重复索引导致排名偏差。snippet负责把命中词上下文裁出来,bm25(chunk)是 FTS5 内置的相关度函数,值越小越相关。查「露点」这种短词容易召回到「露点温度」「露点仪」,这时把检索词改成"露点" AND "温度"就能收窄。

5.2 自动出题:三类题型三套模板

题型生成规则需要的字段
名词解释取含「是指/称为」的句子,遮住术语path、原句
填空句中数值和单位替换为下划线数值正则命中
计算调用meteo.py随机化参数函数名 + 参数区间

计算题模板最值得做,因为参数可以随机化:从meteo.py里挑一个函数,按表里的取值范围随机生成 20 组输入,输出题目和答案两列,答案由函数本身给出。dew_point(25, 70)这种输入换成 18~30 ℃、40%~90% 的随机组合,一次就能刷出一页题。生成时把随机种子固定成学号一类的常数,同一份 PDF 每次生成的练习卷完全一致,方便和同学对答案。

5.3 错题回捞:一张表加一条 SQL

错题本不需要 App,一张表就够。

CREATE TABLE IF NOT EXISTS wrong ( chunk_id TEXT PRIMARY KEY, path TEXT, body TEXT, wrong_n INTEGER DEFAULT 1, last_ts TEXT ); -- 做错的题:存在则次数 +1,否则插入 INSERT INTO wrong(chunk_id, path, body, wrong_n, last_ts) VALUES ('c0031', '第二章 太阳辐射/太阳高度角', 'sin h = sinφsinδ + cosφcosδcosω', 1, datetime('now')) ON CONFLICT(chunk_id) DO UPDATE SET wrong_n = wrong_n + 1, last_ts = datetime('now'); -- 每次复习先捞这批:错 2 次以上、且 3 天没碰过 SELECT path, body FROM wrong WHERE wrong_n >= 2 AND last_ts < datetime('now', '-3 days') ORDER BY wrong_n DESC, last_ts ASC;

wrong_nlast_ts两列配合,复习顺序就自动排出来了:错得多的优先,久没碰的插队。chunk_id做主键是让同一道题重复做错只累加计数,不会撑出重复行。

最后一个能立刻用上的技巧:把meteo.py里每个公式函数配一条已知答案的断言,写成assert abs(dew_point(25, 70) - 19.4) < 0.5这样的形式塞进测试文件,改系数、改单位的时候跑一遍,公式库就不会在你不知情的情况下被改坏。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询