news 2026/10/10 0:02:58

PDF表格转Excel实战:智慧城市试点名单解析与清洗指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PDF表格转Excel实战:智慧城市试点名单解析与清洗指南

简介:2013年度国家智慧城市试点名单是一份PDF格式的官方清单,面向智慧城市研究者、城市规划从业者及政策分析人员,用于快速查证国家级试点布局与覆盖范围。资源共1个PDF文件,压缩包仅32KB,内容紧凑,便于在移动设备上随时查阅。已有129人学习,适合作为区域发展研究、政策沿革梳理或申报参考的基础资料。名单将试点分为市/区级83个、县/镇级20个以及2012年试点扩大范围9个地区,详细列出北京经济技术开发区、天津武清区、重庆永川区等具体试点,并补充了常州新北区、南京高淳区等扩展区域,能够支撑对不同层级智慧城市试点特征的对比分析,也可为理解我国早期智慧城市推进路径提供直观依据。

1. 智慧城市试点名单.pdf:为什么一张名单卡住了整个数据流程

做智慧城市相关项目的人,几乎都摊上过同一件事:拿到一份“智慧城市试点名单.pdf”,领导说把名单里的城市梳理出来,跟业务台账对一遍,给个覆盖度结论。你以为是 Excel 活,结果用 PDF 阅读器全选复制,粘贴出来全是错位。PDF 里那张表看起来是个表格,实际上内部只有一堆文字坐标和线条,根本没有“行”和“列”。把它变成能统计、能筛选、能比对的数据表,不是复制粘贴能解决的,要靠解析、坐标判断、清洗对齐一整条链路。这篇文章要讲的就是这件事:把这种名单 PDF 抽成结构化表格,讲清楚怎么选工具、参数怎么设、在哪些环节翻车。适合做数据分析、方案支撑、区域覆盖评估的人,新手可以照着跑通第一版,熟手能补上平时容易漏的细节。

2. 判断 PDF 形态:文本层、扫描件与表格边线的三类差异

2.1 为什么 PDF 里的表格不能直接“全选复制”

名单类 PDF 往往由排版工具生成,视觉上是一个规整的表格,但 PDF 内部只记录三件事:文字内容、文字摆放坐标、绘制线条的操作。你在阅读器里看到的“第 2 行第 3 列”,是由线条框出来的位置决定的,不是数据结构决定的。所以复制文本时,阅读器会按自己理解的顺序重排,表头多一行、列错位,甚至把页脚也带进来。

另外,PDF 分成文本型和扫描型两种。文本型里面有可提取的文字层,扫描型本质是一张张图片,必须过 OCR。同一份“智慧城市试点名单.pdf”,不同批次来源可能完全不一样:有的是印刷后扫描的,有的是直接导出的,还有的是在表格软件里“另存为 PDF”,这三种处理方式完全不同。先判断形态,再决定解析方案。判断成本很低,十分钟不动手,后面可能要返工一整天。

2.2 用一段代码判断 PDF 有没有文本层

我一般直接拿 PyMuPDF 判断,速度快,代码也短:

import fitz # PyMuPDF,安装名 pymupdf doc = fitz.open("智慧城市试点名单.pdf") for i, page in enumerate(doc, 1): text = page.get_text("text") print(f"第{i}页文本长度: {len(text)}") if len(text) > 0: print(text[:300]) break

先跑一遍,观察输出。如果有正常文字,说明是文本型;如果所有页文本长度都是 0,那就是扫描件。逻辑很简单:get_text("text")会把当前页里的文本块按阅读顺序拼接。注意别用阅读器的“复制粘贴”来判断,因为阅读器会做智能重排,会伪造视觉文本,数据其实并不都在文本层里。参数方面,get_text("dict")拿的是带坐标的结构,"text"只拿纯文本,判断阶段用后者就够了。

2.3 表格型名单优先选 pdfplumber,还是 PyMuPDF

选工具是这份工作里第一个坑。很多人拿到 PDF 就写代码,结果抽出来一团乱,其实不是代码问题,是工具不匹配。

