PDF表格转Excel实战:智慧城市试点名单解析与清洗指南
发布时间:2026/10/10 0:03:03 锦皓数字建站

简介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 页”之类的页码。找到这些区域后在真正解析时把它们裁掉。最简单的方法是记录两个阈值从top50到top740之间才保留具体数值根据你这份名单的版式定。这一步做 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_strategylines表示只按页面上检测到的竖线来切分列适合线框完整的表格如果这份名单是“无边框但对齐”的排版就把这个值改成text它是按文字坐标对齐来推断列边界的。snap_tolerance3控制在多少个像素内被当作同一根线数值太小会把一条线拆成两条太大又可能把两列并成一列。join_tolerance3控制断线接续的容差。edge_min_confidence50表示置信度低于 50% 的边缘不参与计算。还有一件事多页名单通常每页都重复表头。上面代码没有过滤表头我一般会在写文件时把重复内容和第一页表头做对比看看是否完全一致一致就丢一行。这个逻辑放在第 4 章清洗阶段更顺手。3.3 没有线框的名单怎么兜底按坐标聚行再正则补列有些 PDF 表格没有画线只是每个单元格文字印刷在对齐的位置上vertical_strategylines会什么都检测不到。这时可以退一步用提取出来的单词坐标来拼行。import pdfplumber from collections import defaultdict def words_to_rows(page, y_tolerance3): words page.extract_words() rows defaultdict(list) for w in words: # 以 top 坐标所在行作为 keyy_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], keylambda x: x[x0]) ) line_rows.append(line) return line_rows这个函数的原理很朴素同一个表格行里的词y 坐标应该非常接近不同行之间的距离通常远大于单词高度。所以我把top坐标除以容差再取整落在同一桶里的词就视为同一行。y_tolerance3是一个合适的起步值如果行距特别小比如 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, encodingutf-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_oncode, howleft, validatem:1, )city_code_mapping是一个 dict 或 Series提前把城市名映射成前 4 位代码。validatem: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区划代码, howinner) uncovered business[~business[区划代码].isin(list_df[区划代码])] print(f覆盖 {len(covered)} 个区域未覆盖 {len(uncovered)} 个区域)howinner取交集isin反向筛未覆盖。这里唯一要注意的是两边区划代码长度必须一致否则一个 4 位一个 6 位对不上。6.2 保留页码和文本行号做血缘我后来养成了一个习惯从第 3 章开始每一行都带着“来源页码、原始文本、清洗后的结构”三列。当时在交付一份覆盖分析结果时对方问某个城市是从名单哪里来的我如果只给一个清洗结果完全说不清。后来改成每行保留原始串和页码溯源只要 10 秒。这个做法的代价是文件多几列收益却是排错效率翻倍。6.3 一个让我少吃很多亏的习惯以前我把解析完的数据直接覆盖 CSV心想反正名单还能再下载结果第二周同份 PDF 重新解析竟然因为排版微调多出几行。从那以后我每次解析都在第一列加解析日期CSV 文件名也带日期后缀。别人如果不确定数据版本看文件名就知道哪一版是新的。名单处理不是一次性劳动把它做成能重复跑的流程后面每次更新只要一条命令。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。