求助帖:pdf复杂表格解析

用 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:
    # 用行政区标记找分界

但问题是:

  1. 有些行的水体列为空(地下水类),导致字段数不一致
  2. 水体名本身也会断行(如 苏北灌溉总\n渠
  3. 水体名和水源地名的边界不清晰

最终方案: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 的右边。

我们的思路

  1. 后处理映射表 :维护一个 {截断名: 完整名} 映射,在入库前做查找替换。缺点是需要手动维护,且不同年份的同名水源地可能略有变化。
  2. 检查超标信息字段 :如果 exceedance_info 的开头看起来像水源地名的延续,尝试拼接回来。但超标信息的格式不固定,判断困难。
  3. 接受现状:这只影响 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 用"南京"、部分用"南京市",属于格式不统一而非碎片。


寻求帮助

如果你有以下经验,欢迎交流:

  1. 中间截断修复:在不修改 PDF 原文的前提下,如何检测和修复 pypdfium2 产生的中间截断?特别是当尾部碎片被错误归类到其他字段时。

  2. PDF 解析库选型:除了 pypdfium2、pdfplumber、camelot、pymupdf,还有哪些库适合处理左右开口表格(有上下边线和列间分隔线,但无左右外边框)?pdfplumber 需要完整单元格闭合边界才能识别 table,我们的表格左右两端不闭合导致提取失败。我们的需求是纯文本提取(不需要图片),但需要保持表格的行列结构。

  3. 长尾数据质量治理:当 98.9% 的数据已经正确,剩下 1.1% 的异常值是应该逐个修复还是接受现状?你们的工程实践是怎样的?


如果你有任何建议或类似经验,欢迎在评论区交流或提 issue。

相关推荐
充钱大佬10 小时前
Python测试基础教程
python·log4j·apache
初心丨哈士奇12 小时前
Python 四大基础容器|列表篇
python
明理的信封13 小时前
AI 基础设施的“去 Python 化“:Rust 与 C# 的两条替代路径
人工智能·python·rust
麻雀飞吧14 小时前
2026年AI量化开发,先跑通小流程再加复杂功能
人工智能·python
daphne odera�14 小时前
PyCharm 中 Codex 插件启动失败:unknown variant default 的解决方法
python·chatgpt·pycharm
nbu04william15 小时前
Deepseek-api省token的用法
python·大模型·token·deepseek
测试老哥15 小时前
Pytest自动化测试详解
自动化测试·软件测试·python·测试工具·测试用例·pytest·接口测试
坚持学习前端日记15 小时前
国产化适配全流程适配英伟达本地开发
人工智能·python
大飞记Python15 小时前
Linux命令速查手册(测试开发4年实战总结,附PDF)
linux·网络·pdf