工具擅长场景短板建议
pdfplumber有细线边框的表格,解析行列边界页数多时慢,复杂合并单元格容易错首选,名单多为印刷线表
PyMuPDF无边框但版式规整的文本,速度快不直接理解表格结构适合纯文本抽取和坐标分析
Camelot线框明显、表头规整的大表单依赖 Ghostscript 和 OpenCV,环境难配数据量大且表格规范时才用
OCR 工具链扫描件中文形近字错误难避免只有扫描型才值得上

名单 PDF 多数有横竖线,我会优先试 pdfplumber 的extract_table,因为它的线条检测对印刷表格更稳。PyMuPDF 适合先摸坐标,也适合处理没有线框的名单。Camelot 虽然抽取效果不错,但对环境要求太折腾,一套自动化流程里多一个外部依赖就多一个跑不起来的理由。判断标准就一句话:有明确表格线,用 pdfplumber;没有线,用坐标聚行;有图,才考虑 OCR。

2.4 扫描件名单:先转图片再做 OCR

如果判断结果是扫描件,不要直接把 PDF 丢给 OCR 工具,效果会很差。先把页面转成灰度图,DPI 至少给到 300,再按页切出来:

mkdir -p pages pdftoppm -r 300 -gray 智慧城市试点名单.pdf pages/page

转出来的pages/page-1.png这类文件,再交给 OCR 引擎处理。这里有两个参数要留意:-r 300控制分辨率,低于 200 DPI 时小字号会糊;-gray去掉彩色杂讯,能明显减少误识别。OCR 之后还有一层纠错工作,后面第 5 章会单独说。

3. 把试点名单抽成可用数据表:从 PDF 坐标到 CSV 的操作

3.1 先定位表格的物理边界:页眉、页码和表格区

直接抽表经常会把页眉页脚带进来。我的习惯是先不急着解析表格,而是把每一页的文本块坐标打印出来,看页面上有哪些重复出现的区域。

import pdfplumber with pdfplumber.open("智慧城市试点名单.pdf") as pdf: page = pdf.pages[0] for w in page.extract_words(): y = round(w["top"], 1) text = w["text"] if text.strip(): print(y, w["x0"], text)

输出会是一大串坐标。看top值,页面顶部的固定字段(比如文件名、版本号)通常会稳定出现在同一位置;底部往往有“第 1 页”之类的页码。找到这些区域后,在真正解析时把它们裁掉。最简单的方法是记录两个阈值:从top=50到top=740之间才保留,具体数值根据你这份名单的版式定。这一步做 5 分钟,能省掉后面大量的脏数据清洗。

3.2 用 pdfplumber 的 extract_table 提取多页名单

确认是文本型、有边框后,用extract_table才是正路。下面这段是我常用的模板:

import pdfplumber all_rows = [] with pdfplumber.open("智慧城市试点名单.pdf") as pdf: for pno, page in enumerate(pdf.pages, 1): table = page.extract_table( { "vertical_strategy": "lines", "horizontal_strategy": "lines", "snap_tolerance": 3, "join_tolerance": 3, "edge_min_confidence": 50, } ) if not table: continue for row in table: # 统一空值,去掉首尾空格 clean_row = ["" if cell is None else cell.strip() for cell in row] # 第一列拼上页码,方便回溯 all_rows.append([pno] + clean_row) # 当前页第一行如果不是表头,说明上一页表格跨页了 if len(all_rows) > 0: print(f"第{pno}页,抽取行数: {len(table)}")

这段代码的核心逻辑是先按线框定位表格,再把每一页的行追加到总表里。参数里vertical_strategy="lines"表示只按页面上检测到的竖线来切分列,适合线框完整的表格;如果这份名单是“无边框但对齐”的排版,就把这个值改成"text",它是按文字坐标对齐来推断列边界的。snap_tolerance=3控制在多少个像素内被当作同一根线,数值太小会把一条线拆成两条,太大又可能把两列并成一列。join_tolerance=3控制断线接续的容差。edge_min_confidence=50表示置信度低于 50% 的边缘不参与计算。

还有一件事:多页名单通常每页都重复表头。上面代码没有过滤表头,我一般会在写文件时把重复内容和第一页表头做对比,看看是否完全一致,一致就丢一行。这个逻辑放在第 4 章清洗阶段更顺手。

