news 2026/9/18 10:26:05

从PDF到API:古诗词文档清洗与学习系统构建实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从PDF到API:古诗词文档清洗与学习系统构建实践

简介:这份PDF汇总了人教版小学语文必背古诗词75首,按汉乐府、唐诗、宋诗等经典篇目编排,覆盖《江南》《静夜思》《望庐山瀑布》《悯农》等常考诗篇,适合小学生、家长及语文教师作为日常诵读与考前复习的便携清单。文件为1个PDF文档,体积仅13KB,内容包含诗词原文,部分篇目附有对韵律、修辞、时代背景等知识点的简要解读,方便快速预览与打印背诵。目前已有262人学习下载,适合需要系统梳理小学阶段必背古诗词、培养语感与传统文化素养的读者。通过这份资料,可集中掌握75首必背篇目,理解诗中自然景象、情感寄托与历史人文内涵,辅助课内学习与积累。

1. 静态 PDF 里的 75 首古诗,是教育产品最好用的冷启动语料

做教育类产品的团队通常都会遇到同一个问题:手头有大量内容,但内容形态全都是 PDF、Word、扫描件。整理工作总是被排期,产品原型却已经需要真实数据来联调。人教版小学语文必背古诗词 75 首这份 PDF 恰好是一个绕不开又容易低估的样本:它只有 75 篇,却覆盖汉乐府、唐诗、宋诗、清诗多个历史时期,作者信息完整,篇目短小,非常适合作为古文语料工程的第一个试验场。

把这份 PDF 从“给人看的文档”变成“给程序用的数据”,中间并不是简单的复制粘贴。篇目格式不统一、标题与作者信息混杂、正文存在异体字和标点缺失,这些都是文本抽取阶段要处理的真实问题。这篇文章会从 PDF 版面解析讲起,经过字符清洗、数据建模、检索索引,最后落到一个可以实际调用的诗词学习 API 上。每一步都有可以直接跑的代码和参数说明,读完你手里会多一条从静态资源到结构化服务的完整管线。

2. 用 pdfplumber 完成古诗文档的版面解析与字符级清洗

2.1 先做版面分析,再决定抽取粒度

处理 PDF 文本,选库之前要先回答一个问题:我要拿到的是整页字符串,还是带坐标的文本块?对古诗词文档来说,答案是后者。因为这份 PDF 的篇目排列并不完全规律,有的是“序号 + 书名号标题 + 作者 + 正文”一整行,有的是换行排版,如果直接提取整页文本再接正则,很容易把两首诗的内容拼到一起。

我一般第一轮用 pdfplumber 而不是 PyPDF2,原因在于它可以返回每个文本行的坐标信息(bbox),后续如果需要按位置排序,或者根据字体大小判断标题行,都留有余地。下面这段代码完成最基本的逐行抽取:

import pdfplumber def extract_lines_from_pdf(pdf_path: str) -> list[dict]: lines = [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages, start=1): for line in page.extract_text_lines(): text = line["text"].strip() if not text: continue # 同时记录页码和 y 坐标,供后续按阅读顺序重排 lines.append({ "page": page_num, "text": text, "top": line["top"], "chars_count": len(text) }) return lines pdf_lines = extract_lines_from_pdf("gushi75.pdf") for item in pdf_lines[:8]: print(item["page"], item["text"])

这里extract_text_lines()返回的行对象里,text是当前行文本,top是该行顶部坐标。保留页码和坐标不是多余操作:有的 PDF 在排版时会插入页眉、页脚,后续清洗阶段需要按坐标把这些干扰行过滤掉。chars_count字段是为了快速找出明显过短的行——比如页码或者孤立的标题行。

参数层面需要留意两个点:extract_text_lines()extra_attrs参数可以传入["size"]来获取字体大小,这会在“标题行 vs 正文行”的判别上非常有用;另外pdfplumber打开页面后默认会做字符重排,所以遇到文本被切割成两半的情况,可以检查extract_words()替换extract_text_lines()

2.2 用正则和命名分组做三字段拆分

清洗的下一步是把每一行拆成“序号、标题、作者”三个字段。直接按中括号切是行不通的,因为原文档里有好几种写法:有的序号后带点,有的没有;有的作者在书名号前,有的在书名号后;还有几首诗的标题里带括号,比如《悯农》(一)和《悯农》(二)。这种情况下,命名分组正则比手写字符串切片更稳。

