用 pypdfium2 解析政府公开 PDF 表格:我们在江苏饮用水源地月报上踩过的坑
前言 :我们在做全国饮用水源地水质数据采集项目,需要从各省生态环境厅网站爬取月报 PDF 并解析表格。江苏是数据量最大、格式最复杂的省份之一。这些 PDF 使用左右开口表格(有上下边线和列间分隔线,但无左右外边框),导致主流表格提取库失效。本文记录了解析过程中遇到的 4 类问题和我们的解决思路,希望有经验的同行能提供帮助。
项目背景
我们开发了一套自动化采集系统,从 31 个省级生态环境厅网站定期采集饮用水源地水质月报。数据来源是政府公开的 PDF 文件,时间跨度从 2016 年到 2026 年。
核心难点 :这些 PDF 没有统一格式。即使是同一个省份,不同年份的 PDF 格式也会发生变化------列数不同、列宽不同、标题措辞不同。更关键的是,这些 PDF 使用左右开口表格 :有上下边线(水平线)和列间分隔竖线,但没有左右外边框竖线 。这种结构导致 pdfplumber 的 extract_tables() 检测不到完整表格------pdfplumber 需要完整的单元格闭合边界来识别 table,而左右开口的结构导致最左列和最右列的单元格"不闭合"。camelot 等基于线条的表格提取库同样失效。
我们对 2018 年 4 月的 PDF 做过详细分析,第 3 页(表格页)的结构如下:
| 元素 | 有/无 | 数量 | 说明 |
|---|---|---|---|
| 上下边线(水平线) | 有 | 290 条 | 表头线 + 每行分隔线,x 范围 90~515 |
| 列间竖线 | 有 | 252 条 | x 位于 90.5, 153, 208, 265, 363, 393, 435, 515 |
| 左右外边框竖线 | 无 | 0 | x=90 和 x=515 处没有竖线 |
| 单元格背景矩形 | 有 | 180 个 | 部分列有背景框(非全部列) |
表格有上下边线、行间分隔线和列间竖线,但左右两端是开口的------没有任何竖线标记表的左右边界。pdfplumber 的 text 策略也只能提取出表格上方的文字段落,无法还原行列结构。
最终方案:使用 pypdfium2 提取文本(按 y 坐标分行,按 x 坐标排列),然后通过正则 + 规则引擎进行结构化解析。这个方案对近年数据(2021+)效果很好,但对早年数据(2016-2020)仍有碎片化问题。
江苏的数据规模:
- 月报总数:119 篇,全部成功解析
- 覆盖时间:2016-08 ~ 2026-06(江苏省生态环境厅从 2016 年 8 月开始公开月报)
- 总记录数:14,193 条
- 每月约 103~130 条记录
PDF 格式演变:三种格式,不能用一套逻辑
这是最大的"坑"之一:江苏的月报 PDF 在不同年份有完全不同的表格结构。
格式 A(2016-2017):8 列
序号 | 城市 | 区县 | 水体 | 水源地名称 | 级别 | 达标情况 | 超标倍数
特点:列数多(8列),单元格窄,长名称频繁断行。水体列有值(如"长江"、"淮河")。
格式 B(2018-2020):7-8 列
序号 | 城市 | 区县 | 水体 | 水源地名称 | 级别 | 达标情况 | [超标信息...]
特点:与格式 A 类似但超标信息替代了超标倍数,可能被拆成多个 parts。水体列部分为空白(地下水类)。
格式 C(2021-2026):5-6 列
序号 | 城市 | 区县 | 水源地名称 | 级别 | 达标情况 | [超标信息...]
特点:取消了水体列,列数减少到 5-6 列,单元格变宽,基本无断行问题。
踩坑:最初我们用列数判断格式
python
# ❌ 错误做法:靠列数判断
if len(parts) >= 7:
# 格式 A/B
else:
# 格式 C
不达标行的超标信息会被拆成多个 parts,导致列数不准确。最终改用 level 锚点定位:
python
# ✅ 正确做法:用内容做锚点
LEVEL_VALUES = {"地级", "县级", "市级", "备用"}
COMPLIANCE_VALUES = {"达标", "不达标", "超标", "---", "--"}
for i in range(3, len(row) - 1):
if row[i] in LEVEL_VALUES and row[i + 1] in COMPLIANCE_VALUES:
level_idx = i
break
无论超标信息被拆成多少 parts,地级/达标 或 县级/不达标 这对相邻关系始终成立。这个锚点把左边的内容(水体 + 水源地名)和右边的内容(超标信息)可靠地分开。
问题 1:pypdfium2 断行------长文本在窄列中被截断
严重程度 :高(早年数据的主要污染源)
影响范围 :2016-2020,约占 35% 数据
解决状态:⚠️ 部分解决
问题描述
pypdfium2 提取文本时,会根据 PDF 内部文字的 y 坐标将文本拆分成"行",同一行内按 x 坐标排列各字段。虽然 PDF 有列间竖线,但 pypdfium2 不识别表格结构,只做纯文本提取------列边界靠文字的 x 坐标间距隐式推断。当列宽较窄(格式 A/B 有 8 列,约 425pt 总宽平均分配,每列仅约 50pt),长名称在窄列中会被强制断行。
例如,徐州市铜山区汉王地下水应急水源地 在 PDF 中被 pypdfium2 拆成:
原始行: ['27', '徐州', '市区/', '徐州市铜山区汉王地', '下水应急水源地', '地级', '不达标', '总大肠菌群(230个/L,超75.67倍)']
如果断行发生在更尴尬的位置,比如 水源地 被拆成 水源 + 地:
原始行: ['31', '徐州', '沛县/', '沛县地下水应急水源', '地', '县级', '达标']
我们的解决方案:三阶段修复
我们在 filter_data_rows 中设计了三阶段修复流程:
Phase 0(parse_pdf_bytes 阶段):跨行续行拼接
python
# 定位每个序号行,把到下一个序号行之前的所有行合并
for k, start in enumerate(seq_idx):
end = seq_idx[k + 1] if k + 1 < len(seq_idx) else len(page_lines)
combined = " ".join(page_lines[start:end])
Phase 2:尾部碎片合并
当 left_fields 的最后一个字段是明显的断行残留时,合并到前一个字段:
python
# 精确匹配已知的碎片字符串
_TAIL_FRAG_RE = re.compile(
r'^(护区|保护区|源保护区|急水源地|下水应急水源地|应急水源地|水水源地|水源保护区)$'
)
# 单字碎片
if last in ('地', '水源', '水源地', '源地', '源', '水', '区'):
merge(prev, last)
# 尾部碎片
elif _TAIL_FRAG_RE.search(last):
merge(prev, last)
为什么用精确匹配而不是正则模式?
最初我们用 $ 结尾匹配(如 r'保护区$'),但这会误合并合法名称:
中运河饮用源保护区(合法完整名)→ 被误判为碎片 → 与水体名合并里运河宝应城区饮用水水源保护区(合法完整名)→ 被误合并
改用 ^...$ 精确匹配后,只有完全等于这些碎片字符串的字段才会被合并。
效果
修复前 → 修复后(2018 全年测试):
| 碎片 | 修复前 | 修复后 |
|---|---|---|
区 |
59 次 | 0 |
护区 |
25 次 | 0 |
保护区 |
21 次 | 0 |
急水源地 |
7 次 | 0 |
应急水源地 |
4 次 | 0 |
问题 2:跨行政区断行------district 字段溢出
严重程度 :中
影响范围 :2018-2020
解决状态:✅ 已解决
问题描述
某些区县是共用一个水源地的,PDF 中会把多个区县列在一起:
市区、大丰、建湖、射阳
pypdfium2 在 、 处断行,把这一串拆成 3 个 parts:
['市区、大', '丰、建湖、', '射阳']
如果只做简单的字段映射,district 只会拿到 市区、大,而 丰、建湖、 和 射阳 会被错误地当作水源地名称的一部分。
解决方案:Phase 1 行政区续接
python
while left_fields:
if district.endswith('、'):
# district 以 '、' 结尾,还没完,无条件合并
district = district + left_fields.pop(0)
elif '、' in district and '、' in left_fields[0] \
and not _ADMIN_MARKERS.search(left_fields[0]):
# 两者都含 '、' 且下一个字段不含行政区标记 → 还在列举区县
district = district + left_fields.pop(0)
else:
break
关键判断:当 left_fields[0] 包含行政区标记(县/区/市/镇/乡)时,说明已经到了水源地名称的范围,停止合并。
问题 3:水体列干扰------早年 PDF 有额外的"水体"列
严重程度 :高
影响范围 :2016-2020
解决状态:✅ 已解决(通过丢弃 water_name)
问题描述
2016-2020 的 PDF 有独立的"水体"列,2021+ 取消了。left_fields 中可能包含 1-3 个字段,需要区分哪些是水体、哪些是水源地名。
最初的方案是用 left_count 判断:
python
# ❌ 不可靠
if left_count == 2:
water_name, source_name = left_fields
elif left_count >= 3:
# 用行政区标记找分界
但问题是:
- 有些行的水体列为空(地下水类),导致字段数不一致
- 水体名本身也会断行(如
苏北灌溉总\n渠) - 水体名和水源地名的边界不清晰
最终方案:source_name 优先级最高,丢弃 water_name
用户明确要求"source_name 的优先级比 water_name 高,如果能在丢弃 water_name 的前提下保证 source_name 100% 正确就可以"。
python
if year_int <= 2020:
water_name = "" # 放弃 water_name
if left_count == 1:
source_name = left_fields[0]
elif left_count == 2:
source_name = left_fields[1] # [0] 是水体
else:
# left_count >= 3:用行政区标记找 source 起点
_ADMIN_RE = re.compile(r'[县区市]')
split_idx = find_admin_boundary(left_fields)
source_name = "".join(left_fields[split_idx:])
else:
# 2021+ 无水体列
water_name = ""
source_name = "".join(left_fields)
这个决策大幅简化了逻辑,也消除了因水体列带来的所有碎片问题。
问题 4:中间截断------source_name 在中间被切断,尾部无法恢复
严重程度 :低(约 0.1%)
影响范围 :2016-2018
解决状态 :❌ 未解决,寻求帮助
问题描述
Phase 2 的尾部碎片合并能处理 left_fields[-1] 是已知碎片的情况。但有一种场景:source_name 被截断后,尾部丢失在 level 锚点之后的位置,无法通过字段合并恢复。
实际案例(2018 年数据库记录):
| 实际存储的 source_name | 正确名称 | 缺失部分 |
|---|---|---|
启东市通吕运河吕四 |
启东市通吕运河吕四应急水源地 | 应急水源地 |
泰州市长江永安州永 |
泰州市长江永安州永正水源地 | 正水源地 |
盱眙县马坝镇地下水应 |
盱眙县马坝镇地下水应急水源地 | 急水源地 |
淮安市古淮河杨庄水 |
淮安市古淮河杨庄水源地 | 源地 |
阜宁县通榆河北陈水 |
阜宁县通榆河北陈水源地 | 源地 |
徐州市骆马湖窑湾水源 |
徐州市骆马湖窑湾水源地 | 地 |
宿豫区中运河刘老涧水 |
宿豫区中运河刘老涧水源地 | 源地 |
这些截断发生在 PDF 的同一行内------pypdfium2 在 source_name 中间断行,后半部分被 pypdfium2 放到了 level 锚点之后,被我们的解析器归入了超标信息字段。
当前的 raw 行示例
python
# pypdfium2 提取的原始行
['27', '徐州', '市区/', '徐州市铜山区汉王地', '下水应急水源地', '地级', '不达标', '总大肠菌群...']
这里 left_fields = ['徐州市铜山区汉王地', '下水应急水源地'],Phase 2 能合并成完整名。但如果断行更极端:
python
['27', '启东市', '启东', '启东市通吕运河吕四', '应急水源地', '地级', '达标']
Phase 2 会把 应急水源地 合并回来(已修复)。但在极少数情况下,尾部碎片恰好和 level 锚点之间没有其他字段隔开,导致解析器把它归入了 level 的右边。
我们的思路
- 后处理映射表 :维护一个
{截断名: 完整名}映射,在入库前做查找替换。缺点是需要手动维护,且不同年份的同名水源地可能略有变化。 - 检查超标信息字段 :如果
exceedance_info的开头看起来像水源地名的延续,尝试拼接回来。但超标信息的格式不固定,判断困难。 - 接受现状:这只影响 13 条记录(占总量 0.1%),且全部在 2017-2018 年。
想请教:有没有更通用的方法处理这种"中间截断 + 尾部丢失"的问题?特别是在无法修改 PDF 原文、无法访问其他解析库的情况下。
附:其他踩坑记录
pypdfium2 非线程安全
python
# 并发调用 PdfDocument 会导致 segfault
# 必须加全局锁
_PDFIUM_LOCK = threading.Lock()
def parse_pdf_bytes(pdf_bytes):
with _PDFIUM_LOCK:
pdf = pdfium.PdfDocument(pdf_bytes)
# ... 解析逻辑 ...
我们的采集系统是多线程的,发现 pypdfium2 在高并发下偶尔崩溃。加上全局锁后稳定。
year/month 不能从发布日期提取
最初我们从网页上的发布日期提取年月,但发布日期通常比月报的实际月份晚 1-2 个月。例如 2023 年 2 月的月报,发布日期可能是 2023-03-06。
改用标题提取:
python
def extract_year_month(text: str) -> tuple[str, str]:
m = re.search(r"(\d{4})[-/年\s]*(\d{1,2})", text)
# 从标题 "2023年2月全省县级及以上..." 提取 (2023, 2)
year 字段类型陷阱
pipeline 传给 parser 的 year/month 是字符串类型("2018"),但 parser 内部做数值比较(year > 2020),Python 会抛出 TypeError: '>' not supported between instances of 'str' and 'int'。
python
# 别忘了类型转换
year_int = int(year) if year else 0
quality 字段不能复制 compliance
江苏的 PDF 中没有"水质类别"列(只有"达标/不达标"),但我们在入库时曾错误地把 compliance 的值复制给了 quality。最终改为空字符串:
python
"quality": "", # 江苏 PDF 无水质类别列
最终数据质量
| 时间段 | 总记录 | source_name 碎片 | 城市数 | 状态 |
|---|---|---|---|---|
| 2016-08~2016-12 | 509 | 骆马湖×21(原始短名) |
13 | ✅ |
| 2017 | 1,227 | 骆马湖×21 + 少量截断 |
13 | ⚠️ |
| 2018 | 1,438 | 13 种截断名(各 1 次) | 26* | ⚠️ |
| 2019 | 1,439 | 同上 | 25* | ⚠️ |
| 2020 | 1,469 | 无 | 14 | ✅ |
| 2021-2026 | 8,111 | 0 | 13-14 | ✅ |
*城市数偏多是因为同一年份中部分 PDF 用"南京"、部分用"南京市",属于格式不统一而非碎片。
寻求帮助
如果你有以下经验,欢迎交流:
-
中间截断修复:在不修改 PDF 原文的前提下,如何检测和修复 pypdfium2 产生的中间截断?特别是当尾部碎片被错误归类到其他字段时。
-
PDF 解析库选型:除了 pypdfium2、pdfplumber、camelot、pymupdf,还有哪些库适合处理左右开口表格(有上下边线和列间分隔线,但无左右外边框)?pdfplumber 需要完整单元格闭合边界才能识别 table,我们的表格左右两端不闭合导致提取失败。我们的需求是纯文本提取(不需要图片),但需要保持表格的行列结构。
-
长尾数据质量治理:当 98.9% 的数据已经正确,剩下 1.1% 的异常值是应该逐个修复还是接受现状?你们的工程实践是怎样的?
如果你有任何建议或类似经验,欢迎在评论区交流或提 issue。