3.3 没有线框的名单怎么兜底:按坐标聚行再正则补列

有些 PDF 表格没有画线,只是每个单元格文字印刷在对齐的位置上,vertical_strategy="lines"会什么都检测不到。这时可以退一步,用提取出来的单词坐标来拼行。

import pdfplumber from collections import defaultdict def words_to_rows(page, y_tolerance=3): words = page.extract_words() rows = defaultdict(list) for w in words: # 以 top 坐标所在行作为 key,y_tolerance 是行判断容差 key = round(w["top"] / y_tolerance) rows[key].append(w) # 每一行按 x0 排序,再拼成文本 line_rows = [] for key in sorted(rows.keys()): line = " ".join( w["text"] for w in sorted(rows[key], key=lambda x: x["x0"]) ) line_rows.append(line) return line_rows

这个函数的原理很朴素:同一个表格行里的词,y 坐标应该非常接近;不同行之间的距离通常远大于单词高度。所以我把top坐标除以容差再取整,落在同一桶里的词就视为同一行。y_tolerance=3是一个合适的起步值;如果行距特别小,比如 10 号字配 12 磅行距,就得加大到 5 以上。拼出来的行再交给正则表达式处理,比如把“省份”“城市”“备注”之间的分隔切出来。

这种按坐标聚行的方式没有extract_table准确,但它能兜住最差情况。对“智慧城市试点名单.pdf”这种命名规整的文件,通常 90% 的页面能靠 pdfplumber 解决,个别页面无边框,再用坐标方案补齐。

3.4 写出 UTF-8 BOM CSV,顺便清掉空白

数据抽出来之后,第一版先落 CSV。有个小细节:中文 CSV 如果要让同事用 Excel 直接打开不乱码,必须写成utf-8-sig,而不是默认的utf-8。