import re LINE_PATTERN = re.compile( r"^(?P<idx>\d{1,2})\s*[、..]?\s*" r"[《((]?(?P<title>[^《》()()]{1,12})[》))]?" r"\s*(?P<author>[\u4e00-\u9fa5]{1,6})?$" )

拆分逻辑里最微妙的是作者字段:{1,6}限制了作者名长度,但这个正则要求作者名后必须紧跟行尾。问题在于原文档第 27 首写作“杜甫《绝句》杜甫”,书名号前后都出现了作者名。如果只用上面的表达式,后半个“杜甫”会匹配到author组里,但“绝句”会被切出来,而前面的“杜甫”会变成多余字符。我实际处理时会在正则之前先做一次特殊预处理,把“作者+《标题》+作者”这种模式直接替换成“《标题》作者”:

def normalize_header_line(line: str) -> str: # 处理类似 "27. 杜甫《绝句》杜甫" 这种作者重复出现的情况 dup = re.match( r"^(?P<idx>\d{1,2})\s*[、..]?\s*" r"(?P<author1>[\u4e00-\u9fa5]{1,6})" r"[《((](?P<title>[^《》()()]{1,12})[》))]" r"(?P<author2>[\u4e00-\u9fa5]{1,6})$", line ) if dup: return f"{dup.group('idx')}. 《{dup.group('title')}》{dup.group('author1')}" return line

替换完成后再走正式解析。这个边界处理如果漏掉,会导致整个清洗管线在跑到第 27 首时崩掉,后续所有篇目的 ID 错位。如果你打算把这个清洗脚本跑在不同批次的 PDF 上,建议把这类特殊模式收集成字典表,而不是只依赖正则覆盖。

2.3 正文清洗:标点统一与异体字映射表

标题行拆完,正文还有一层字符级处理。原始文本里有几个绕不开的问题:全角标点和半角标点混用、“里”和“裏”这类异体字同时出现、以及正文行尾缺少句号。字符清洗决定了后续做背诵评测时的比对准确度,这一步省了,后面全部返工。

问题类型示例处理方案
标点不统一“江南可采莲,莲叶何田田” vs 半角逗号统一替换为中文标点
异体字“里” 与 “裏”,“峰” 与 “峯”建立映射表再 replace
行末缺标点“鱼戏莲叶间”后直接换行按韵脚补句读符
混入页眉页面顶部重复出现“人教版”字样按坐标过滤掉 top 值小于阈值的行

对应的清洗函数如下,建议把映射表单独抽成常量,方便后续扩展其他语料时复用。

VARIANT_MAP = { "裏": "里", "峯": "峰", "飮": "饮", "烟": "烟", "邨": "村", } def clean_poem_text(text: str) -> str: # 统一标点:全角逗号、句号归一 text = text.replace(",", ",").replace(".", "。").replace(";", ";") # 异体字映射 for old, new in VARIANT_MAP.items(): text = text.replace(old, new) # 去掉首尾空白与多余空格 return "".join(text.split())

"".join(text.split())会去掉文本内部所有空白字符,这个操作对古文安全吗?只要是纯汉字与标点组成的诗句就是安全的,但如果未来接入包含注释的文本,就不能这么粗暴,应该换成只去掉空白字符的正则。补句读符是最难自动化的部分,简单做法是按“韵脚字集 + 句末助词”判断,比如“兮”“之”“矣”后补句号,但更稳妥的方案是直接硬编码这 75 首的标准分行数据,清洗只是兜底。

3. 建立诗词结构化模型与低开销检索层

3.1 诗词 JSON Schema 的设计考量

数据清洗完成后,下一个决策点是为每首诗设计存储结构。我的选择是 JSON,原因有三个:字段可以逐步新增而不破坏旧数据;与 Python 字典天然兼容;输出到 Elasticsearch、SQLite 或 NoSQL 都只是做一次序列化转换。但 JSON 的灵活性也容易掩盖数据质量问题,所以 schema 里的关键字段必须提前定义好约束。

下面是我处理这类诗词语料时使用的结构,75 首的字段总量很小,不需要引入 ORM,直接存.json文件或导入 SQLite 都合适:

