AI工具教程

用 Python 批量处理 PDF 文件:提取文本、按规则命名并检查异常文件

AI智能摘要

面对合同、发票和资料 PDF,逐个打开、改名、分拣既耗时又容易漏掉异常。借助 Python 与开源 pypdf,可批量提取文本,按编号、日期等规则命名,复制成功文件并记录日志,同时将扫描件、加密、损坏、空文本和重复文件送入 review 目录。但它不能替代 OCR 或法律审阅,怎样建立更稳妥的自动化流程?

— 此摘要由AI分析文章内容生成,仅供参考。

如果你经常收到合同、发票或资料 PDF,可以用 Python 把“收集文件、提取文本、生成新名称、分拣异常文件”串成一次批处理。文本型 PDF 通常可以直接读取;扫描件、加密文件、损坏文件和提取结果为空的文件,则应进入异常目录,等待 OCR 或人工复核,而不是强行改名。

Python 批量处理 PDF 的文件分类流程示意图

这类 PDF 适合直接批量处理吗

PDF 是否能被脚本直接处理,关键不在文件扩展名,而在文件内部是否包含可读取的文本层。

PDF 类型是否适合直接提取文本建议处理方式
由 Word、网页或办公软件导出的 PDF通常可以直接提取文本并按规则命名
带有可复制文字的合同或发票通常可以提取标题、编号、日期或原文件名
扫描仪生成的图片型 PDF通常不可以转交 OCR,或人工命名
设置了打开密码的 PDF取决于是否有密码提供密码后再处理
损坏、截断或结构异常的 PDF不稳定移到异常目录并记录原因
能打开但提取结果为空的 PDF不一定视为 OCR 候选文件

本文使用开源的 pypdf 读取 PDF 文本。它适合文件整理和初步分类,但不能替代专业 OCR,也不能判断合同条款是否有效,更不能替代法律审阅。

安装 Python 库并准备目录

安装依赖

先确认已经安装 Python,然后在终端执行:

纯文本
python -m pip install pypdf

在部分 Windows 环境中,Python 命令可能是 py:

纯文本
py -m pip install pypdf

建议在一个单独的项目目录中保存脚本,例如:

纯文本
pdf_batch/
├── batch_rename_pdf.py
├── input/
├── output/
└── review/

三个目录分别用于:

  • input:放入待处理的 PDF。
  • output:保存成功提取并完成命名的 PDF。
  • review:保存加密、损坏、空文本和重复文件等需要检查的 PDF。

脚本默认复制文件,而不是直接移动或覆盖原文件。这样即使命名规则写错,也可以从 input 目录重新处理。

编写批量处理脚本

下面的脚本会完成这些工作:

  1. 扫描 input 目录下的 PDF 文件。
  2. 尝试提取前几页或全部页面的文本。
  3. 根据发票号、合同号和日期等规则生成名称。
  4. 无法提取文本时,将文件复制到 review 目录。
  5. 对完全相同的文件内容进行标记,避免重复处理。
  6. 处理目标文件名冲突,并生成 process_log.csv 日志。

将以下内容保存为 batch_rename_pdf.py:

纯文本
from __future__ import annotations

import csv
import hashlib
import re
import shutil
from pathlib import Path

from pypdf import PdfReader


BASE_DIR = Path(__file__).resolve().parent
INPUT_DIR = BASE_DIR / "input"
OUTPUT_DIR = BASE_DIR / "output"
REVIEW_DIR = BASE_DIR / "review"
LOG_FILE = BASE_DIR / "process_log.csv"

# 设置为 None 表示读取全部页面。
# 如果文件很多,也可以改为 3,只读取前 3 页。
MAX_PAGES_TO_READ = None


def file_hash(path: Path) -> str:
    """计算文件的 SHA-256,用于识别完全相同的文件。"""
    digest = hashlib.sha256()

    with path.open("rb") as file:
        for chunk in iter(lambda: file.read(1024 * 1024), b""):
            digest.update(chunk)

    return digest.hexdigest()


def clean_filename(value: str, fallback: str = "未命名文件") -> str:
    """清理不适合作为文件名的字符。"""
    value = re.sub(r"s+", " ", value).strip()
    value = re.sub(r'[\/:*?"<>|]+', "_", value)
    value = value.strip(" .")

    return value[:120] if value else fallback


def extract_text(pdf_path: Path) -> tuple[str, str]:
    """
    返回:提取出的文本、状态。
    status 可能是 ok、encrypted、empty 或 error。
    """
    try:
        reader = PdfReader(str(pdf_path), strict=False)

        if reader.is_encrypted:
            # 只尝试处理没有密码的加密文件。
            # 有密码的文件需要额外配置密码,不能猜测或绕过密码。
            decrypt_result = reader.decrypt("")

            if not decrypt_result:
                return "", "encrypted"

        pages = reader.pages
        selected_pages = pages

        if MAX_PAGES_TO_READ is not None:
            selected_pages = pages[:MAX_PAGES_TO_READ]

        text_parts = []

        for page in selected_pages:
            page_text = page.extract_text() or ""
            if page_text.strip():
                text_parts.append(page_text)

        text = "n".join(text_parts).strip()

        if not text:
            return "", "empty"

        return text, "ok"

    except Exception as exc:
        return f"{type(exc).__name__}: {exc}", "error"


