
用 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左右外边框竖线无0x90 和 x515 处没有竖线单元格背景矩形有180 个部分列有背景框非全部列表格有上下边线、行间分隔线和列间竖线但左右两端是开口的——没有任何竖线标记表的左右边界。pdfplumber 的 text 策略也只能提取出表格上方的文字段落无法还原行列结构。最终方案使用 pypdfium2 提取文本按 y 坐标分行按 x 坐标排列然后通过正则 规则引擎进行结构化解析。这个方案对近年数据2021效果很好但对早年数据2016-2020仍有碎片化问题。江苏的数据规模月报总数119 篇全部成功解析覆盖时间2016-08 ~ 2026-06江苏省生态环境厅从 2016 年 8 月开始公开月报总记录数14,193 条每月约 103~130 条记录PDF 格式演变三种格式不能用一套逻辑这是最大的坑之一江苏的月报 PDF 在不同年份有完全不同的表格结构。格式 A2016-20178 列序号 | 城市 | 区县 | 水体 | 水源地名称 | 级别 | 达标情况 | 超标倍数特点列数多8列单元格窄长名称频繁断行。水体列有值如长江、“淮河”。格式 B2018-20207-8 列序号 | 城市 | 区县 | 水体 | 水源地名称 | 级别 | 达标情况 | [超标信息...]特点与格式 A 类似但超标信息替代了超标倍数可能被拆成多个 parts。水体列部分为空白地下水类。格式 C2021-20265-6 列序号 | 城市 | 区县 | 水源地名称 | 级别 | 达标情况 | [超标信息...]特点取消了水体列列数减少到 5-6 列单元格变宽基本无断行问题。踩坑最初我们用列数判断格式# ❌ 错误做法靠列数判断iflen(parts)7:# 格式 A/Belse:# 格式 C不达标行的超标信息会被拆成多个 parts导致列数不准确。最终改用level 锚点定位# ✅ 正确做法用内容做锚点LEVEL_VALUES{地级,县级,市级,备用}COMPLIANCE_VALUES{达标,不达标,超标,—,--}foriinrange(3,len(row)-1):ifrow[i]inLEVEL_VALUESandrow[i1]inCOMPLIANCE_VALUES:level_idxibreak无论超标信息被拆成多少 parts地级/达标或县级/不达标这对相邻关系始终成立。这个锚点把左边的内容水体 水源地名和右边的内容超标信息可靠地分开。问题 1pypdfium2 断行——长文本在窄列中被截断严重程度高早年数据的主要污染源影响范围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 0parse_pdf_bytes 阶段跨行续行拼接# 定位每个序号行把到下一个序号行之前的所有行合并fork,startinenumerate(seq_idx):endseq_idx[k1]ifk1len(seq_idx)elselen(page_lines)combined .join(page_lines[start:end])Phase 2尾部碎片合并当left_fields的最后一个字段是明显的断行残留时合并到前一个字段# 精确匹配已知的碎片字符串_TAIL_FRAG_REre.compile(r^(护区|保护区|源保护区|急水源地|下水应急水源地|应急水源地|水水源地|水源保护区)$)# 单字碎片iflastin(地,水源,水源地,源地,源,水,区):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 行政区续接whileleft_fields:ifdistrict.endswith(、):# district 以 、 结尾还没完无条件合并districtdistrictleft_fields.pop(0)elif、indistrictand、inleft_fields[0]\andnot_ADMIN_MARKERS.search(left_fields[0]):# 两者都含 、 且下一个字段不含行政区标记 → 还在列举区县districtdistrictleft_fields.pop(0)else:break关键判断当left_fields[0]包含行政区标记县/区/市/镇/乡时说明已经到了水源地名称的范围停止合并。问题 3水体列干扰——早年 PDF 有额外的水体列严重程度高影响范围2016-2020解决状态✅ 已解决通过丢弃 water_name问题描述2016-2020 的 PDF 有独立的水体列2021 取消了。left_fields中可能包含 1-3 个字段需要区分哪些是水体、哪些是水源地名。最初的方案是用left_count判断# ❌ 不可靠ifleft_count2:water_name,source_nameleft_fieldselifleft_count3:# 用行政区标记找分界但问题是有些行的水体列为空地下水类导致字段数不一致水体名本身也会断行如苏北灌溉总\n渠水体名和水源地名的边界不清晰最终方案source_name 优先级最高丢弃 water_name用户明确要求source_name 的优先级比 water_name 高如果能在丢弃 water_name 的前提下保证 source_name 100% 正确就可以。ifyear_int2020:water_name# 放弃 water_nameifleft_count1:source_nameleft_fields[0]elifleft_count2:source_nameleft_fields[1]# [0] 是水体else:# left_count 3用行政区标记找 source 起点_ADMIN_REre.compile(r[县区市])split_idxfind_admin_boundary(left_fields)source_name.join(left_fields[split_idx:])else:# 2021 无水体列water_namesource_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 行示例# pypdfium2 提取的原始行[27,徐州,市区/,徐州市铜山区汉王地,下水应急水源地,地级,不达标,总大肠菌群...]这里left_fields [徐州市铜山区汉王地, 下水应急水源地]Phase 2 能合并成完整名。但如果断行更极端[27,启东市,启东,启东市通吕运河吕四,应急水源地,地级,达标]Phase 2 会把应急水源地合并回来已修复。但在极少数情况下尾部碎片恰好和 level 锚点之间没有其他字段隔开导致解析器把它归入了 level 的右边。我们的思路后处理映射表维护一个{截断名: 完整名}映射在入库前做查找替换。缺点是需要手动维护且不同年份的同名水源地可能略有变化。检查超标信息字段如果exceedance_info的开头看起来像水源地名的延续尝试拼接回来。但超标信息的格式不固定判断困难。接受现状这只影响 13 条记录占总量 0.1%且全部在 2017-2018 年。想请教有没有更通用的方法处理这种中间截断 尾部丢失的问题特别是在无法修改 PDF 原文、无法访问其他解析库的情况下。附其他踩坑记录pypdfium2 非线程安全# 并发调用 PdfDocument 会导致 segfault# 必须加全局锁_PDFIUM_LOCKthreading.Lock()defparse_pdf_bytes(pdf_bytes):with_PDFIUM_LOCK:pdfpdfium.PdfDocument(pdf_bytes)# ... 解析逻辑 ...我们的采集系统是多线程的发现 pypdfium2 在高并发下偶尔崩溃。加上全局锁后稳定。year/month 不能从发布日期提取最初我们从网页上的发布日期提取年月但发布日期通常比月报的实际月份晚 1-2 个月。例如 2023 年 2 月的月报发布日期可能是 2023-03-06。改用标题提取defextract_year_month(text:str)-tuple[str,str]:mre.search(r(\d{4})[-/年\s]*(\d{1,2}),text)# 从标题 2023年2月全省县级及以上... 提取 (2023, 2)year 字段类型陷阱pipeline 传给 parser 的 year/month 是字符串类型2018但 parser 内部做数值比较year 2020Python 会抛出TypeError: not supported between instances of str and int。# 别忘了类型转换year_intint(year)ifyearelse0quality 字段不能复制 compliance江苏的 PDF 中没有水质类别列只有达标/不达标但我们在入库时曾错误地把 compliance 的值复制给了 quality。最终改为空字符串quality:,# 江苏 PDF 无水质类别列最终数据质量时间段总记录source_name 碎片城市数状态2016-08~2016-12509骆马湖×21原始短名13✅20171,227骆马湖×21 少量截断13⚠️20181,43813 种截断名各 1 次26*⚠️20191,439同上25*⚠️20201,469无14✅2021-20268,111013-14✅*城市数偏多是因为同一年份中部分 PDF 用南京、部分用南京市属于格式不统一而非碎片。寻求帮助如果你有以下经验欢迎交流中间截断修复在不修改 PDF 原文的前提下如何检测和修复 pypdfium2 产生的中间截断特别是当尾部碎片被错误归类到其他字段时。PDF 解析库选型除了 pypdfium2、pdfplumber、camelot、pymupdf还有哪些库适合处理左右开口表格有上下边线和列间分隔线但无左右外边框pdfplumber 需要完整单元格闭合边界才能识别 table我们的表格左右两端不闭合导致提取失败。我们的需求是纯文本提取不需要图片但需要保持表格的行列结构。长尾数据质量治理当 98.9% 的数据已经正确剩下 1.1% 的异常值是应该逐个修复还是接受现状你们的工程实践是怎样的如果你有任何建议或类似经验欢迎在评论区交流或提 issue。