{ "id": 1, "title": "江南", "author": "汉乐府", "dynasty": "汉", "genre": "乐府", "char_count": 35, "theme_tags": ["江南", "采莲", "自然"], "content": "江南可采莲,莲叶何田田。鱼戏莲叶间。鱼戏莲叶东,鱼戏莲叶西,鱼戏莲叶南,鱼戏莲叶北。", "sentence_count": 7, "first_chars": ["江", "莲", "鱼", "鱼", "鱼", "鱼", "鱼"] }

字段设计不是越多越好,要围绕“上层产品用得到什么”来定。theme_tags用于主题筛选;first_chars是每句首字数组,背诵评测时可以用“给首字背全句”的提示模式;char_countsentence_count用于做难度分级。genre字段建议统一枚举值,不能有的存“乐府”有的存“汉乐府”,否则后面按体裁过滤时会出现统计偏差。

3.2 从清洗文本到 JSON 的转换管线

解析逻辑分成两步:先按目录正则切分 75 首的边界,再逐首提取正文。切分边界是整个管线里最容易出错的地方,必须用“序号连续 + 标题行特征”双重判断,而不是只在文本里找书名号。下面这段代码把上一步清洗好的文本列表转换成结构化数据:

import json def build_poem_dataset(clean_lines: list[str]) -> list[dict]: poems = [] current = None for line in clean_lines: header = LINE_PATTERN.match(line) if header: if current: poems.append(current) current = { "id": int(header.group("idx")), "title": header.group("title"), "author": header.group("author"), } else: if current: current.setdefault("content_parts", []).append(line) if current: poems.append(current) for poem in poems: content = "".join(poem.pop("content_parts", [])) poem["content"] = content # 去掉按句切分后的空字符串 sentences = [s for s in re.split(r"[,。!?;]", content) if s] poem["sentence_count"] = len(sentences) poem["first_chars"] = [s[0] for s in sentences if s] return poems dataset = build_poem_dataset(cleaned_lines) print(json.dumps(dataset[:2], ensure_ascii=False, indent=2))

运行时会发现一个常见问题:五言诗和七言诗的sentence_count计算并不准确,因为在“鱼戏莲叶间。鱼戏莲叶东”这两句里,“间”和“东”之间没有逗号,只在句尾有句号。按标点切分会把一个“句”分成多个片段。如果产品端只需要句子数量,可以容忍这个误差;如果要做严格的“逐句背诵”,就必须手工维护一份断句数据。对 75 首的规模来说,手工校正成本很低,远比写一个自然语言处理的层次切分器划算。

3.3 用倒排索引做篇目级检索

数据结构建好之后,需要解决“用户搜一个词,如何快速找到相关诗篇”的问题。75 首的规模不大,直接遍历每一首做子串匹配也就几毫秒,但为了接口后续扩展到更多诗词,我倾向直接用 Python 默认库建倒排索引,不引入额外中间件。

from collections import defaultdict def build_inverted_index(poems: list[dict]) -> dict[str, list[int]]: index = defaultdict(list) for poem in poems: # 以单个汉字为最小索引单元,配合主题标签做增强 for ch in set(poem["content"].replace(",", "").replace("。", "")): index[ch].append(poem["id"]) for tag in poem.get("theme_tags", []): index[tag].append(poem["id"]) return dict(index) index = build_inverted_index(dataset)

以单个汉字为单位建索引的优点是索引体积很小,一个汉字对应一个诗词 ID 列表,查询时直接把用户输入中的汉字对应列表取交集,就能得到包含这些字的诗篇。比如输入“春风”,可以查出《咏柳》《村居》《泊船瓜洲》等篇目。这个方案的缺点是无法感知词组语义,所以我在索引里额外写入了theme_tags,用人工打标解决“春风”这种抽象词在语义层面的召回问题。实际开发里,主题标签的覆盖率和准确性比索引结构对搜索体验的影响更大。

4. 背诵练习背后的记忆算法与评测逻辑

4.1 间隔重复:为什么默认参数不能照搬

诗词背诵类产品的核心功能是“提醒用户复习”。业界标准方案是间隔重复,SM-2 算法最常用,它通过用户每次的自评等级调整下次复习间隔。但把它直接套到小学生背古诗的场景里,有两个参数必须调整。