def first_match(pattern: str, text: str) -> str | None:
    match = re.search(pattern, text, flags=re.IGNORECASE)
    return match.group(1).strip() if match else None


def build_new_stem(original_stem: str, text: str) -> str:
    """
    按优先级尝试提取:
    1. 发票号码
    2. 合同编号
    3. 日期
    4. 原文件名
    """
    invoice_no = first_match(
        r"(?:发票号码|发票号|Invoices*No.?)s*[::]?s*([A-Z0-9-]+)",
        text,
    )

    contract_no = first_match(
        r"(?:合同编号|合同号)s*[::]?s*([A-Z0-9-]+)",
        text,
    )

    date_value = first_match(
        r"(20d{2}[-年/.]d{1,2}[-月/.]d{1,2}日?)",
        text,
    )

    if invoice_no:
        return clean_filename(f"发票_{invoice_no}")

    if contract_no:
        return clean_filename(f"合同_{contract_no}")

    if date_value:
        date_value = re.sub(r"[年月/.]", "-", date_value).replace("日", "")
        return clean_filename(f"资料_{date_value}")

    return clean_filename(original_stem, fallback="未命名文件")


def make_unique_path(directory: Path, stem: str, suffix: str) -> Path:
    """避免不同文件生成相同名称。"""
    candidate = directory / f"{stem}{suffix}"
    counter = 2

    while candidate.exists():
        candidate = directory / f"{stem}_{counter}{suffix}"
        counter += 1

    return candidate


def copy_to_review(pdf_path: Path, reason: str) -> Path:
    target = make_unique_path(REVIEW_DIR, pdf_path.stem, pdf_path.suffix.lower())
    shutil.copy2(pdf_path, target)
    return target


def main() -> None:
    for directory in (INPUT_DIR, OUTPUT_DIR, REVIEW_DIR):
        directory.mkdir(parents=True, exist_ok=True)

    pdf_files = sorted(
        path for path in INPUT_DIR.iterdir()
        if path.is_file() and path.suffix.lower() == ".pdf"
    )

    if not pdf_files:
        print(f"没有在 {INPUT_DIR} 中找到 PDF 文件。")
        return

    rows = []
    seen_hashes: set[str] = set()

    for pdf_path in pdf_files:
        try:
            current_hash = file_hash(pdf_path)
        except Exception as exc:
            rows.append({
                "original": pdf_path.name,
                "result": "",
                "status": "hash_error",
                "reason": str(exc),
            })
            continue

        if current_hash in seen_hashes:
            review_path = copy_to_review(pdf_path, "duplicate_content")
            rows.append({
                "original": pdf_path.name,
                "result": review_path.name,
                "status": "duplicate_content",
                "reason": "与本批次中此前的文件内容完全相同",
            })
            continue

        seen_hashes.add(current_hash)

        text, status = extract_text(pdf_path)

        if status != "ok":
            review_path = copy_to_review(pdf_path, status)
            rows.append({
                "original": pdf_path.name,
                "result": review_path.name,
                "status": status,
                "reason": text if status == "error" else status,
            })
            continue

        new_stem = build_new_stem(pdf_path.stem, text)
        output_path = make_unique_path(
            OUTPUT_DIR,
            new_stem,
            pdf_path.suffix.lower(),
        )

        shutil.copy2(pdf_path, output_path)

        rows.append({
            "original": pdf_path.name,
            "result": output_path.name,
            "status": "ok",
            "reason": "",
        })

    with LOG_FILE.open("w", newline="", encoding="utf-8-sig") as file:
        writer = csv.DictWriter(
            file,
            fieldnames=["original", "result", "status", "reason"],
        )
        writer.writeheader()
        writer.writerows(rows)

    success_count = sum(row["status"] == "ok" for row in rows)
    review_count = len(rows) - success_count

    print(f"处理完成:成功 {success_count} 个,待复核 {review_count} 个。")
    print(f"日志文件:{LOG_FILE}")


if __name__ == "__main__":
    main()

根据你的文件格式修改命名规则

代码中的 build_new_stem() 决定新文件名。当前优先识别:

  • 发票号码:ABC123
  • 发票号: ABC123
  • 合同编号:HT-2024-001
  • 合同号: HT-2024-001
  • 类似 2024-05-18、2024年5月18日 的日期

例如,文本中包含:

纯文本
合同编号:HT-2024-001

文件可能会被命名为:

纯文本
合同_HT-2024-001.pdf

如果你的 PDF 使用“项目名称”“客户名称”或“订单号”等字段,可以增加正则规则。例如:

纯文本
project_name = first_match(
    r"(?:项目名称|项目名)s*[::]?s*(.+)",
    text,
)

if project_name:
    return clean_filename(f"项目_{project_name}")