import csv with open("parse_result.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["页码", "省份", "城市", "区县", "备注"]) writer.writerows(all_rows)

utf-8-sig会在文件头写入 BOM 标记,Excel 看到后就知道这是 UTF-8,不写的话会按 GBK 打开,第一列全是乱码。这一步不需要任何额外参数,但几乎每次都有同事漏掉。

4. 清洗与对齐:从“能读”到“能算”的名单结构化

4.1 统一地名写法,解决“同一城市多个名字”

PDF 里的地名写法非常不稳定。同一座城市可能出现“A市”“A市(含所辖县区)”“A市全境”三种写法;有时候单元格里还混着换行符、全角空格。这导致后面统计数量直接对不上。我一般会先把文本里的空白和括号备注拆开处理:

import re def normalize_place(raw): if not raw: return "" # 去掉全角空格、普通空格、换行 raw = raw.replace("\u3000", "").replace(" ", "").replace("\n", "") # 把所有括号内容暂时拿出来,作为备注信息保存 note = re.findall(r"[((]([^()()]*)[))]", raw) # 去掉括号内容 raw = re.sub(r"[((][^()()]*[))]", "", raw) if raw.endswith("市") and "市市" in raw: raw = raw.replace("市市", "市") return raw.strip(), ";".join(note)

这里的关键是把“备注”和“主体名称”拆开,而不是直接删掉。因为“含所辖县”这类信息在后续覆盖度计算里可能还要用到。正则里[((]...[])]前后两个字符要匹配中文括号和英文括号,很多 PDF 转换后括号会混用,只处理一种就会留尾巴。替换市市这种写法,是因为列表里经常出现“XX市市”的脏值。

4.2 用行政区划代码做唯一键,而不是拿地名做键

地名清洗完了,另一个问题马上浮出来:同名不同级。比如我们手头有“澜川市”和“澜川新区”,光靠名字无法判断是不是同一个区域。唯一可靠的办法是维护一张本地行政区划代码表,至少有四个字段:

字段名示例说明
code3301026 位区划代码
province某省省级名称
city澜川市市级名称
district苍梧区区县级名称

拿到解析结果后,不要直接字符串匹配,而是先尝试把“省市县”拼成一个标准区划名,再跟代码表关联。如果名单只到市一级,就取代码前 4 位匹配;如果到区县级,用全 6 位匹配。用 pandas 做这个对齐非常快:

import pandas as pd # clean_df 是第3章解析结果,region_df 是本地区划表 clean_df["市代码"] = clean_df["城市"].map(city_code_mapping) merged = clean_df.merge( region_df, left_on="市代码", right_on="code", how="left", validate="m:1", )

city_code_mapping是一个 dict 或 Series,提前把城市名映射成前 4 位代码。validate="m:1"会检查右边没有重复映射,如果有重复直接报错,能提前发现问题。用代码做唯一键之后,多期名单之间比对,或者与业务台账匹配,都不再担心“鹅城市”和“峨城市”这种差异。

4.3 多期名单 Diff:找出新增、退出和改名的城市

试点名单会更新,今天拿到的这版,跟你上个月手里那版可能不一样。直接比较文本不靠谱,因为排版变了,但行政区划代码不会变。我会把两期名单分别解析成同样的结构,然后对“代码”做差集:

old = set(old_df["市代码"]) new = set(new_df["市代码"]) added = new - old removed = old - new print("新增:", added) print("退出:", removed)

如果出现同一个城市被移除的同时又一个新名称进去,先查代码映射,往往是改名,而不是真的试点调整。改名的判断规则是:removed里某个城市的名称和added里某个名称在区划代码表里指向同一个code。脚本只能帮你筛出来,最终确认还是要人工看一眼。

5. 智慧城市名单解析避坑:5 个最容易翻车的地方

5.1 表格跨页,行数据被拦腰截断

现象:某个城市名称出现在第 3 页底部,所属区县跑到了第 4 页顶部,extract_table把它们当成两行,导致汇总时多出一个“只有市名没有区县”的脏行。

原因:PDF 表格跨页时,打印排版把同一逻辑行拆开了,但结构上没有合并标记。代码按页面处理,天然不懂跨页关系。

解决:处理多页时,先记录上一页最后一行;下一页第一行如果与它字段语义互补,就手动拼接后再入列。更省事的方法是按“市代码”做二次合并:清洗后如果发现同一行左边有城市、右边无区县,且下一行左边无城市、右边有区县,就把两者拼起来。

5.2 多级表头把全表挤到了一列

现象:解析结果第一行只有“序号、城市、批次、备注”,第二行又出来一个“序号、城市、批次、备注”,甚至中间夹了一层“一级指标、二级指标”。

原因:PDF 表头有合并单元格,extract_table会把每一行都解读成独立的数据行,表头行不只一行。

解决:用第一页提取出的表头做模板,后续页凡是与模板字段顺序一致的行,直接过滤。过滤条件要严格一点:不只比文字,还比较列数,列数一样才可能是重复表头。

5.3 OCR 把相近字认错

现象:扫描版名单里,“临江市”被 OCR 识别成“临汪市”,“青朔区”变成“青塑区”,市代码匹配对不上。

原因:中文 OCR 引擎对形近字识别容易出错,尤其在小字号、灰度不均的情况下。

解决:OCR 之后必须做一步行政区划匹配校验。做法是先把 OCR 结果的地名跟本地区划词表做精确匹配,匹配不上的全部标记成“疑似错误”,再人工审核。不要试图用 OCR 置信度来兜底,置信度高不等于字对,最终判断依据还是区划代码表。

5.4 页脚“第 1 页,共 30 页”混进表格

现象:解析结果最后一行多出“第 1 页 共 30 页 2024年”,甚至被当成一个新城市。

原因:页面底部的页码区域与表格下边界重合,pdfplumber 的线框检测把页码文字包进了表格最后一列。

解决:在第 3.1 节定位边界的时候,把页面底部一定阈值内的文本直接排除掉。更稳妥的办法是判断文本内容:如果某一行以“第”开头并且包含“页”和“共”,直接丢弃。别依赖页眉页脚检测,因为打印设置不同。

5.5 合并单元格让某行某个字段为空

现象:连续多行属于同一个省份,表格只在第一行写“某省”,下面行该字段为空,后续筛选省份时这些城市全部丢了。

原因:合并单元格在 PDF 里没有任何标记,解析后就是“空字符串”。

解决:清洗阶段对“省份”这类层级字段做上一近邻填充。用 pandas 的ffill()能解决,但要注意顺序,先按原始页码排序再填充,否则跨页后可能把上一页的省份带到下一页。还要结合业务逻辑:如果该字段本来就可为空,不应该填充,这一步得人工确认字段语义。

6. 把名单接入业务:自动对比覆盖度并保留来源

6.1 生成“已覆盖/未覆盖”对照表

名单清洗成结构化表后,最直接的用途就是跟业务台账做覆盖度比对。用一个merge加一个query就能输出未覆盖清单:

covered = business.merge(list_df, left_on="区划代码", right_on="区划代码", how="inner") uncovered = business[~business["区划代码"].isin(list_df["区划代码"])] print(f"覆盖 {len(covered)} 个区域,未覆盖 {len(uncovered)} 个区域")

how="inner"取交集,isin反向筛未覆盖。这里唯一要注意的是两边区划代码长度必须一致,否则一个 4 位一个 6 位,对不上。

6.2 保留页码和文本行号做血缘

我后来养成了一个习惯:从第 3 章开始,每一行都带着“来源页码、原始文本、清洗后的结构”三列。当时在交付一份覆盖分析结果时,对方问某个城市是从名单哪里来的,我如果只给一个清洗结果,完全说不清。后来改成每行保留原始串和页码,溯源只要 10 秒。这个做法的代价是文件多几列,收益却是排错效率翻倍。

6.3 一个让我少吃很多亏的习惯

以前我把解析完的数据直接覆盖 CSV,心想反正名单还能再下载,结果第二周同份 PDF 重新解析,竟然因为排版微调多出几行。从那以后,我每次解析都在第一列加解析日期,CSV 文件名也带日期后缀。别人如果不确定数据版本,看文件名就知道哪一版是新的。名单处理不是一次性劳动,把它做成能重复跑的流程,后面每次更新只要一条命令。希望帮到你。

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

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

Python中类的mro与继承关系详解

前言 MRO 是 Method Resolution Order 的缩写,中文常译作"方法解析顺序"。它回答一个问题:当一个实例调用某个方法时,Python 按什么顺序去各个类里找它? 单继承时这个问题看起来没什么可讲的——子类没有就往上找父类&a…

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

Java工程师必学:J2SE核心基础与实战避坑指南

1. 为什么每个Java工程师都绕不开J2SE这个地基刚入行那会儿,我也觉得J2SE就是本厚得能砸核桃的说明书,翻两页就犯困。直到第一次面试被问到“HashMap的hashCode和equals到底怎么配合工作”,我才意识到,那些看似枯燥的基础&#xf…

作者头像 李华
网站建设 2026/10/9 23:55:44

抖店开通与运营全流程指南:资质合规、内容驱动、闭环成交

1. 抖店不是“开个网店”那么简单:先搞清它到底是什么、适合谁、能解决什么问题抖店不是淘宝或拼多多的翻版,更不是把商品拍照上传就完事的简易工具。它是抖音生态内嵌的、深度绑定内容流量的交易闭环系统——简单说,它把“刷到一个视频→被种…

作者头像 李华
网站建设 2026/10/9 23:52:08

Flink+HBase电商实时链路:亿级QPS下的状态管理与低延迟写查实践

简介:本资源是一份聚焦实时大数据架构落地的深度技术文档,面向大数据开发工程师、实时计算方向从业者及Flink/HBase进阶学习者,解决高并发、低延迟电商场景下实时数据处理与存储协同难题。文档系统解析阿里巴巴电商业务中Flink流式计算与HBas…

作者头像 李华
网站建设 2026/10/9 23:44:48

Java Future.get超时与cancel方法实战:避免线程池雪崩

1. 从一次线上事故说起:为什么get超时和cancel总被忽略很多写过Java并发的人都有过这种经历:代码里用线程池提交任务,调用Future.get()拿结果,本地测试一切正常,上线后某个下游接口偶尔抽风,整个线程池被拖…

作者头像 李华