第一个是初始间隔。Anki 的默认曲线是“1 分钟 → 10 分钟 → 1 天”,这适合记忆卡片,但一首 20 字的五言绝句,初次背诵后当天就应该再做一次复习,而不是等一天。我处理的方案是把第一轮间隔设为 4 小时,当天完成“首次学习 + 第一次复习”闭环。第二个是用户自评等级的粒度,成人可以区分 0 到 5 六个等级,对低龄用户明显过细,压缩成“记住了 / 模糊 / 忘了”三档更现实。

def schedule_review(interval_days: float, ease: float, memory_level: int) -> tuple[float, float]: """ memory_level: 0=忘了, 1=模糊, 2=记住了 返回新的间隔天数与难度系数 """ if memory_level == 0: # 忘掉:间隔重置到 0.17 天(约 4 小时),难度下调 interval_days = 0.17 ease = max(1.3, ease - 0.2) elif memory_level == 1: # 模糊:间隔不变,保持当前难度 interval_days = max(0.17, interval_days * 0.5) ease = ease else: # 记住了:间隔按难度系数增长 if interval_days < 1: interval_days = 1 else: interval_days = round(interval_days * ease, 1) ease = max(1.3, ease + 0.05) return interval_days, ease

这段代码里interval_days用浮点数,因为第一天内的复习间隔要用小时表示。ease的初始值设为 2.0,比 SM-2 的默认 2.5 略低,原因是古诗词篇幅短、复习成本低,难度系数虚高会导致间隔拉得过长,与“每周把 75 首轮一遍”的目标冲突。实盘跑下来发现,ease 低于 1.3 时对应的用户基本已经放弃,所以下限必须锁死在 1.3。

4.2 背诵结果的自动判定方法

背诵评测不能只靠用户自己点“记住了”。常见的判断手段是把用户背诵的文本和标准诗句做相似度比对,但逐字相等太严格,背串诗句的现象很常见。我用的是编辑距离思路,用 Python 标准库difflib实现:

import difflib def normalize_for_compare(text: str) -> str: # 去掉所有标点与空白,只保留汉字 return "".join(ch for ch in text if "\u4e00" <= ch <= "\u9fa5") def recitation_score(user_input: str, standard: str) -> float: u = normalize_for_compare(user_input) s = normalize_for_compare(standard) if not u: return 0.0 matcher = difflib.SequenceMatcher(None, u, s) return matcher.ratio()

判定阈值的经验值是:ratio >= 0.95算完全正确,允许 1 个字错漏;0.8 ~ 0.95算模糊,要求用户观看答案后再复习一次;< 0.8直接判遗忘。阈值不能定死,因为ratio()是按匹配块长度算的,五言诗和七言诗同样错一个字,得分差异明显。七言诗 28 个字错 1 个字,ratio 已经降到 0.96 左右,四言短诗更敏感。实际产品里应该按char_count分档设置阈值,而不是用全局阈值。

4.3 结合遗忘曲线的复习时间窗

排复习计划时,可以把一天的复习任务按“超过 80% 用户遗忘节点”分早中晚三个窗口。根据这个语料库的实际体量(首诗均 20~35 字),一天背 5 首新诗 + 复习 15 首旧诗是合理的上限。如果并发复习超过 25 首,间隔重复算法的间隔调整已经无法解决实际疲劳问题,需要做的是降级新诗数量而不是继续硬排。

复习队列的实现不复杂,但要注意每次调度后把next_review_at持久化。这里给出一段参考实现,数据结构使用heapq按时间排序:

import heapq from datetime import datetime, timedelta review_queue = [] def push_review(poem_id: int, review_time: datetime): heapq.heappush(review_queue, (review_time.timestamp(), poem_id)) def pop_due_reviews(now: datetime) -> list[int]: due = [] while review_queue and review_queue[0][0] <= now.timestamp(): _, poem_id = heapq.heappop(review_queue) due.append(poem_id) return due

push_review里我用时间戳作为堆排序主键,避免datetime对象比较时出现时区问题。这里有一个工程细节:无论复习结果如何,都要在用户提交背诵结果后立刻重新调用schedule_review,并生成新的review_time入堆,不能在定时器回调里统一处理,否则会出现大量到点但用户已经睡了的无效提醒。

5. 把资源做成可在线调用的诗词学习 API

5.1 FastAPI 单文件实现最小服务

