Python批量文档处理:文本清洗与统计的自动化实践

📅 发布时间:2026/10/20 15:44:07
Python批量文档处理:文本清洗与统计的自动化实践
先说我自己的习惯但凡遇到一摞需要整理的文档我不会先打开编辑器而是先写一段 Python。这个习惯是在一次内容归档需求里彻底定下来的——几十篇格式不一、带大量多余空行和混合标点的文章人工逐个清理既慢又容易漏交给脚本统一清洗、统计、再重新生成结果速度快了非常多。文档文章处理这个场景听上去宽泛实际上流程非常稳定读文件、洗文本、做统计、导出结果。只要把这条链路跑通不管是处理几十篇还是几百篇差别只是等待时间不同。这篇文章不是教科书式的库清单也不是在炫耀哪个库的用法而是把我实际用来处理文本类与格式类文档的一套完整方案拆开讲。里面既有可以直接拿去改的代码也有我在真实批处理场景里踩过多次的坑。如果你是内容运营、编辑、数据整理人员或者只是想把一堆文章快速变成结构化资产这篇应该能帮你省下不少时间。1. 先拆清楚文档文章处理到底在做哪些事1.1 从一段原始文本到一份可用内容中间有几道工序当我把“文档文章处理”当作一个技术任务来对待时第一反应不是去找某个大而全的工具而是先把需求拆成几个清晰的工序。你可以把整个过程想象成一条流水线原始材料从一端进来经过清理、整理、分析、包装从另一端变成一份结构清晰、可以直接交付的结果。这个拆解步骤非常重要因为不同工序需要的工具和逻辑完全不同混在一起最容易写出不好维护的代码。第一道工序是读取。这一步解决的是“内容进得了内存”的问题。TXT 文件直接用标准文件接口就能读docx 文件则要解析段落和表格结构PDF 又因为文本层和排版模型不同需要额外的提取手段。我在实际项目中见过不少同学在一开始就把读取和清洗逻辑揉在一起结果遇到一个文件格式异常时连问题出在哪一步都很难定位。第二道工序是清洗。清洗是把原始文本里影响后续处理的噪音去掉例如全角半角混用、多行空行、行尾残留空格、不统一的中英文间距、不可见字符等。很多人会觉得清洗不重要但实际上只要把这一步做扎实后面统计字数、提取关键词、做格式转换都会顺利很多它们之间的依赖关系非常强。第三道工序是分析。分析不是指做复杂的大模型分析而是从文本里提取可量化的特征比如总字数、中文字符数、英文单词数、段落数量、关键词分布、标题层级是否完整。对内容运营来说这些特征可以快速判断一篇文章的类型和结构对于资料归档来说也能为后续检索提供线索。第四道工序是导出。把处理好的文本和分析结果重新写回文件。这里的“文件”可能是干净的 TXT、排版后的 Markdown也可能是带有各项统计数据的表格。导出环节决定前面所有工作的成果能不能被别人方便地使用。我习惯把这四道工序看作固定骨架后面所有方案都围绕它展开。骨架稳定之后剩下的工作就是在每一道工序里填合适的库、写合适的逻辑遇到新需求时也只需要替换其中的某一段而不是推翻重来。1.2 文本型和格式型文档处理路线完全不同严格来说“文档文章”不是一个整齐划一的类型。按内容承载方式不同至少要分成文本型文件和格式型文档。文本型文件包括纯文本、Markdown、CSV 等内容是可见的字符流处理起来最简单。格式型文档则包括 docx、PDF、以及各种电子表格它们的内容被包在一套结构模型里想要拿到正文必须借助对应的解析库。文本型文件处理的关键在“清洗规则”。因为字符流里除了正文之外往往还残留着各种格式痕迹。比如从网页复制下来的文本可能带着 HTML 标签片段从别人文稿里拿到的 Markdown可能有混乱的标题级别这些都需要靠规则去规整。格式型文档处理的关键则在“结构解析”。以 docx 为例正文分布在段落和表格两类结构里段落又有样式、字体等属性。要读取正文和标题就需要按段落对象逐个读取而不是把这些内容当成普通字符串来处理。PDF 更特殊它的页面是按照视觉位置排布的同一个段落中的文字可能被拆成多个文本片段提取的时候要面对顺序错乱的问题。在实际处理中我通常会在读取入口处先统一做一次文件类型判断再分别走不同的解析分支。这样的好处是上层逻辑不需要关心文件具体是什么格式唯一需要约定的就是“返回一段干净的纯文本”。这个约定让整个系统的复杂度可控也是后面批处理能够顺畅跑起来的前提。1.3 Python 在这类任务上的天然优势选择 Python 不是因为它性能最强而是因为它在文档处理这个领域里有难得的生态宽度。文本处理有丰富的标准库和第三方解析库从结构化文档到半结构化文本都能覆盖数据分析有成熟的数据结构写脚本做自动化批处理也足够灵活如果后面需要接入更复杂的文本分析也能和其他工具顺利衔接。另一个优势是试错成本低。文档处理最大的不确定来自真实文件里的脏数据这些情况往往要到运行到那一行代码时才暴露。Python 的解释执行特点让我能够把整个处理流程拆成小函数用一小段样本数据先调通再放大到全量文件调试的节奏比其他编程语言要舒服。所以我在绝大多数文章处理任务里都会优先用 Python 搭一条可复用的处理流水线。2. 开工前把工具和环境准备好2.1 按用途选择合适的库在处理链路中的不同位置我会选择不同的库基本遵循“谁擅长什么就用谁”的原则。下面是这份配置中各位成员的定位处理需求建议库选它的理由纯文本读写内置 open()、io无需额外依赖读取速度最快字符串匹配和替换re标准正则库清洗规则全靠它docx 文档读取python-docx能读段落、表格、样式操作模型清晰PDF 文本提取pdfplumber提取准确度较高能拿位置信息表格结果导出openpyxl对 xlsx 的支持完善适合写统计报告中文分词和关键词jieba内置词典中文场景下开箱即用以上这些库都是我在处理中反复用过的整体稳定性比较好。需要注意它们并非完全互相替换比如 PDF 提取也可以用其他同类库但遇到排版复杂或文字分块的 PDF 时pdfplumber 的容错效果通常更好。docx 文件也可以用 XML 直接解析但改动量更大不如直接站在成熟库的肩上来得稳。还有一类需求经常出现就是旧版的 doc 格式。由于 docx 已经取代了它除非使用额外的兼容工具否则处理会比较麻烦。遇到这种情况我的建议是在项目里先约定好输入文件的格式要求尽量把旧格式文件通过文档工具批量转换成 docx 再进入流程这会省掉不少效率损失。2.2 环境安装与目录结构设计使用 Python 的第一步是建一个干净的环境。我通常会在项目根目录下创建虚拟环境避免不同项目之间的依赖冲突。安装依赖的命令如下python3 -m venv venv source venv/bin/activate # 某些系统下使用 venv\Scripts\activate pip install python-docx pdfplumber openpyxl jieba安装完成后可以用一个极短的脚本验证关键库是否可用import docx import pdfplumber import openpyxl import jieba print(环境正常)如果这一行能顺利运行说明环境基本没问题。接下来需要把项目目录梳理清楚。这里我强调一个原则输入输出严格分离。原因很实际文档处理脚本往往要对文件做批量覆盖或重写如果输入和输出放在同一个目录脚本运行出错时很容易直接把原始文件弄坏。分离目录后即使输出结果出了问题原材料仍然保留着可以随时重新跑一遍。一个适合大多数文章处理任务的目录大致长这样doc_process/ ├── input/ # 需要处理的原始文档 ├── output/ # 清洗处理后的文本 ├── reports/ # 统计与汇总表 └── main.py # 主流程脚本input 下可以按日期或来源再分子目录reports 下放统计表和处理日志方便回溯。这个结构看起来简单但能显著提高调试效率也方便以后交给别人接手维护。很多批处理脚本最后变得难以收拾问题往往不是出在代码本身而是从一开始就没有把文件路径和职责边界规划好。2.3 统一读取入口先让程序能读进所有文件在写任何具体处理逻辑前我会先写一个统一的读取函数把环境里可能出现的文件格式都“接住”。这样做的好处是后面所有逻辑都只需要面对一个返回值一段编码干净的字符串。不管输入的是 TXT、docx 还是 PDF统一读取入口都返回一个规范的“纯文本视图”。在这个入口函数里需要做三件事判断后缀名、选择解析方式、处理读取异常。这样处理之后上层代码不需要关心文件格式区别遇到不认识的文件类型也可以快速跳过而不会让整个批处理中断。这时候自然会引出一个问题不同文件的编码并不统一。TXT 文件最常见的编码是 UTF-8但国内不少存量文稿实际上是 GBK 或 GB2312 编码。如果只按一种编码去读很容易在批处理中途报错。我的做法是按优先级依次尝试常用编码全部失败后才报错并把失败信息记录下来。这样虽然牺牲了一点效率但换来的是批处理过程的连续性。这一段经验在后面部署时非常值钱。3. 文本清洗与规范化质量决定了后面的分析3.1 原始文章中那些需要处理的问题文本清洗是文档处理链路里最显眼、也最容易出问题的一环。因为原始文章的“脏”从来不是单一类型而是多种问题混合出现。最常见的几类包括全角英文字母和数字导致后续匹配困难中文与英文、数字之间的空格缺失全文缩进不一致空行多寡不一文件开头带着 BOM 头以及容易被忽略的不可见字符。这些问题单独看都是小事但混在一起之后任何统计数字都会被污染。我判断清洗方案是否合格通常看两件事一看是不是把绝大多数噪音都消掉了二看是不是没有“伤及无辜”也就是不要把文章原有的结构信息也一并删掉。最典型的例子就是换行。有些人喜欢把所有换行都删除把文章拼成一个大字符串这样确实方便做正则匹配但文章段落结构也就丢了。正确做法是保留段落边界只规范空行数量而不是把内容完全揉平。3.2 一套开箱即用的清洗函数有了上面的思路就可以动手写一套相对完整的清洗函数。下面这组代码是我在多个项目里反复使用后总结出来的既覆盖了高频问题也保留了调整空间import re def fullwidth_to_halfwidth(text: str, keep_cjk_punct: bool True) - str: 全角字符转半角字符支持保留中文标点。 keep_cjk_punct 为 True 时中文标点。“”‘’等保持全角。 result [] cjk_puncts set(。、《》【】“”‘’…—) for char in text: code ord(char) if code 0x3000: code 0x20 elif 0xFF01 code 0xFF5E: if keep_cjk_punct and char in cjk_puncts: result.append(char) continue code - 0xFEE0 result.append(chr(code)) return .join(result) def normalize_spaces(text: str) - str: 压缩行内空格并去除每行首尾空白保留段落边界。 text text.replace(\r\n, \n).replace(\r, \n) lines [re.sub(r[ \t], , line).strip() for line in text.split(\n)] return \n.join(lines) def add_space_between_cn_en(text: str) - str: 在中文与英文、数字之间补充一个空格提升排版可读性。 text re.sub(r([\u4e00-\u9fa5])([A-Za-z0-9]), r\1 \2, text) text re.sub(r([A-Za-z0-9])([\u4e00-\u9fa5]), r\1 \2, text) return text def clean_text(text: str, keep_cjk_punct: bool True) - str: 文档清洗主入口按顺序完成可见字符处理、空格规整、空行控制。 text text.lstrip(\ufeff) text fullwidth_to_halfwidth(text, keep_cjk_punctkeep_cjk_punct) text normalize_spaces(text) text re.sub(r\n{3,}, \n\n, text) text add_space_between_cn_en(text) return text.strip()这段代码看起来不复杂但每一行的取舍都有讲究。比如fullwidth_to_halfwidth里的keep_cjk_punct参数就是为了避免把中文标点也转成半角。全角中文标点转成半角后排版会变得松散别扭尤其是逗号、句号、引号这些高频标点。保持中文环境下的全角标点才符合中文文章的习惯。normalize_spaces只处理“行内空格”和“行首行尾空白”刻意不碰换行结构。这样做的好处是文章原本的段落划分仍然保留清洗后看起来还是正常的文章。add_space_between_cn_en处理的是中英文混排时常见的“粘连”问题这个规则在内容排版中几乎处处都用得上。3.3 清洗时最容易被忽略的三个坑第一正则表达式里的字符范围要小心。中文范围[\u4e00-\u9fa5]覆盖的是常用中文字符像中文标点、汉字扩展区并不包含。做中英文间距处理时如果标题或其他内容里包含特殊字符需要先确认目标范围够不够用否则可能会出现漏处理。第二清洗顺序不能随意颠倒。先处理字符集转换再做空格规整最后做中英文间距补充。如果顺序反过来全角转半角时刚调整好的空格可能会被后续正则再次误改效果会变得很不稳定。第三不要为了追求视觉上的干净而把段落全部合并。很多人看到文中有空行第一反应是把所有\n去掉。但在文章处理场景里段落边界是有语义的。更何况后面如果要统计段落数合并换行会让统计结果完全失真。保留段落边界不只是排版习惯更是数据准确性的要求。清洗结束后我通常会把每组样本文件单独跑一遍和清洗前对照一下。如果发现某个规则误伤了标题或特殊结构就先把特殊结构保护起来再跑一次。这个“对照-修正-再跑”的循环虽然耗时但能避免批处理几十分钟之后才发现结果异常要返工。如果是一次性批量任务可以用对比工具检查原始和清洗后的差异逐项确认每条规则的效果符合预期。4. 核心分析字数统计、关键词提取与文档画像4.1 用不同指标描述一篇文章清洗干净的文本下一步是量化。量化一篇文章当然不只是统计一个实数而是要得到一组结构稳定的画像指标。最常用的指标包括中文字符数、英文单词数、总字符数、段落数、句子数、平均段落长度、关键词及词频。组合起来基本就能回答“这篇文章大概什么规模、什么重点”的问题。统计时需要区分中文字符和英文单词。中文字符用正则范围匹配即可英文单词则要避免把一个句子里的每个字母都当成一个词。用正则[A-Za-z0-9](?:[-][A-Za-z0-9])*可以把带连字符的复合单词识别成一个整体这个细节在很多编辑规范里是有实际价值的。句子数统计相对复杂。如果按句号、问号、感叹号分割需要排除省略号、小数点等情况如果要求不高直接按中文句末标点加英文句末标点匹配即可。如果文章里有大量缩写词句子数会偏高但只要在最终报告里注明统计口径用于横向比较仍然是有效的。我常用的统计函数大概是这样的import re def build_stats(text: str) - dict: chinese_chars len(re.findall(r[\u4e00-\u9fa5], text)) english_words len(re.findall(r[A-Za-z0-9](?:[-][A-Za-z0-9])*, text)) paragraphs len([line for line in text.split(\n) if line.strip()]) sentences len(re.findall(r[^。!?]*[。!?], text)) total_chars len(re.sub(r\s, , text)) return { 中文字数: chinese_chars, 英文字数: english_words, 总字符数: total_chars, 段落数: paragraphs, 句子数: sentences, }这个函数本身很简单但它表达的是一种“按照既定口径描述文档”的思路。只要口径固定下来不同文件的数据就能横向比较批处理报告也就有了实际意义。4.2 基于词频的关键词提取关键词提取是文章分析的常见需求。如果只是做轻量级的整理工作完全可以用词频加停用词过滤的方式来实现不需要引入复杂度很高的模型。基础思路三步走分词、过滤停用词、按词频排序。用 jieba 来完成第一步用自带或自定义的停用词表完成第二步最后用collections.Counter来输出高频词。下面这套代码可以提取一篇文档的 top N 关键词from collections import Counter import jieba STOP_WORDS {的, 了, 在, 是, 我, 有, 和, 就, 不, 人, 都, 一, 一个, 上, 也, 很, 到, 说, 要, 去, 你, 会, 着, 没有, 看, 好, 自己, 这} def extract_keywords(text: str, top_n: int 10) - list: words jieba.lcut(text) filtered [] for word in words: word word.strip() if len(word) 1: continue if word in STOP_WORDS: continue if not re.search(r[\u4e00-\u9fa5A-Za-z0-9], word): continue filtered.append(word) counter Counter(filtered) return counter.most_common(top_n)停用词表可以根据不同领域调整。比如处理技术文档可以加入“实现”“使用”“基于”等通用动词处理娱乐内容可以加入“小编”“朋友”等词。这些词在多数文章里都是高频但对于主题判断没有帮助把它们过滤掉才能让关键词更有区分度。jieba 也支持向词典里加入领域专有词需要时调用jieba.add_word就可以。我也遇到过需要自定义用户词表的情况实际上用用户字典文件会更方便能够避免每次启动脚本后还要重新注册专有词汇。4.3 采集阅读信号的通用特征结构有了统计指标和关键词下一步就是把这一堆信息组装成一个通用的“文档特征”结构。这个结构可以用字典存放也可以用数据类表示。我比较习惯将一个文档的分析结果组织成这样的字典{ 文件名: sample.md, 中文字数: 3560, 英文字数: 210, 总字符数: 4120, 段落数: 38, 句子数: 120, 关键词Top5: 算法、模型、数据、优化、训练 }把所有文件的特征收集起来就是一份完整的文档画像。这一步的价值不在于某个字段正确与否而在于它把文章处理从“一个一个读文件”提升到了“一批文本形成结构化资产”的层面。也就是说之后如果需要排序筛选比如找出中文字数超过 3000 的文章、段落密度最大的文章只需要在这些特征上做运算而不用重新打开原始文件。由于各个统计函数都遵循“传入文本、返回数值或列表”的约定整个分析模块的扩展性就比较好。后面加一个新指标比如标题出现次数或引用数量只要新增一个小函数并挂进特征组装流程即可。这也是我在文档处理任务中一直坚持的模块化思路。5. 批量跑起来全流程自动化与结果导出5.1 主流程的组织方式文档处理做到一定程度后你会发现单篇处理只是热身真正的价值在把整个流程串成批处理。批量处理的组织方式我建议用“三层结构”文件层负责遍历和读取业务层负责清洗和分析报告层负责汇总和导出。文件层不知道业务层具体做了什么业务层也不关心结果从哪里来。这样整个项目不管是处理 10 篇还是 1000 篇主流程代码几乎不用改动。先把主流程入口写出来from pathlib import Path def run_batch(input_dir: str, output_dir: str, report_path: str) - None: input_path Path(input_dir) output_path Path(output_dir) output_path.mkdir(parentsTrue, exist_okTrue) report_rows [] failed_rows [] for file_path in sorted(input_path.rglob(*)): if not file_path.is_file(): continue try: row process_single_file(file_path, output_path) report_rows.append(row) except Exception as exc: failed_rows.append({文件: str(file_path), 错误信息: str(exc)}) write_report(report_rows, failed_rows, report_path) print(f处理完成成功 {len(report_rows)} 篇失败 {len(failed_rows)} 篇)这段代码里有几个容易被忽略的细节。使用rglob(*)是为了同时覆盖子目录里的文件如果只需要处理顶层文件可以改成glob(*)。使用sorted是为了让处理顺序稳定方便后续排查问题。调用统一的process_single_file函数则该函数内部负责读取、清洗、分析、保存这四个环节出错时异常向上抛出主流程记录失败行但继续跑不会因为某个文件问题导致整个批次崩溃。5.2 兼容 docx、PDF、TXT 的读取实现批处理面对的文件不会只有一种格式所以process_single_file内部要根据后缀走不同分支。下面是读取层的完整实现思路def read_text(path: Path) - str: suffix path.suffix.lower() if suffix in {.txt, .md, .markdown}: return read_text_file(path) if suffix .docx: return read_docx(path) if suffix .pdf: return read_pdf(path) raise ValueError(f不支持的文件类型: {suffix}) def read_text_file(path: Path) - str: for encoding in [utf-8, gbk, utf-16]: try: with open(path, r, encodingencoding) as f: return f.read() except UnicodeDecodeError: continue raise UnicodeDecodeError(f无法识别编码: {path.name}) def read_docx(path: Path) - str: import docx document docx.Document(str(path)) parts [p.text for p in document.paragraphs if p.text.strip()] for table in document.tables: for row in table.rows: cells [cell.text.strip() for cell in row.cells] parts.append( | .join(cells)) return \n.join(parts) def read_pdf(path: Path) - str: import pdfplumber pages_text [] with pdfplumber.open(str(path)) as pdf: for page in pdf.pages: page_text page.extract_text() or pages_text.append(page_text) return \n\n.join(pages_text)read_text_file按编码优先级依次尝试解决了大量 TXT 文件不是 UTF-8 的问题。read_docx除了段落还会把表格里的内容按行拼接出来避免漏掉藏在表格里的文字。read_pdf提取后按页之间用两个换行分隔尽量保留阅读顺序。很多人在 PDF 提取上容易踩坑看到extract_text()返回空字符串就以为文件没有内容。实际情况通常是这页 PDF 是图片扫描件没有文本层文字其实是图像里的一部分。对于这种文件轻度方案不如提前拦截在报告里列出“待人工处理”标记而不是让程序反复重试浪费时间。扫描件要做 OCR需要使用专门的 OCR 工具已经不是纯文本处理能力范围内的事情了。process_single_file的完整流程可以这样组织def process_single_file(file_path: Path, output_path: Path) - dict: raw_text read_text(file_path) cleaned clean_text(raw_text) stats build_stats(cleaned) keywords extract_keywords(cleaned, top_n5) new_path output_path / f{file_path.stem}_cleaned.md new_path.write_text(cleaned, encodingutf-8) return { 原文件: str(file_path), 处理结果: 成功, 输出文件: str(new_path), **stats, 关键词Top5: 、.join(word for word, _ in keywords), }这个函数体现了“每个环节只负责一件事”的思想读取、清洗、统计、保存、汇总各自独立。实际应用时如果需要排除某些关键词或者调整统计口径只需要改动对应的子函数。5.3 把处理结果汇总成一张可以交出去的表格单篇处理完成后需要把批量的结果汇总成表格。用 openpyxl 写 xlsx 是省事又通用的选择既能交给运营同事直接看又能作为后续分析的输入。下面这段代码会把成功结果和失败记录写入同一个工作簿的不同工作表from openpyxl import Workbook def write_report(rows: list, failed_rows: list, report_path: str) - None: wb Workbook() ws wb.active ws.title 处理结果 if rows: headers list(rows[0].keys()) ws.append(headers) for row in rows: ws.append([row.get(h, ) for h in headers]) if failed_rows: ws_fail wb.create_sheet(失败记录) ws_fail.append([文件, 错误信息]) for row in failed_rows: ws_fail.append([row[文件], row[错误信息]]) wb.save(report_path)写表格时有个值得注意的小细节ws.append会自动把整行追加到下一行所以只要第一行写好表头后面按照同一个顺序 append 数据即可。为了健壮获取字段时用row.get(h, )而不是row[h]可以避免某行缺少某个字段时直接报错。表格写完后整个批处理任务就闭环了原始文档进 input清洗后的文件进 output统计和失败信息进 reports 目录。拿到结果的人不需要了解代码只要打开表格就能看到哪些文件成功、哪些文件有问题、每篇文章的规模特征分别是什么。6. 实战中最容易翻车的几个地方6.1 文件编码引起的乱码与报错文件编码问题是我在文档处理里遇到最频繁的一类坑对新手来说尤其隐蔽。直接沿用系统的默认编码读文件也许在测试案例里正常放到真实目录里立刻就会遇到刺眼的UnicodeDecodeError。国内历史文稿最常见的是 GBK 编码也有少量 GB2312 和 UTF-16 文件。一旦文件用 UTF-8 无法解码整个批处理就会中断。我的解决方案是方案性的在读取入口里按“UTF-8 → GBK → UTF-16”的顺序依次尝试解码只有全部失败才抛出异常。为了提高健壮性还可以用第三方库做编码识别但注意引入额外依赖会加大部署复杂度。对大多数一次性批处理任务按顺序尝试足够失败时人工确认一下即可。此外还有一类编码异常很隐蔽文件本身是 UTF-8但开头带了 BOM。如果不处理后续正则匹配时首字符可能不是一个正常字符导致统计结果出现偏差。解决方法是读取后在清洗入口把\ufeff剥离掉这也是前面清洗函数里第一步做这件事的原因。6.2 从 docx 或 PDF 里提取内容时的偏差docx 文档看起来稳定但文本提取时陷阱也不少。段落对象返回的是该段落的纯文本但段落里的批注、修订标记不会出现在这里如果你期望拿到的是“最终显示效果”结论会和你预期不同如果文档存在分节符或页眉页脚常规段落遍历也不会包含这些内容需要额外处理节对象。另一个常见情况是文档里有多级列表列表项在段落模型里表现为带编号样式或符号的文本直接提取可能拿不到编号需要根据列表样式自行判断。我处理 docx 时通常把“正文内容”定义为“所有非空段落 所有表格单元格里的非空文字”。表格拼接时用竖线分隔单元格这样可以保留表格的行列结构。对于最终交付件如果要把处理后的内容重新写回 docx 并保留样式那就需要更多的样式处理逻辑。如果只是为了后续分析提取成纯文本就够了不一定要还原全部视觉细节。PDF 的文本提取偏差更常见。PDF 的文本层并不是按“行”存储的而是按“显示位置”存储文字片段的段落中的换行在视觉上是一行在文本层却可能是独立片段。直接拼接所有文本经常出现一句被拆成两段的情况。比较实用的做法是页内提取后按换行符再合并过短的行同时忽略页码、页眉页脚。如果 PDF 版本复杂不追求完美达到可接受的水平就可以了。6.3 处理大量文件时的性能和容错控制大批量处理时性能问题主要出在两个地方一是文件读取和写入的次数二是内存占用。按“逐文件处理、用完即走”的思路就能避免把所有文件内容一次性存进列表里。每个文件读入内存、清洗、统计、写盘然后释放整个过程对内存的占用几乎是常量级。如果文件数量很大建议用生成器逐批遍历而不是用列表推导式一次性生成所有路径。还有一个细节是磁盘写入每篇文章都单独写一个输出文件时尽量用 UTF-8 编码并指定换行符避免某些系统下换行混乱。如果输出文件非常多也可以按日期或批次分子目录存放而不是全部堆在同一个目录里。别小看这一步数千个文件平铺在一个目录里光打开文件管理器都会卡顿。容错方面批处理最重要的原则是“单个文件失败不影响整个批次”。所以在主循环里要捕捉异常记录失败信息继续处理下一个文件。处理完成后检查失败记录确认哪些文件需要单独处理。这个思路看似简单却是从一次全批次中断的教训里提炼出来的。当时因为一个明显损坏的 docx 文件让整个脚本抛错后续文件全部没有处理不得不手动改数据重跑代价不小。还可以加一个简单的时间统计记录每个文件消耗的时间超过某个阈值就打印警告。因为某些 PDF 提取特别慢可能是页数极多或者结构异常提前发现能做人工干预总比等整批跑完才发现要好。6.4 一份常用问题的快速排查表现象可能原因排查/解决思路读 TXT 报 UnicodeDecodeError文件不是 UTF-8 编码按 GBK、UTF-16 顺序尝试解码清洗后首字符出现乱码文件带了 BOM在清洗入口剥离\ufeff表格里的内容丢失只遍历了段落没遍历表格增加表格单元格遍历逻辑PDF 后面出现成片空字符串扫描版 PDF无文本层标记为待人工处理或用专门的 OCR 工具统计的中文字数比预期少正则只匹配[\u4e00-\u9fa5]漏掉扩展区按需求扩展字符范围批处理中途中断没有捕获单文件异常主循环捕获异常并写入失败记录输出文件太多目录卡顿全部输出在上层目录按日期或批次分子目录存储这张表基本涵盖了我在实际任务里高频踩中的问题。文档处理技术本身不算复杂真正考验功力的是对各种边界情况的预判和处理。把这张表当作“启动前检查清单”也可以跑批任务之前先对照一遍能少走不少弯路。这篇文章写到这里我还没有提到任何特别神奇的库或方法因为文档文章处理的底色就是这样相当一部分时间花在理解数据、清理数据和处理异常上。我个人体会最深的是处理链路的模块化和输出结果的可见性比代码短小重要得多。每次我把数据清洗、统计、导出这几个环节拆开之后遇到新需求都能快速重用旧代码反过来如果当初图省事把逻辑揉成一大团后面一定需要反复返工。如果再有新的文章处理需求我建议先从读取入口和清洗规则开始搭用少量真实文件跑通第一版确认清洗效果没有误伤信息之后再扩展到批量。这样每一轮迭代的反馈都很清晰也更容易定位问题所在。文档处理的效率不取决于某一行的技术深度而取决于你对这批内容的理解程度。拿到一批文档先翻几篇看看真实状态再动手写规则永远是最快的路径。