
简介这是一份2009年安徽省国土绿化状况公报以官方口径梳理当年全省国土绿化工作的推进情况适合从事林业规划、生态建设研究或需要查阅区域绿化历史数据的读者。压缩包内仅含1个doc格式文档大小31KB内容浓缩、便于快速查阅。文档按义务植树、林业重点工程、乡村绿化、城镇绿化、部门绿化、林业改革等板块展开包含全民植树人数与株数、成片造林面积、绿色长廊里程、城市绿化覆盖率等翔实数据并披露了集体林权制度改革与森林生态效益进展可作为了解安徽生态文明建设和林业政策落实成效的一手档案。目前已有73人学习浏览适合对国土绿化、生态建设或林业改革感兴趣的读者直接引用或对比研究。1. 从2009年安徽省国土绿化状况公报.doc里挖数据先解决格式兼容做生态数据对接时一份“2009年安徽省国土绿化状况公报.doc”送到手上意味着一个旧式二进制文档要重新进入数据管道。这个标题看起来像文书归档实际是一道典型的历史数据处理题把Word 97-2003格式的林业统计内容变成可查询、可对比的结构化记录。难点不在“国土绿化状况”这几个字而在后缀“.doc”。它默认指向OLE2复合文档正文藏在WordDocument流里和GBK中文、表格排版、页眉页脚混在一起。直接改扩展名另存为或者用文本编辑器打开只会看到大段乱码和不可见控制符。这篇内容就是把处理这类历史公报的路径拆开讲识别真实格式、无头转换、正则抽取、交叉校验。适合要给历年绿化数据做回溯的GIS工程师、林业信息化运维以及被堆积如山的公文逼着做数据分析的人。2. 识别.doc的真实身份OLE2复合文档与WordDocument流2.1 用file和xxd先看魔数再决定解析策略我一般先把文件丢到Linux服务器上跑两个命令而不是直接双击打开。第一个是file第二个是xxd用来确认它到底是不是真正的Word 97-2003二进制文档file 2009年安徽省国土绿化状况公报.doc xxd -l 16 2009年安徽省国土绿化状况公报.docfile的输出如果类似“Composite Document File V2 Document, Little Endian, Os: Windows, Version 10.0, Code page: 936”说明它是一个OLE2复合文档Code page 936表示文档内部使用简体中文GBK编码。此时无论后面要调用LibreOffice还是antiword前提条件都成立。xxd看前16字节更直接。真正的.doc文件头前8个字节通常是d0 cf 11 e0 a1 b1 1a e1这是OLE2格式化文件的魔数。如果看到的是50 4b 03 04文件实际是zip容器多半是把.docx或另存的zip直接改了后缀得先重命名再处理。这个判断步骤两分钟内可以完成却能把后续排查时间省掉一大半。还有一种容易翻车的情况从Windows上传到Linux时文件名里的中文变成问号用通配符或Tab补全可以避免手抖文件内容本身如果有尾部垃圾字节file不一定报警xxd能看到异常。注意file输出里的“Code page: 936”只是Word写入时的默认代码页不等于文本实际编码转换时仍要以输出内容是否乱码为准。2.2 用olefile把复合文档的流目录列出来确定了OLE2身份后再用Python的olefile库读取内部结构。这个库不解析Word格式本身只负责把复合文档里的流暴露出来import olefile ole olefile.OleFileIO(2009年安徽省国土绿化状况公报.doc) print(ole.listdir()) stream ole.openstream(WordDocument) head stream.read(64) print(head[:16].hex()) print(0Table exists:, ole.exists(0Table)) print(1Table exists:, ole.exists(1Table))这段代码的逻辑是先列出文档的所有流目录再打开名为WordDocument的主流读取前64字节并打印前16个字节的十六进制值。最后通过exists方法检查是否存在0Table或1Table流。参数说明olefile.OleFileIO的构造参数是文件路径listdir返回的是二维列表每个元素是一个流路径openstream返回一个文件对象支持read、seek等操作。对Word 97-2003来说WordDocument是必有流而0Table或1Table至少存在一个。如果listdir里只有WordDocument而没有Table流这个文件很可能损坏后面转换时会出现“The document is corrupt”之类的错误。2.3 FIB里最值得关注的三个信息点正文偏移、字符编码、文本长度把流打开之后手工提取正文需要解析FIBFile Information Block。FIB是一大段位域和字段的组合深究每个位的含义对日常工作没有必要但值得知道三个字段文本起始位置fcMin、正文压缩标志fExtChar、正文字符数ccpText。用工具解析时最常见的现象分两种如果输出是一串可读的GBK中文说明文档正文按8位字符压缩存储如果输出每两个字节夹一个空字节说明正文以UTF-16存储。这两种情况下用文本处理工具直接读出的字符串完全不同。理解这一点就能解释为什么用strings命令扫.doc只能找到零星词汇以及为什么转换后数字和汉字之间会多出奇怪的空格。转换工具的本质就是先把OLE2解包再对照FIB把正文流按正确编码解码出来。不手工写解析器的前提下选择LibreOffice或antiword更稳妥。FIB里还有一个隐藏风险如果ccpText大于整个WordDocument流的实际长度说明文档可能带快速保存产生的冗余修订块或者传输时损坏。这种文件用自研解析器会越界LibreOffice会报警告但继续输出antiword则可能直接拒绝。遇到这种情况先把文件用LibreOffice另存为docx往往能绕开损坏区域。3. 用LibreOffice和antiword把.doc正文落成可解析的纯文本3.1 LibreOffice无头转换最短命令与参数含义对公文中常见的多级标题、左右缩进、横排表格LibreOffice的兼容性比大多数纯Python实现要好。在服务器上推荐用headless模式mkdir -p out soffice --headless --convert-to txt:Text (encoded):UTF8 \ --outdir out 2009年安徽省国土绿化状况公报.doc--headless表示不启动图形界面--convert-to后面的txt:Text (encoded):UTF8指定导出过滤器为带编码控制的纯文本输出UTF-8编码--outdir指定输出目录。命令执行成功后out目录下会出现同名的.txt文件。如果输出的中文变成问号通常是因为过滤器名称在某些LibreOffice版本里不接受冒号后的编码参数。此时把过滤器改成txt:Text再在命令末尾加一个显式的--infilterMicrosoft Word 97-2003强制把输入当作Word二进制格式处理。反过来的情况也遇到过不加infilter时LibreOffice自动识别没有问题加了反而触发宏安全提示。还在多进程批量转换上吃过亏LibreOffice第一次运行要初始化用户目录多个soffice进程同时启动会互相争用配置目录导致部分文件转换失败。解决方法是给每个进程指定独立配置目录。soffice -env:UserInstallationfile:///tmp/lo_profile_2009 \ --headless --convert-to txt:Text (encoded):UTF8 \ --outdir out 2009年安徽省国土绿化状况公报.doc参数-env:UserInstallation的file://前缀必须保留后面的路径指向一个新目录。这样同一台机器上并行跑十份年度公报也不会锁文件。提示--outdir要写在--convert-to之后部分LibreOffice版本对参数顺序敏感顺序颠倒时会忽略输出目录把文件直接写到当前工作目录。3.2 antiword低依赖环境下的备选方案如果生产服务器不装LibreOffice或者只是临时要看一个文件antiword更轻量。它是MB级别的C程序读取Word 97-2003的速度快也不会产生几GB的用户配置目录。antiword -m UTF-8.txt 2009年安徽省国土绿化状况公报.doc out.txt-m指定映射文件。antiword通过映射文件把Word内部的字符代码映射到当前字符集中文是否能正常显示取决于系统里有哪个映射文件。常见的位置是/etc/antiword和~/.antiword。如果不加-m输出乱码先查看映射文件列表里有没有UTF-8.txt或cp936.txt再选择合适的参数。某些发行版安装的antiword默认不带中文映射手动补齐映射文件比改文档本身要省事。LibreOffice和antiword的取舍可以列成一张表对比项LibreOfficeantiword安装体积数百MB依赖多约1MB无额外运行时转换速度冷启动慢批量时更明显单文件毫秒级表格保留接近原排版空格分隔清晰表格列容易粘连中文编码由过滤器控制依赖映射文件并发稳定性需独立UserInstallation天然支持并发从实践看我的默认路径是LibreOffice转全文antiword用于快速确认文件内容以及当LibreOffice因宏或损坏段报错时的兜底。3.3 转换后常见的三类脏数据页眉、空行、全角数字命令行工具把正文导出来后还不能直接拿去抽指标。公报这类文档通常有固定的页眉页脚转换结果是每页顶部都出现同一行文档标题还有大量空行以及从Word表格里带出来的制表符和全角空格。先做一步清洗再进正则阶段。import re raw open(out/2009年安徽省国土绿化状况公报.txt, encodingutf-8).read() raw raw.replace(\u3000, ) # 全角空格转半角 raw re.sub(r[ \t]\n, \n, raw) # 清理行尾空白 lines [ln.strip() for ln in raw.splitlines()] lines [ln for ln in lines if ln and len(ln.strip()) 1] text \n.join(lines)这里的逻辑是先把全角空格统一成半角防止后面正则里的\s漏匹配再删掉行尾多余空格和空行最后去掉长度为1的孤立行因为它们大概率是页码或装饰字符。注意不要在这里用re.sub(r\s, , raw)去掉所有空白那样会把中文句子连在一起指标名和数字之间的分隔也被破坏。清洗后可以把text保存回文件也可以直接在内存中继续处理。这一步做完后续的指标抽取才有稳定的输入。4. 从公报文本中抽取国土绿化指标正则、表格与单位清洗4.1 先定义目标字段表再写匹配规则抽取指标前先把公报里出现的常见说法列成表避免在正则里反复试错。2009年安徽省国土绿化状况公报这类文本通常包含森林覆盖率、造林面积、义务植树、湿地保护率等核心指标。下面是实际匹配时用到的字段定义沿用上一节清洗出的text变量作为输入指标名常见表述单位建议正则森林覆盖率森林覆盖率%森林覆盖率[\s\u3000]{0,8}(\d{1,2}(?:\.\d{1,2})?)\s*%造林面积完成造林面积、营造林面积万公顷(?:完成)?造林面积[\s\u3000]{0,6}(\d(?:\.\d)?)\s*万?公顷义务植树义务植树亿株义务植树[\s\u3000]{0,6}(\d(?:\.\d)?)\s*亿株湿地保护率湿地保护率%湿地保护率[\s\u3000]{0,8}(\d(?:\.\d)?)\s*%表格里的[\s\u3000]{0,6}是关键。公报是从排版系统导出的汉字和数字之间常夹着半角空格、全角空格或换行单纯的\s*不能匹配全角空格。给一个可变的短区间能容忍真实文本里一两个多余空格又不会跨越整个段落去匹配错误内容。4.2 用finditer提取所有命中而不是search同一份公报可能在概况、措施、成效三个部分重复提到同一个指标而且数字可能有出入。此时用re.search只会拿到第一处需要finditer把所有位置都找出来import re indicators [ { name: forest_cover_rate, pattern: re.compile( r森林覆盖率[\s\u3000]{0,8}(\d{1,2}(?:\.\d{1,2})?)\s*% ), }, { name: afforestation_area, pattern: re.compile( r(?:完成)?造林面积[\s\u3000]{0,6}(\d(?:\.\d)?)\s*万?公顷 ), }, ] for ind in indicators: for m in ind[pattern].finditer(text): value, span m.group(1), m.span() print(ind[name], value, span)这段代码先用compile把正则编译成对象再在清洗后的text上执行finditer。每次匹配返回一个Match对象group(1)取出第一个捕获组也就是纯数字部分span()返回匹配的起止位置用来定位原文片段。正则中的(?:完成)?是非捕获组表示“完成”这两个字出现或不出现都可以。\d(?:\.\d)?匹配整数和带一位以上小数的数字。输出结果里如果出现“森林覆盖率 27.53”、“造林面积 5.43”这类键值对说明匹配成功如果某个指标一个都没命中先打印原始文本确认是清洗步骤把指标名和数字之间的空格删掉了还是全角数字用了Unicode的0-9这种情况需要先做全角数字归一化。如果遇到全角数字比如.需要先映射到半角否则上面的正则全部落空。归一化可以在清洗阶段处理half {: 0, : 1, : 2, : 3, : 4, : 5, : 6, : 7, : 8, : 9, : ., : %} for k, v in half.items(): text text.replace(k, v)这段代码把常见全角字符替换成半角。之所以不直接调用unicodedata.normalize是因为它会把更多不必要字符做NFKC合并影响后续原文定位。4.3 表格坍缩后的行级解析公报里的分项数据经常以表格形式出现转换后表格线消失单元格被两个以上空格隔开。直接把行拆开会得到字段错位的乱序数组更稳妥的做法是先按两个以上空格切分再判断行首是否命中已知指标名。import csv import re rows [] lines text.splitlines() for line in lines: if not re.search(r\d, line): continue cols re.split(r[ ]{2,}|\t, line.strip()) if cols and (造林 in cols[0] or 覆盖率 in cols[0]): rows.append(cols) with open(indicators.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([指标, 数值, 单位, 原文]) for cols in rows: writer.writerow(cols [line.strip()])re.split(r[ ]{2,}|\t, line)是核心只按连续两个以上半角空格或制表符切分避免中文标题内部被单个空格拆开。切分后的列表长度如果小于3说明这行原本是普通句子而不是表格行跳过即可。写入CSV时用encodingutf-8否则在Windows下用Excel打开会乱码。如果表格里指标名和数字之间只有一个空格切分后列表长度会异常。这时候先做全角空格替换再尝试把连续空白压缩成一个\t能明显提高解析成功率。不要指望一行正则解决所有表格变体公报的排版年年变保留原文片段字段比追求一次解析干净更实用。5. 对2009年国土绿化公报提取结果做总量守恒和同比校验5.1 用分项之和校验总量公报里经常出现“全年完成造林面积5.43万公顷其中人工造林1.23万公顷飞播造林0.45万公顷封山育林3.75万公顷”这类表述。四舍五入会带来微小偏差直接用相等判断会误报。给一个容差值更合理total 5.43 parts {人工造林: 1.23, 飞播造林: 0.45, 封山育林: 3.75} if abs(sum(parts.values()) - total) 0.05: print(分项之和与总量不一致请检查原始表述) else: print(总量校验通过)0.05万公顷的容差对应500公顷足以覆盖小数位四舍五入的误差。如果超差就需要回到原文确认是否漏掉了“中幼林抚育”或“迹地更新”之类的分项这类补充项在公报里经常放在“其中”之后、句号之前位置比较隐蔽。5.2 用同比增长率反向验证公报还会写“与上年相比森林覆盖率提高0.3个百分点”。如果上年数据已经被提取并入库当前年份的数值应该满足一个近似关系def check_yoy(current, previous, points): return abs((current - previous) - points) 0.05 if check_yoy(27.53, 27.23, 0.3): print(同比增长校验通过)这里的points是百分点数不是百分比增长率。森林覆盖率这种百分数指标公报通常说“提高0.3个百分点”直接用百分比相减不使用(cur-prev)/prev。两种口径在代码里要分开混用会把正常数据判定为异常。5.3 输出标准化JSON作为入库中间层最后把校验通过的数据统一成JSON而不是直接生成数据库插入语句。统一结构在字段增减时改起来更容易{ year: 2009, province: 安徽省, indicators: [ {name: 森林覆盖率, value: 27.53, unit: %}, {name: 造林面积, value: 5.43, unit: 万公顷} ] }每年公报解析后生成一个这样的JSON再通过ETL写入PostgreSQL或时空数据库。校验脚本留作定时任务的一部分发现某一年分项之和超差时直接读取该年份的原文片段人工复核。整个处理链路由格式识别开始到校验结束每一步都有可回滚的中间产物适合直接接进年度公报ETL任务。本文还有配套的精品资源点击获取