前面的数据和算法都是离线运行的,要让前端 H5、小程序或者语音助手用起来,需要把整个管线包一层 HTTP 接口。FastAPI 适合这个场景,因为 75 首诗的规模不需要引入数据库,内存里维护一份 JSON 列表就够。启动命令也简单,uvicorn main:app --host 0.0.0.0 --port 8000。这里给出一个单文件实现,同时暴露“列表查询”和“单首详情”两个接口:

from fastapi import FastAPI, Query, HTTPException from fastapi.responses import JSONResponse import json app = FastAPI(title="古诗词 API", version="1.0.0") with open("poems.json", encoding="utf-8") as f: POEMS = json.load(f) @app.get("/poems") def list_poems( author: str | None = Query(None, description="按作者过滤"), keyword: str | None = Query(None, description="按正文关键词搜索"), ): result = POEMS if author: result = [p for p in result if author in p["author"]] if keyword: result = [p for p in result if keyword in p["content"]] return JSONResponse(content={"total": len(result), "items": result}) @app.get("/poems/{poem_id}") def get_poem(poem_id: int): for p in POEMS: if p["id"] == poem_id: return JSONResponse(content=p) raise HTTPException(status_code=404, detail="poem not found")

接口参数里authorin做模糊匹配而不是精确相等,是为了兼容“杜甫”和“杜”这类查询意图,实际请求量小到不需要为这种匹配方式优化。返回结构直接复用 JSON schema 字段,前端拿content字段展示正文,拿first_chars做提示背诵模式,这两个字段是产品端使用频率最高的。

5.2 用内容哈希做接口版本控制

接口上线后,最容易被忽略的问题是“数据更新了,但客户端还在用旧缓存”。JSON 文件可能因为标点修订、作者信息校正而更新,浏览器端和服务端的内存缓存都需要一个幂等的版本标识。计算一份内容哈希并把它放进响应头,是性价比最高的方案。

import hashlib def build_etag(payload: list[dict]) -> str: raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") return hashlib.md5(raw).hexdigest()[:16] from fastapi.responses import Response @app.get("/poems/{poem_id}") def get_poem_with_etag(poem_id: int, response: Response): # 首次请求时计算并缓存 etag,后续请求判断 If-None-Match content = json.dumps(get_poem(poem_id).body, ensure_ascii=False) etag = build_etag([content]) response.headers["ETag"] = etag if response.headers.get("If-None-Match") == etag: raise HTTPException(status_code=304) return content

sort_keys=True是为了保证同样内容每次计算出的哈希一致,否则字典键顺序变化会导致 ETag 抖动。304 状态码配合浏览器本地缓存,可以让背诵打卡页在二次打开时秒开。这个小技巧比引入 Redis 缓存更直接,也更容易在 PHP、Node.js 项目里迁移。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 10:22:04

3ds Max 2026零基础实操地图:从安装卡顿到施工图交付

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 10:21:28

Comsol仿真太赫兹热可调超材料:VO₂与InSb建模全流程

去年我在Comsol里跑通了一个太赫兹超材料模型&#xff0c;材料体系用的是二氧化钒&#xff08;VO₂&#xff09;和锑化铟&#xff08;InSb&#xff09;&#xff0c;核心玩法是“热可调”。当时目标很直白&#xff1a;在0.5~2 THz这个频段&#xff0c;用温度把结构的透射响应从“…

作者头像 李华
网站建设 2026/9/18 10:15:19

SLF4J与SpringBoot日志系统深度解析

1. SLF4J在SpringBoot中的核心价值作为Java生态中最主流的日志门面框架&#xff0c;SLF4J(Simple Logging Facade for Java)在SpringBoot项目中扮演着关键角色。不同于直接使用Log4j或Logback等具体日志实现&#xff0c;SLF4J通过门面模式提供统一的日志API&#xff0c;这种设计…

作者头像 李华
网站建设 2026/9/18 10:14:03

从单模型预测到群体智能:MiroFish 多智能体数字世界推演实践

上周有位做产品的朋友问我一个挺刁钻的问题&#xff1a;手里没有标注数据&#xff0c;也没有历史样本&#xff0c;怎么判断一件还没发生的事会往哪个方向走。我没直接回答&#xff0c;而是打开 MiroFish 给他跑了一遍——把一个模糊的预测问题丢进去&#xff0c;它先拉起一个几…

作者头像 李华