拿文档处理说事儿很多人第一反应是这还不简单打开文件复制粘贴另存为完事儿。真到手上批量处理几十个、上百个文件时你很快会发现事情完全不是想象中那样。编码乱码、标题格式不统一、PDF里文字顺序错乱、Word里的表格读不出来、来回手动整理想死的心都有。这时候用Python写一个小脚本来做文档文章处理几乎是所有干过这活儿的人都会走的一条路。这篇文章我不想讲大而全的教程而是从一个实际动手者的角度把文档文章处理这个需求拆开来看它到底在解决什么问题选哪些工具顺手核心流程怎么搭实际操作中会踩哪些坑。内容覆盖纯文本、Markdown、Word、PDF等常见格式适合正在做办公自动化的同学、需要批量整理文章的内容运营以及正准备给手里的杂七杂八文档做“大扫除”的人。下面所有代码和步骤都是可以直接照着跑的程度。1. 文档文章处理先搞清你属于哪种需求1.1 三类最常见的“文档文章处理”场景我接触过不少提这个需求的人表面上看大家都是说“帮我处理一下文档”但细聊下来需求完全不同。大致能分成三类。第一类是办公自动化。典型问题是手里的报表是几十个Word文件要提取里面的标题、日期、正文段落或者说有一大堆PDF需要批量把首页信息抽出来又或者要把一堆旧文档从一种格式统一转成另一种格式。这类需求的核心是“提取”和“转换”对文本精确度要求高弄错一个数字可能就麻烦。第二类是内容生产与整理。自媒体编辑、技术写作、课程运营这类人群通常手里有一堆草稿、素材、历史文章格式五花八门有的在Markdown里有的在Word里还有从网页上复制下来带着一堆乱七八糟标签的文本。他们想要的不是简单提取而是把内容整理成统一风格批量做错别字检查、字数统计、重复内容删减。第三类是数据分析与文本挖掘。这类人往往不关心单个文档长什么样而是关心一堆文档拼起来之后的整体信息。比如把所有反馈文件合并清洗掉噪音再做分词、关键词统计。这类需求核心是“清洗”和“结构化”格式对不对远没有内容能不能被机器读入重要。三类场景的共性是手动处理太累容易漏而且没法复现。今天花两小时整理好明天又来一批新文件又得重来。用程序处理的核心价值不是“自动”而是“可复现、可批量、可审计”——同样的规则跑一百次都不会偷懒。1.2 为什么偏偏选 Python能处理文档的语言不止一种但Python在这个领域的综合上手成本是最低的。理由不外乎三点一是生态齐全文本处理、PDF解析、Word读写、网页正文提取都有很成熟的现成库二是代码表达力强几行就能完成一个相对复杂的处理流程不需要像其他语言那样写一大坨底层逻辑三是社区资源足够密绝大多数你遇到过的奇怪问题搜一搜基本都有对应的处理思路。另外一点很多人没注意Python在“脏数据”场景下特别宽容。文档处理免不了遇到某个文件格式损坏、编码不对、中间夹着特殊字符Python可以用异常捕获把这些情况拦下来做成“跳过”或者“记录到日志里”不会因为一个坏文件让整批任务崩掉。这是写业务脚本时很关键的能力。当然Python不是万能的。特别复杂的PDF版面还原、扫描件OCR、复杂表格结构抽取还得配合专业工具甚至人工介入。但作为处理链条里的“调度中枢”和“清洗工厂”它非常合适。2. 环境准备与工具选型基础不牢后面全白搭2.1 不同文档类型对应的核心库先解决一个常见困惑处理文档到底该用哪些库答案取决于你手里的文件类型不要贪多只装用得上的。文档类型推荐库主要用途纯文本 / Markdown直接使用内置open()、re、pathlib读写、正则清洗、批量操作Word .docxpython-docx读取段落、标题、表格生成Word老版 .docantiword命令行不是Python库虽然麻烦但比硬写解析靠谱PDF文字版PyMuPDFfitz为主pdfplumber为辅提取文字、按页定位、处理表格HTML网页BeautifulSoup4lxml或trafilaturaHTML清洗、正文抽取通用文本匹配retextdistance正则匹配、重复内容近似度中文文本分析jieba、pycorrector分词、错别字检查按需引入这里我想多说一句PDF的情况。很多朋友图省事用某种在线工具转换PDF结果不是乱码就是版式位移。用PyMuPDF提取纯文字版PDF是目前体验相对稳定的方案速度也快。如果遇到需要保留表格结构、按行坐标读取的情况pdfplumber更合适但它比PyMuPDF慢不少。一般建议先用PyMuPDF跑遇到明显错乱再针对局部文件换工具而不是上来就全量用最重的方案。.doc这类老格式最好别直接读。python-docx只支持新版的.docx旧版.doc本质上是二进制复合文档直接用Python解析的成本极高。可行的路线是用antiword先转成纯文本或者让用户先另存为新格式。这个我在后面常见问题里还会再次强调。2.2 一套稳妥的环境搭建流程我不会建议你直接往全局环境里装一堆库。文档处理涉及的库依赖复杂尤其lxml、PyMuPDF这类带编译产物的包全局环境下很容易和别的项目冲突。建议新建一个专用虚拟环境。mkdir doc_handler cd doc_handler python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate然后安装核心依赖pip install python-docx PyMuPDF beautifulsoup4 lxml trafilatura textdistance如果你要处理PDF里的表格再补充pip install pdfplumber安装完成后建议第一时间验证一下版本免得后面API差异造成报错。import docx import fitz from bs4 import BeautifulSoup print(docx.__version__ if hasattr(docx, __version__) else python-docx is ready) print(fitz.__doc__)这一步看起来简单但能省掉不少排查时间。尤其是PyMuPDF的版本迭代很快不同版本之间提供的方法名略有差异我在网上看到过不少“昨天还能跑今天报错”的案例基本都跟升级后API变化有关。配好环境后建议把用到的版本号记到requirements.txt里留个底。3. 核心处理流程拆解读取、清洗、结构化、输出3.1 读取阶段编码和文件类型是两个大坑文档处理的第一个环节是读取这里翻车率最高。纯文本文件最容易出问题的是编码。很多同事传来一个.txt文件名上写着“UTF-8”实际上是用Windows记事本另存为“ANSI”格式即GBKPython用默认的utf-8编码去读直接抛UnicodeDecodeError。就算不抛错读出来也是一堆乱码。稳妥的做法是优先用文件头部BOM判断没有BOM时再用常见编码做尝试读取。from pathlib import Path def read_text_file(filepath): path Path(filepath) raw path.read_bytes() if raw.startswith(b\xef\xbb\xbf): return raw.decode(utf-8-sig) if raw.startswith(b\xff\xfe) or raw.startswith(b\xfe\xff): return raw.decode(utf-16) for enc in (utf-8, gbk): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace)这个函数的思路是先看有没有BOM有就直接按对应编码处理没有就试探性解码。gbk在中文Windows环境里非常常见放在utf-8后面备用比较合理。最后实在不行就用errorsreplace把个别无法识别的字符替换成占位符保证程序不崩至于字符丢失情况我们再在日志里标记出来。读取普通纯文本还有一个隐蔽问题行尾符。Windows下是\r\nLinux下是\n如果直接按行切分可能带着一个\r干扰后面的正则匹配。通常在读取后统一做一次归一化text text.replace(\r\n, \n).replace(\r, \n)这一步很便宜但能让后面处理少很多没头没尾的怪问题。对于 Word 和 PDF 文档编码问题不明显但要区分“读取文本”和“读取结构”。比如python-docx读取.docx时表面上是读段落实际上每个段落还有样式、层级关系。如果你只想要纯文本直接遍历paragraphs取text即可如果你要保留标题层级就得把style.name一并取出来。PDF 更麻烦它是按页面、按文本块组织的读取时还要考虑坐标顺序这个下面单独讲。3.2 清洗阶段文本清洗怎么做才不掉东西清洗是所有环节里最考验细致程度的。处理不好轻则多余的空行没去掉重则把文章里正常需要的符号、结构给删没了。先建立一个原则清洗过程必须尽量把操作拆成单步且每步只做一件明确的事。我在实际项目里总结的一套最小清洗流程是这样的统一换行符去掉“零宽字符”和不可见控制字符合并连续空行为一个去除行首行尾空白清理全角空格、不间断空格按需替换错误标点去掉网址里多余参数可选前四步几乎是所有场景都必须做的。代码可以这样写import re ZERO_WIDTH_CHARS re.compile(r[\u200b\u200c\u200d\ufeff]) CONTROL_CHARS re.compile(r[\x00-\x08\x0b\x0c\x0e-\x1f]) NBSP re.compile(r\u00a0) def clean_text(text: str) - str: text text.replace(\r\n, \n).replace(\r, \n) text ZERO_WIDTH_CHARS.sub(, text) text CONTROL_CHARS.sub(, text) text NBSP.sub( , text) lines [line.strip() for line in text.split(\n)] cleaned_lines [] blank_count 0 for line in lines: if line: blank_count 0 cleaned_lines.append(line) else: blank_count 1 if blank_count 1: cleaned_lines.append() text \n.join(cleaned_lines).strip() return text注意我这里的空行合并逻辑连续多个空行只保留一个。如果你处理的是Markdown空行在块级语法里是有含义的所以不能全部删掉保留一个空行最稳妥。CONTROL_CHARS这个正则不要误删\n和\t所以我排除了这几个常用字符。关于标点替换我踩过一次坑。有些从网页复制的文稿里中文引号被转成了英文引号逗号有的是中文全角有的是英文半角。如果后面要做分词或者统计最好统一成中文标点但如果做的是搜索引擎输入反而可能保留原样更好。所以这一步要当作业务规则来定义不要一把梭。建议把规则集中放在一个配置列表里方便后面改PUNCT_RULES [ (,, ), (;, ), (!, ), (?, ), (:, ), ] def normalize_punct(text: str) - str: for old, new in PUNCT_RULES: text text.replace(old, new) return text这里需要提醒不要把这种替换用在代码块或网址里否则会破坏内容。所以更严谨的做法是先临时用占位符保护代码块清洗完再还原。3.3 结构化阶段标题识别、分块、切句清洗完的文本是一长串但很多时候我们需要的是“按文章结构”来管理。比如生成目录、统计每个章节字数、按标题拆分成独立文档。结构化第一步是标题识别。不同来源规则不同Markdown 文件里标题是#开头的一行正则很好写^#{1,6}\sWord 文档里标题通常对应样式名里的“标题1”“标题2”纯文本里往往是“第X章”“一、”这类编号前缀处理时不要只靠一种规则建议把所有候选规则都跑一遍再结合行长度做判断。举个例子import re MD_HEADING re.compile(r^(#{1,6})\s(.*)$) CN_NUM_HEADING re.compile(r^(第\s*[一二三四五六七八九十百千0-9]\s*[章篇节])\s*(.*)$) CN_SECTION_HEADING re.compile(r^([一二三四五六七八九十]、)\s*(.*)$) def detect_heading(line: str): for pattern in (MD_HEADING, CN_NUM_HEADING, CN_SECTION_HEADING): m pattern.match(line) if m: return (len(m.group(1)), m.group(2).strip()) return None这个函数返回的是(层级, 标题文字)。如果没有匹配到返回None。需要注意的是不要只看行首正则还要过滤“只有标题没人超过50个字”之类的可疑行否则有些索引行也被误判成标题。结构化第二步是分块。分块有两种思路。一种是按标题层级把内容拆成树状结构另一种是固定长度切块比如按段落、按句长切。如果只是做批处理整理按标题拆成列表就够了。def split_by_heading(text: str): blocks [] current_heading None current_level 0 current_content [] for line in text.split(\n): heading detect_heading(line) if heading: if current_heading is not None: blocks.append({ level: current_level, title: current_heading, content: \n.join(current_content).strip() }) current_heading heading[1] current_level heading[0] current_content [] else: current_content.append(line) if current_heading is not None: blocks.append({ level: current_level, title: current_heading, content: \n.join(current_content).strip() }) return blocks结构化第三步是切句。基础方案是按中文句末标点切分。但不建议直接用re.split把句子全拆开因为会破坏引号里的对话和省略号。我一般会保留简单规则句号、问号、感叹号后跟着换行或空格时才切。SENTENCE_BOUNDARY re.compile(r(?[。?])\s*) def split_sentences(paragraph: str): parts SENTENCE_BOUNDARY.split(paragraph) return [p.strip() for p in parts if p.strip()]这个正则用的是零宽后行断言只切在标点之后、空白之前的位置能保留中文引号内部的完整句子。注意它不会处理省略号“……”结尾的句子所以如果文本里省略号很多可以在这个正则里再加一个“…”的边界条件。4. 实操案例批量整理一整个文件夹的文章4.1 案例背景与脚本设计拿一个很常见的需求来演示某内容团队把一个文件夹里散落的.txt、.md、.docx文件统一转成带标题结构的纯文本并且生成一份总目录和字数统计。这个需求我做过不止一次看起来很基础但代码里包含的异常处理、编码处理、文件类型分流是几乎所有文档处理脚本都要用到的骨架。我先给出脚本整体设计。数据源目录假设是./articles_input里面可能有子目录输出目录是./articles_output。脚本要做四件事递归扫描所有.txt、.md、.docx文件按不同扩展名调用对应读取函数清洗后按文章原始结构输出成统一格式生成一个output_index.csv记录文件名、标题、字数、输出路径设计上一定要把“读取”和“处理”分开。因为不同类型的文件读取方式差异大但进入清洗阶段后它们就统一成纯文本了这样后续逻辑不用为每种格式写一遍。4.2 核心代码和关键写法说明主循环如下from pathlib import Path import csv from docx import Document INPUT_DIR Path(./articles_input) OUTPUT_DIR Path(./articles_output) OUTPUT_DIR.mkdir(parentsTrue, exist_okTrue) SUPPORTED_SUFFIX {.txt, .md, .docx} def read_docx(filepath: Path) - str: doc Document(str(filepath)) parts [] for para in doc.paragraphs: text para.text.strip() style para.style.name if para.style else if not text: parts.append() continue if style.startswith(Heading): level int(style.replace(Heading , ).strip() or 1) prefix # * level parts.append(prefix text) else: parts.append(text) return \n.join(parts) def extract_title(filepath: Path, text: str) - str: lines [l.strip() for l in text.split(\n) if l.strip()] head detect_heading(lines[0]) if lines else None if head: return head[1] return filepath.stem def process_one_file(filepath: Path): suffix filepath.suffix.lower() if suffix .docx: raw_text read_docx(filepath) else: raw_text read_text_file(filepath) raw_text raw_text.replace(\r\n, \n).replace(\r, \n) cleaned clean_text(raw_text) title extract_title(filepath, cleaned) output_name filepath.stem .txt output_path OUTPUT_DIR / output_name output_path.write_text(cleaned, encodingutf-8) return filepath.name, title, len(cleaned), str(output_path)这段代码有几个设计要点read_docx里把Word的“标题1”“标题2”样式转成了Markdown的#前缀这样后续就能统一走detect_heading逻辑。extract_title优先看正文第一个有没有标题找不到才用文件名。这个顺序很重要因为很多文件正文标题和文件名不一样。读取纯文本时统一走前面写的read_text_file编码兼容性才有保障。写入输出时强制用utf-8避免再次出现编码问题。你如果要给别人用可以再补一个“自动给文件名加序号”的操作防止不同子目录同名文件互相覆盖。批量处理部分用Path.rglob遍历并对每个文件加try和日志import logging logging.basicConfig(levellogging.INFO, format%(levelname)s %(message)s) rows [] for filepath in sorted(INPUT_DIR.rglob(*)): if filepath.suffix.lower() not in SUPPORTED_SUFFIX: continue try: row process_one_file(filepath) rows.append(row) logging.info(已处理: %s, filepath.name) except Exception as exc: logging.warning(处理失败 %s: %s, filepath, exc)日志里只记录文件名和异常内容不打印整个堆栈。大批量跑的时候一堆堆栈信息反而没用你只需要知道哪一个文件挂了、什么原因。4.3 输出结果示例跑完脚本后输出目录里每个文件变成了一致风格的.txt下面是一个整理后的段落结构示例# 月报数据说明 本月数据已统一整理整体趋势如下。 ## 访问量 访问量较上月增长明显。 ## 转化率 转化率保持稳定。然后再用一份CSV把处理结果汇总起来filename,title,chars,output a.txt,月报数据说明,152,./articles_output/a.txt b.md,常见问题汇总,439,./articles_output/b.txt c.docx,发布规范,872,./articles_output/c.txt这个CSV的好处是后面不管是要做字数汇总、重复检查还是往Excel里导入都有据可查。如果你需要把多个文件合并成一个总文件也很简单直接在循环里把每篇文章用两个换行分隔后追加到一个大文件即可。5. 常见问题与排查技巧实录5.1 高频问题速查表下面这些问题是文档处理里重复出现率最高的我把它们整理成速查表。遇到问题先对号入座能省下大量搜索时间。症状常见原因处理建议打开txt文件乱码文件实际是GBK/ANSI被按UTF-8读取用前面read_text_file的兼容逻辑增加编码探测UnicodeDecodeError文件包含非法字节序列读取时用errorsreplace同时记录文件名用python-docx打不开文件文件其实是.doc或已损坏先确认扩展名建议另存为.docxPDF提取出的文字顺序错乱PDF是双栏排版或包含复杂图层用PyMuPDF的get_text(dict)按坐标排序或者换pdfplumber正则把Markdown标题误删了清洗时没有保留#结构清洗前用占位符保护标题行或者清洗时跳过^#{1,6}\s行批处理跑一半崩溃某个坏文件触发了未捕获异常给每个文件包一层try-except错误写日志输出文件多了看不见的空字符原文本包含零宽空格用ZERO_WIDTH_CHARS正则清除处理.rar/.zip压缩包当前脚本没有解压逻辑先用zipfile或命令行解压再进入处理主流程5.2 排查思路与避坑经验先说编码问题。很多人以为用自动检测编码的库就能解决我在实践中的体验是这类库对中文文本的准确率没那么高尤其是长文本里夹杂了大量ASCII符号时。所以我的建议是“BOM优先、常见编码其次、检测兜底”。不要一上来就跑chardet先按文件头部特征判断再尝试解码这样速度快也稳定。再说PDF。PDF读取最大的坑是“看着有字提取出来没字”或者“提取出来顺序错乱”。如果是图片型PDF也就是扫描件或照片任何文本提取库都无能为力必须OCR。好在现在OCR工具成熟可以先把PDF每页渲染成图片再用OCR识别。但这是另一套方案了。文字顺序错乱多半是双栏版式问题PyMuPDF的基础get_text()是按内部逻辑顺序返回的有时不是我们预期的“从左到右从上到下”需要按文本块的坐标排序。简单的处理办法import fitz def extract_pdf_sorted(path): doc fitz.open(path) full_text [] for page in doc: blocks page.get_text(blocks) # (x0, y0, x1, y1, text, ...) blocks.sort(keylambda b: (round(b[1], 1), b[0])) full_text.append(\n.join(b[4].strip() for b in blocks if b[4].strip())) return \n.join(full_text)blocks里的坐标排序先按 y 轴行再按 x 轴列通常能解决大部分双栏问题。但碰上复杂表格区域这种方式还是不够得靠pdfplumber的extract_table之类功能。所以每次处理PDF前先确认“需要的是全文还是特定结构的表格”再决定技术路线。还有一个很容易忽视的点Word文档里的内容不只分布在段落里还有表格、页眉页脚、文本框。python-docx的paragraphs属性只覆盖“正文段落”表格里的文字要单独遍历doc.tables。如果你的业务方说“文档里有个表格也要处理”而你只处理了段落那结果必然缺数据。我一般会写一个函数把段落和表格合并读取def read_docx_all(doc): parts [] for para in doc.paragraphs: parts.append(para.text) for table in doc.tables: for row in table.rows: for cell in row.cells: parts.append(cell.text) return \n.join(parts)当然这个函数会丢失表格的二维结构只适合纯文本抽取。如果你要保留表格结构建议输出成CSV或HTML表格。6. 再往前走一步正文提取、去重与质量检查6.1 从网页批量提取文章正文批量处理本地文件只是需求的一部分很多人的文章其实是散落在网页端的。这时候就需要从HTML里抽取正文。用BeautifulSoup可以做针对性的抽取比如抓article或者.content这些容器。但不同网站的页面结构千差万别靠手写选择器维护成本太高。更务实的方式是用专门做正文抽取的库。trafilatura就是专门干这个的它能自动检测网页正文区域剥离导航、广告、评论区输出相对干净的文本。用法非常简单import trafilatura downloaded trafilatura.fetch_url(https://example.com/some-article) content trafilatura.extract(downloaded, output_formattxt) with open(output.txt, w, encodingutf-8) as f: f.write(content)这类库适合批量抓取正文的场景。不过别指望它100%准确比如网页有弹窗遮挡、正文在iframe里、或者用了动态加载技术的页面它就抓不到了。面对那种页面还得用浏览器自动化工具但成本和复杂度会高很多。我的原则是能静态抽取的先静态抽取只有少数整页动态渲染的站点才考虑上浏览器方案。6.2 近似去重别被“看起来不同”骗了文章整理到一定规模重复问题就会出现。完全相同的重复很容易查比较哈希值即可。麻烦的是“几乎一样”的文章广告词换了几句、首段加了个词、图片URL不一样正文主体完全重复。这时候需要用到近似去重。比较基础的方案是用textdistance这个库计算文本之间的编辑距离或Jaccard相似度。但如果文本数量大两两比较的复杂度会爆炸。更常用的做法是先把每篇文章抽成一组“关键特征”比如百字连续片段、章节标题序列、正文前N个句子再用这些特征做碰撞检测。from textdistance import jaccard def ngram_set(text, n3): text re.sub(r\s, , text) return {text[i:in] for i in range(len(text)-n1)} def similarity(a, b): a_set ngram_set(a) b_set ngram_set(b) union len(a_set | b_set) if union 0: return 0.0 return len(a_set b_set) / union这里有几点很重要。一个是先做清洗再做去重尤其是去掉多余空格和标点否则长得完全一样但换行方式不同的文章也会被误判为低相似度。另一个是阈值的设定我一般会先跑一小批样本把相似度分布打出来再确定阈值不要随手定90%。因为中文文本里即使是很短的一篇文章3-gram的相似度也可能比较低但是核心章节如果一样相似度就会很高。6.3 文本质量检查字数、段落、错别字整理完的文章拿去做发布前质量检查也是一个很实际的需求。基础指标包括总字数、段落数、平均句长、最长段落、疑似空段落数。这些用纯Python就能统计。def text_statistics(text): paragraphs [p.strip() for p in text.split(\n) if p.strip()] sentences [] for p in paragraphs: sentences.extend(split_sentences(p)) total_chars len(re.sub(r\s, , text)) avg_para_chars total_chars / len(paragraphs) if paragraphs else 0 avg_sentence_len total_chars / len(sentences) if sentences else 0 return { total_chars: total_chars, paragraph_count: len(paragraphs), sentence_count: len(sentences), avg_para_chars: round(avg_para_chars, 1), avg_sentence_len: round(avg_sentence_len, 1), max_para_chars: max(len(re.sub(r\s, , p)) for p in paragraphs) if paragraphs else 0, }这个统计可以快速帮你发现“某篇文章有几段超长文字没分段”“某段疑似把两个段落黏在一起了”等问题。错别字检查属于进阶需求传统词典法覆盖不了所有问题。pycorrector这类工具适合做个粗检测但它对专业术语、人名地名的误报率不低不能直接自动改。我的做法是先让它跑一遍把疑似错别字和原句输出到Excel里再由人工确认。自动改错的风险就在于有些词在特定上下文里其实是正确的比如“交付”和“交手”只凭词典判断很不可靠。最后说几个小经验做文档处理这类脚本我最大的体会是千万不要一上来就写一个巨复杂的函数试图把所有格式、所有规则、所有清洗步骤都塞进去。正确顺序是先拿三五个真实文件手动跑通“读取到输出”的最小流程确认业务方认可输出结果再逐步增加文件类型和清洗规则。这套思路帮我躲过了很多后期推倒重来的局面。另一个很有用的技巧是文件处理前后都要留原始备份。脚本跑之前把输入目录压缩留档哪怕处理出问题也能随时回到原始状态。尤其是处理Word和PDF这类无法完美还原格式的文件时贸然覆盖源文件相当于自断退路。最后一个小技巧脚本里多打印“当前处理到第几个文件、这个文件多少字、是否成功”很多人批量处理几百个文件时只关心最终统计结果跑到一半某个文件挂掉也没发现。加日志看起来笨实际救命的次数远比你想象得多。希望这批经验能帮你少踩几个坑。