规则越具体,误命名的概率越低。建议先用少量样本测试,不要一开始就对数千个文件执行覆盖式改名。

文本型 PDF 与异常 PDF 的批处理分流示意图

运行脚本并检查结果

在脚本所在目录执行:

纯文本
python batch_rename_pdf.py

运行结束后,可以检查以下位置:

纯文本
output/
├── 合同_HT-2024-001.pdf
└── 发票_ABC123.pdf

review/
├── scan_001.pdf
├── encrypted_002.pdf
└── damaged_003.pdf

process_log.csv

process_log.csv 使用 UTF-8 带 BOM 的编码写入,在 Windows 版 Excel 中通常可以直接打开。日志中的 status 字段用于区分处理结果:

  • ok:成功提取文本并复制到输出目录。
  • empty:读取成功,但没有得到有效文本。
  • encrypted:文件需要密码,脚本没有继续处理。
  • error:读取过程中发生异常。
  • duplicate_content:文件内容与本批次此前的文件完全相同。
  • hash_error:读取文件指纹时发生错误。

扫描件为什么不能直接依赖文本提取

扫描型 PDF 的每一页可能只是图片。虽然 PDF 阅读器能显示页面,但页面上没有可供 pypdf 读取的文字对象,因此脚本得到的文本可能为空。

这时可以采用以下流程:

  1. 先用本文脚本筛出 empty 文件。
  2. 将这些文件交给 OCR 工具处理。
  3. OCR 生成可搜索的 PDF 或文本文件。
  4. 再次运行命名脚本。
  5. 对合同金额、日期、当事人和编号进行人工复核。

OCR 的识别结果可能受到字体、印章、表格、低分辨率、倾斜页面和手写内容影响。特别是金额、身份证号、银行账号、合同编号等字段,不应只因为 OCR 提取成功就认为内容准确。

常见异常和处理方式

文件能打开,但脚本提示为空

这通常表示文件是扫描件,或者文字以脚本暂时无法解析的方式嵌入。可以先复制到 review 目录,再通过 OCR 处理。

如果只有少数文件异常,也可以直接人工查看并按原文件名整理,不必为了少量文件改造整个流程。

文件被加密

脚本只尝试处理没有设置实际密码的加密文件。对于需要密码的 PDF,你需要:

  • 向文件提供者索取密码;
  • 在得到授权的前提下先解密并另存文件;
  • 将解密后的副本放入 input 目录重新处理。

不要把密码硬编码到脚本中,也不要尝试绕过不属于你的文件权限。

多个文件生成同一个名称

脚本会自动添加 _2、_3 等后缀,例如:

纯文本
合同_HT-2024-001.pdf
合同_HT-2024-001_2.pdf

这能避免覆盖文件,但也说明命名字段可能不够唯一。你可以把原文件名、客户名称或日期加入规则,减少同名情况。

文件名中出现非法字符

Windows 不允许文件名包含以下字符:

纯文本
 / : * ? " < > |

脚本会把这些字符替换为下划线,并限制文件名长度。若业务系统对中文或文件名长度有额外限制,可以在 clean_filename() 中进一步调整。

批处理后是否应该删除原文件

不建议脚本自动删除原文件。更安全的做法是:

  1. 先保留 input 原件。
  2. 检查 output 和 process_log.csv。
  3. 随机打开若干份文件,确认内容没有被改变。
  4. 确认异常文件已经单独备份后,再由你手动清理输入目录。

适合采用的处理边界

这套 Python 脚本适合处理规则相对稳定、数量较多、需要减少重复命名工作的 PDF,例如:

  • 从邮件或共享目录集中收到的合同;
  • 需要按发票号码归档的票据;
  • 文件名混乱但正文包含固定字段的资料;
  • 需要先分出扫描件和加密文件的资料包。

它不适合直接承担以下任务:

  • 判断合同条款是否合法;
  • 自动确认发票金额是否正确;
  • 在没有授权的情况下解密文件;
  • 对手写内容或复杂表格做可靠识别;
  • 仅凭 OCR 结果决定财务、法律或合规结论。

你可以先用 10 到 20 个样本验证提取结果和命名规则,再扩大到完整目录。把“文本提取成功”和“业务内容确认”分开处理,才能让批量改名真正节省时间,同时避免把 OCR 边界误当成自动审核能力。

热门话题

52okp 是一名关注人工智能、开源软件与效率工具的技术内容创作者,长期实践 Stable Diffusion、ComfyUI、AI 智能体、MCP、Codex 和各类开源项目。通过实际安装、配置与测试,整理可复现的操作教程、问题排查方法和工具使用经验。

登录用户才能发表评论! 登录账户

取消回复

评论列表 (1条):

加载更多评论 Loading...

延伸阅读:

在 VS Code 中搭建 Python 办公自动化环境:从解释器选择到依赖安装

文章以 VS Code 和 Python 为基础,讲解办公自动化项目的完整环境搭建流程:准备编辑器、解释器与 Pytho...

52okp
2026-09-22
    返回顶部