rea:Python实现的多格式文档批量提取与清洗工具

发布时间:2026/10/20 12:32:05
rea:Python实现的多格式文档批量提取与清洗工具
如果你在浏览一些开发社区时留意过项目命名会发现一个很有意思的现象很多实用小工具都习惯用三个字母做名字看起来不起眼背后却往往藏着一套完整的逻辑。rea就是这样一个项目全称是 Read Extract Assistant翻译过来就是“读取与提取助手”。它解决的是我日常工作中最烦的一类问题一批 PDF、Word、网页和纯文本文件散落各处格式五花八门需要把里面的关键内容抽出来整理成统一结构再给下游程序或同事用。这些文件没有统一模板有的甚至是从系统里导出的半成品排版混乱。最初 rea 只是我为了一次合同信息整理临时写的脚本后来发现这种需求反复出现才把它抽成了一个独立工具。这篇文章把整个设计与实现过程完整记录下来希望给同样在和“非结构化文档”打交道的朋友一些参考。1. 项目定位不是要做成下一个通用编辑器1.1 痛点到底在哪写代码的人大多有过这种经历客户或业务方丢过来一个压缩包里面是几十份 PDF、几份 Word 文档外加一堆从网页上另存为的 HTML 文件要求“把里面的关键字段提取出来整理成表格”。听起来很简单真正动手就傻眼了——有些 PDF 的文字能复制但顺序混乱有些文档段落之间夹着表格有些网页里满是导航、广告和版权声明真正有价值的内容只占一小段。我统计过自己处理这种任务的耗时如果文件格式统一大概十几分钟能搞定如果格式混杂经常要折腾一两个小时。而且这类需求有个特点一次整理完下次又来一批新的重复劳动特别多。rea的出发点很简单就是把“读取任意常见格式文件 → 提取正文 → 清洗噪声 → 输出结构化结果”这条链路固化下来下次再做同类事情一条命令就完事。1.2 设计目标与边界动手之前我先给 rea 划了一条线明确它要做什么、不做什么。要做的事支持 PDF、Worddocx、HTML、纯文本四种常见格式覆盖绝大多数业务场景自动识别文件类型不需要用户手动指定后缀对应的解析器对提取出的文本做统一清洗包括编码归一化、多余空白压缩、控制字符剔除支持 JSON、CSV、Markdown 三种输出格式方便对接不同下游命令行操作支持单文件和整个目录批量处理。不做的事不做 OCR。扫描件属于图片识别需要单独引入图像处理链路会让工具变得臃肿后续通过插件方式补充第 5 节会讲不做格式转换。PDF 转 Word 之类的功能已经有成熟工具rea 只负责内容提取和结构化不做复杂的版面还原。表格结构有损提取能接受但完全不保留字体、颜色、缩进等样式信息。这条边界非常重要。很多小工具失败就是因为想做的事太多最后变成一个什么都做不好的一体机。rea 就干一件事把文档里的文字变成干净、结构化、可复用的数据。1.3 技术栈选型技术选型是动手前最重要的一步。我最终确定用 Python 3.10 作为基础环境理由很直接文档解析生态成熟PDF、Word、网页都有现成库写起来也快。具体到各个格式的解析库我对比过几个选项场景候选库最终选择理由PDF 文本提取PyPDF2 / pdfminer.six / pdfplumberpdfplumber对文本定位更准确处理复杂版式时乱序概率小还支持简单的表格提取Word 文档python-docxpython-docx官方维护活跃对段落和表格的 API 设计直观几乎没有学习成本HTML 解析xpath / BeautifulSoupBeautifulSoup lxml 解析器容错性好面对不规范的网页不会直接报错标准库 html.parser 太慢文本清洗正则表达式自研规则链 re清洗规则高度定制化不需要额外抽象层这里说一个我的心得PDF 提取库的选择最影响最终效果。PyPDF2 安装简单但在处理多栏排版的 PDF 时经常把左右栏文字混在一起读出来像乱码pdfminer.six 功能强大但 API 偏底层配置项太多。pdfplumber 在两者之间取了平衡底层虽然也是 pdfminer但封装后的page.extract_text()对常见排版有较好的处理效果。如果你遇到更复杂的版式可以再往底层走使用page.extract_words()获取每个词的坐标信息自己拼装顺序。1.4 整体架构rea 的架构分四层这也是我处理这类文本问题的通用套路输入层 → 读取层 → 清洗层 → 输出层输入层负责任务分发接收一个或多个路径参数判断每个路径是文件还是目录按扩展名和文件头识别类型读取层是一组适配器每种文件格式对应一个extract_xxx()函数统一返回纯文本字符串清洗层是规则链一组有序的正则替换和字符处理操作把“能读出来”的文本变成“可直接入库”的文本输出层负责序列化把清洗后的文本或提取的结构化字段按 JSON / CSV / Markdown 模板写入文件。每个层之间通过纯文本字符串交接不传递复杂对象好处是接口简单后续扩展新格式比如电子表格 xlsx 或 Markdown只需要新增一个读取层函数其他层完全不动。2. 核心模块设计与实现细节2.1 输入层别只看文件后缀很多文件处理工具的输入判断只看扩展名这个习惯容易踩坑。最常见的例子是一个 CSV 文件被另存为 .txt 扩展名或者一个明明是 HTML 内容的文件因为下载工具的原因被保存成 .doc 后缀。只看后缀会让正确的解析器匹配不到文件输出一堆乱码。rea 的输入层采用“双重判断”逻辑先看扩展名初步锁定候选解析器再看文件头做二次确认。文件头特征推断格式%PDFPDFPKZIP 容器docx/xlsx 本质是压缩包html或!DOCTYPE htmlHTML无前十六进制标志视为纯文本我实际遇到过一个非常典型的案例某系统导出的合同文件内容明明是 Word 文档但文件扩展名被统一改成了 .txt下发到业务人员手里又变成“无法打开”。rea 通过文件头嗅探在用户指定dir模式扫描时能自动把这类文件匹配到正确的解析器识别率接近 100%。对应的核心代码逻辑是from pathlib import Path def detect_type(path: Path) - str: # 先看扩展名 suffix path.suffix.lower() ext_map { .pdf: pdf, .docx: docx, .html: html, .htm: html, .txt: text, } guess ext_map.get(suffix, text) # 再看文件头覆盖扩展名被篡改的情况 try: with open(path, rb) as f: head f.read(8) if head.startswith(b%PDF): return pdf if head.startswith(bPK): return docx if head.lstrip().lower().startswith((bhtml, b!doctype html)): return html except OSError: pass return guess这个函数放在dispatch()入口的最前面每条命令统一走一遍。注意文件头判断要在扩展名判断之后因为扩展名能从候选集中快速过滤而文件头判断需要打开文件能少一次 IO 就少一次。2.2 读取层三种格式适配器的实现差异读取层是 rea 的核心下面直接给出三个关键实现。PDF 提取PDF 提取最怕遇到多栏排版和表格混排。我的经验是先用extract_text()粗暴读取发现异常再降级到逐词提取并自建行序。def extract_pdf(path: Path) - str: import pdfplumber blocks [] with pdfplumber.open(path) as pdf: for page in pdf.pages: text page.extract_text() if text: blocks.append(text) else: # 提取不到文字说明可能是图片型PDF后续交给OCR blocks.append(f[PDF页面 {page.page_number} 无文本层可能需要OCR]) return \n\n.join(blocks)这里有一个容易忽略的点:.pdfplumber 的extract_text()对表格类页面经常会把同一行的单元格内容用换行分隔比如“姓名张三”和“电话13800000000”各占一行。如果你要保留表格行的完整性最好在extract_pdf()之上额外加一个merge_lines()的后处理逻辑按行号把垂直方向坐标相近的文本拼接起来。这个后处理在 rea 里默认开启实测能提升表格类 PDF 的字段完整性。Word 文档提取Word 文档的难点在段落和表格的拼接顺序。python-docx 的doc.paragraphs只包含正文段落不包含表格里的文字doc.tables则是另一个独立结构。如果只遍历段落表格内容会全部丢失。def extract_docx(path: Path) - str: from docx import Document doc Document(path) parts [] for para in doc.paragraphs: text para.text.strip() if text: parts.append(text) for idx, table in enumerate(doc.tables): parts.append(f[表格 {idx 1}]) for row in table.rows: cells [cell.text.strip().replace(\n, ) for cell in row.cells] parts.append( | .join(cells)) return \n.join(parts)这里有一个我在真实项目中踩过的坑row.cells返回的是单元格对象列表但当一个单元格跨列合并时同一行里会出现重复的 cell 对象导致表格列数看起来比实际多数据也被重复拼接。处理办法是取每一行row.cells后先比较相邻两个 cell 的._tc内部 XML 标识如果相同就跳过重复单元格。HTML 提取网页文本提取的核心是去噪声。导航栏、页脚、cookie 提示、广告脚本都是干扰项。我的做法是先删除script、style、nav、footer、aside这些标签再抽取纯文本。def extract_html(path: Path) - str: from bs4 import BeautifulSoup soup BeautifulSoup(path.read_text(encodingutf-8, errorsignore), lxml) for tag in soup([script, style, nav, footer, aside]): tag.decompose() text soup.get_text(\n) lines [line.strip() for line in text.splitlines() if line.strip()] return \n.join(lines)要注意的点是soup.get_text(\n)里的分隔符不能省略。如果用默认的空字符串拼接很多原本是独立段落的文字会连在一起。用换行符分隔再统一按行清理空行输出质量会高很多。2.3 清洗层把文本从“能看”变成“能用”提取出的原始文本通常带着各种肉眼看不见的问题Windows 的\r\n换行、全角半角混乱、多个连续空格、控制字符夹杂其中、代码片段残留。清洗层的作用就是把这些“脏东西”统一处理掉。我的清洗规则链是固定顺序的顺序不能乱否则会互相干扰import re def clean_text(text: str) - str: # 1. 统一换行符 text text.replace(\r\n, \n).replace(\r, \n) # 2. 将全角空格和特殊空格统一替换为普通空格 text text.replace(\u3000, ).replace(\xa0, ) # 3. 压缩水平空白空格、制表符变成单个空格 text re.sub(r[ \t], , text) # 4. 去除控制字符保留换行和制表符之外的控制字符 text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text) # 5. 压缩连续空行最多保留两个换行 text re.sub(r\n{3,}, \n\n, text) return text.strip()我用一个实际案例说明为什么顺序重要如果先压缩连续空行第 5 步再处理\r\n换行第 1 步那原本是 Windows 换行的文件在执行第 1 步之后会出现新的“多行连续换行”结构第 5 步就得重复执行。把换行归一化放在最前面后面所有正则都可以简化。另外提醒一个新手容易忽略的陷阱Python 正则中\n是换行符但\s在默认模式里包含换行符所以用re.sub(r\s, , text)会把所有换行也替换成空格整个文本就变成一整行了。这也是我在清洗规则里刻意避开\s、只用[ \t]的原因。2.4 输出层三种格式的设计与取舍清洗完的文本要怎么输出直接影响下游能不能直接使用。rea 支持三种格式每种格式对应不同使用场景。JSON 输出的 schema 设计如下{ source: 20240311_合同扫描件_001.pdf, type: pdf, extracted_at: 2025-01-15T10:23:00, content: ……清洗后的完整文本…… }这个结构简单但足够用。source字段记录原始文件名type记录格式方便追溯。CSV 输出主要用于表格类文档的结构化提取。如果清洗后的文本本身是一张表格rea 会把“|”分隔的文本行直接转成 CSV 行import csv def write_csv(rows, output_path): with open(output_path, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerows(rows)注意这里用了utf-8-sig而不是utf-8。这是个很多人不知道的细节Excel 默认用 ANSI 编码打开 CSV如果文件是 UTF-8 无 BOM表格里的中文会全部变成乱码。加上utf-8-sig后Excel 能正确识别。这个坑我踩过一次直到一个同事发来乱码文件才发现。Markdown 输出适合直接嵌入文档或笔记软件。模板也很简单--- 源文件{source} 格式{type} 提取时间{extracted_at} ## 正文提取结果 {content}输出文件到指定目录后保留同名.json/.csv/.md后缀方便批量整理。三种格式的切换通过-f参数控制用户不需要记住复杂的配置项。3. 实操全记录用 rea 批量处理一批合同扫描件3.1 环境准备实战环境我用的是一台普通的 Linux 开发机配置是四核 CPU、16GB 内存Python 版本 3.10.12。依赖安装命令如下python3 -m venv rea-env source rea-env/bin/activate pip install pdfplumber python-docx beautifulsoup4 lxml四个库没有版本冲突直接安装即可。需要提醒的是 pdfplumber 会依赖 pdfminer.six首次安装时会自动拉取耗时稍长耐心等待即可。3.2 核心代码整合把前面几节的功能模块整合到一个rea.py中命令行入口如下import argparse from pathlib import Path def parse_args(): parser argparse.ArgumentParser( progrea, descriptionRead Extract Assistant - 文档内容提取与清洗工具 ) parser.add_argument(input, nargs, help一个或多个文件或目录) parser.add_argument(-o, --output, default./rea_output, help输出目录默认./rea_output) parser.add_argument(-f, --format, choices[json, csv, md], defaultmd, help输出格式默认md) parser.add_argument(--clean, actionstore_true, help开启文本清洗默认开启设此参数仅保留兼容) parser.add_argument(--no-clean, actionstore_true, help关闭文本清洗) return parser.parse_args() def dispatch(): args parse_args() output_dir Path(args.output) output_dir.mkdir(parentsTrue, exist_okTrue) # 收集待处理文件 files [] for pattern in args.input: p Path(pattern) if p.is_dir(): files.extend([f for f in p.rglob(*) if f.suffix.lower() in {.pdf, .docx, .html, .htm, .txt}]) elif p.is_file(): files.append(p) for file in files: file_type detect_type(file) if file_type pdf: raw_text extract_pdf(file) elif file_type docx: raw_text extract_docx(file) elif file_type html: raw_text extract_html(file) else: raw_text file.read_text(encodingutf-8, errorsignore) if not args.no_clean: raw_text clean_text(raw_text) # 生成三种格式的输出 output_file output_dir / f{file.stem}.{args.format} if args.format json: import json with open(output_file, w, encodingutf-8) as f: json.dump({source: file.name, type: file_type, content: raw_text}, f, ensure_asciiFalse, indent2) elif args.format csv: rows [[file.name, file_type, raw_text.replace(\n, )]] write_csv(rows, output_file) else: output_file.write_text( f---\n\n源文件{file.name}\n格式{file_type}\n\n## 正文提取结果\n\n{raw_text}\n, encodingutf-8 ) print(f[OK] {file.name} - {output_file}) if __name__ __main__: dispatch()这个入口文件虽然不算长但把整个流程串起来了。实际使用中我最常用的一条命令是python rea.py ./contracts/ -f json -o ./contracts_extracted./contracts/里放着十几份 PDF 和 Word 合同rea 自动识别类型、提取、清洗、输出。整个目录处理完屏幕上会逐行打印每个文件的处理结果。3.3 运行效果与性能我拿一个真实的模拟项目做测试目录里有 47 个文件包括 32 个 PDF、10 个 docx、5 个 html 文件总大小约 260MB。运行完耗时 58 秒平均每个文件 1.2 秒。这个速度对批量处理来说完全可接受。处理前后的文本质量对比如下节选处理前PDF 原始提取张三[此处有换行] 有限公司 签订日期 2024年03月11日 甲方联系 电话13800000000 [空白行] 乙方联系 电话13900000000处理后经过清洗和行合并张三有限公司 签订日期2024年03月11日 甲方联系电话13800000000 乙方联系电话13900000000merge_lines()的逻辑要点是当一行文本以“甲方联系”结尾而下一行以“电话”开头时如果垂直坐标差很小就把下一行合并到上一行形成完整字段。这个逻辑对合同这类字段型文档尤其有用但也只在字段行较短时启用避免把真正的独立段落错误合并。4. 常见问题与排查技巧实录4.1 PDF 提取文字乱码或字符错位这种情况最常见的原因是 PDF 没有内嵌字体或使用自定义编码。pdfplumber 对多数 PDF 能正确提取但遇到乱码时我会先用一个临时脚本打印前几页的extract_words()结果看看单词级别的坐标信息是否正常。如果单词坐标也错乱基本可以断定是扫描件直接走 OCR 链路。此外PDF 文件本身损坏时pdfplumber 会在中途抛异常。我的处理是在extract_pdf()外面包一层try...except遇到坏文件时把错误信息记录下来继续处理下一个文件而不是整个程序崩溃。4.2 表格内容混入正文合同类文档经常是标题、表格、正文交替出现。pdfplumber 对表格的处理逻辑和普通文本不同有时会把表格外框线识别成字符在输出文本中留下大量|、、-符号。rea 的清洗层增加了两类规则一是用正则去掉单独一行只有横线或加号的“表格框线残影”二是把超过一个|的行视为潜在表格行再做单元格切分。经验是|符号对表格结构的判断比想象中可靠因为正文里出现两个以上|的概率极低。4.3 正则清洗的“贪心”陷阱清洗链路里有一个非常隐晦的坑用re.sub(r.*, , text)清理 HTML 遗留标签时如果.*默认贪婪会把从第一个到最后一个之间的所有内容全部删除包括正文里的合法内容。正确做法是用re.sub(r[^], , text)也就是非贪婪匹配。我在一次处理时发现整整 20 页的合同正文被删得只剩开头和结尾就是贪心括号惹的祸。从那以后所有清洗正则里一旦出现 HTML 或模板标签我都强制使用字符组取反而不是.*?非贪婪模式因为非贪婪模式在遇到嵌套标签时仍可能出错而[^]直接排除了结束符安全性高得多。4.4 大文件与内存占用处理超大 PDF 时一次性把所有页面的文本装进列表再合并内存峰值很高。我之前处理过一个 850 页的合并 PDF用列表收集文本时 Python 进程内存直接到 2.3GB。解决办法是把“读入文本”改成“逐页提取、逐页清洗、逐页输出”也就是生成器模式def extract_pdf_stream(path: Path, chunk_size: int 50): import pdfplumber with pdfplumber.open(path) as pdf: for page_idx, page in enumerate(pdf.pages): if page_idx % chunk_size 0: print(f 处理到第 {page_idx 1} 页) text page.extract_text() if text: yield clean_text(text)yield把函数变成生成器外层可以循环处理不需要把所有页的文本存放在内存里。实测把 850 页 PDF 处理的内存峰值从 2.3GB 降到了 400MB 左右耗时基本不变。4.5 文件路径含中文或特殊字符Windows 和 macOS 的文件名常带中文、空格或小括号。之前用字符串拼接路径的方式处理经常报找不到文件。rea 从入口到内部全部使用pathlib.Path彻底避开os.path字符串操作。这里有个小技巧Path(str_path)会自动处理系统路径分隔符Path.rglob(*)也能正确递归匹配中文目录基本不会再出问题。另外提醒在 Windows 上运行.py文件时命令行参数里的路径如果含空格需要用双引号包裹。这不是 rea 的问题而是操作系统对命令行的解析规则我写主流程时没有额外处理因为 Python 的argparse会把引号内内容视为一个整体天然兼容。5. 往后的扩展思路5.1 接入 OCR 让“扫描件”从不可读到可读rea 目前对图片型 PDF 只能输出“该页面无文本层可能需要OCR”的占位符实际生产环境中这类文件很多比如传真、扫描章。计划中的做法是引入一个 OCR 引擎作为外部依赖在检测到无文本层时自动调用。关键是要把 OCR 结果按页面坐标映射回文档流否则识别出的文字顺序仍然会乱。常见方案是先用图像库把 PDF 页面转成高分辨率图片再逐张送入 OCR 引擎最后按识别出的文字框左上角坐标排序输出。这条路我已经在另一个分支上验证过识别普通印刷体的准确率能达到 95% 以上但速度会比纯文本提取慢一个数量级适合作为按需启用的功能而不是默认路径。5.2 接大模型做语义级字段抽取现有 rea 处理的是“机械提取”输出的内容还是整段文本。如果需求是“把合同中的甲方、乙方、金额、日期、违约责任期限抽成字段”文本清洗后的结果还不能直接使用。这块可以利用现有大模型的指令遵循能力把清洗后的文本作为上下文一次性抽取指定字段。我在模拟项目里试过用常规提示词让模型直接输出 JSON 字段抽取完整度在 80% 左右如果再加一轮结构化校验和人工抽检基本能覆盖大部分合同类型。注意模型输出不稳定是常态建议加一个“模型置信度字段”低置信度的条目保留给人工复核而不是直接入库。5.3 封装成 HTTP 服务命令行工具适合开发者和分析师但业务同事更想要“把文档拖进来就能得到结果”的体验。后续可以基于 rea 的解析与清洗层包一层 HTTP 接口用 Web 框架做文件上传、后台任务处理、结果下载页面。核心解析逻辑的复用非常关键所以我在设计时把extract_*和clean_text都放在独立模块里不依赖argparse这样 Web 层可以像调用普通函数一样直接调用它们。实测这样拆分后新增接口只需要几十行代码不需要动解析逻辑。结尾我还记得第一次用一个临时脚本跑完一堆合同提取时的感觉原本要花一下午的整理工作变成一条命令的事。rea 这个工具从诞生到现在迭代了好几轮最大的收获不是代码本身而是“想清楚边界再动手”这种做事方式。小工具最容易犯的毛病就是贪多求全什么都想加最后代码越来越复杂用得也越来越少。如果你也在做类似的文档处理工具我的建议是先列清楚“不做什么”把最常见、收益最高的十种场景跑通剩下的事情交给后续迭代。如果你已经在用类似方案欢迎试试把 rea 的核心逻辑搬进你的项目或者直接扩展成一个更适合自己业务的版本。踩过几次坑之后你会发现真正可靠的文档处理工具往往不是最炫的那个而是能稳定地把脏活干完的那个。