面对合同、发票和资料 PDF,逐个打开、改名、分拣既耗时又容易漏掉异常。借助 Python 与开源 pypdf,可批量提取文本,按编号、日期等规则命名,复制成功文件并记录日志,同时将扫描件、加密、损坏、空文本和重复文件送入 review 目录。但它不能替代 OCR 或法律审阅,怎样建立更稳妥的自动化流程?
— 此摘要由AI分析文章内容生成,仅供参考。
如果你经常收到合同、发票或资料 PDF,可以用 Python 把“收集文件、提取文本、生成新名称、分拣异常文件”串成一次批处理。文本型 PDF 通常可以直接读取;扫描件、加密文件、损坏文件和提取结果为空的文件,则应进入异常目录,等待 OCR 或人工复核,而不是强行改名。

这类 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 目录重新处理。
编写批量处理脚本
下面的脚本会完成这些工作:
- 扫描
input目录下的 PDF 文件。 - 尝试提取前几页或全部页面的文本。
- 根据发票号、合同号和日期等规则生成名称。
- 无法提取文本时,将文件复制到
review目录。 - 对完全相同的文件内容进行标记,避免重复处理。
- 处理目标文件名冲突,并生成
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}")
规则越具体,误命名的概率越低。建议先用少量样本测试,不要一开始就对数千个文件执行覆盖式改名。

运行脚本并检查结果
在脚本所在目录执行:
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 读取的文字对象,因此脚本得到的文本可能为空。
这时可以采用以下流程:
- 先用本文脚本筛出
empty文件。 - 将这些文件交给 OCR 工具处理。
- OCR 生成可搜索的 PDF 或文本文件。
- 再次运行命名脚本。
- 对合同金额、日期、当事人和编号进行人工复核。
OCR 的识别结果可能受到字体、印章、表格、低分辨率、倾斜页面和手写内容影响。特别是金额、身份证号、银行账号、合同编号等字段,不应只因为 OCR 提取成功就认为内容准确。
常见异常和处理方式
文件能打开,但脚本提示为空
这通常表示文件是扫描件,或者文字以脚本暂时无法解析的方式嵌入。可以先复制到 review 目录,再通过 OCR 处理。
如果只有少数文件异常,也可以直接人工查看并按原文件名整理,不必为了少量文件改造整个流程。
文件被加密
脚本只尝试处理没有设置实际密码的加密文件。对于需要密码的 PDF,你需要:
- 向文件提供者索取密码;
- 在得到授权的前提下先解密并另存文件;
- 将解密后的副本放入
input目录重新处理。
不要把密码硬编码到脚本中,也不要尝试绕过不属于你的文件权限。
多个文件生成同一个名称
脚本会自动添加 _2、_3 等后缀,例如:
合同_HT-2024-001.pdf
合同_HT-2024-001_2.pdf
这能避免覆盖文件,但也说明命名字段可能不够唯一。你可以把原文件名、客户名称或日期加入规则,减少同名情况。
文件名中出现非法字符
Windows 不允许文件名包含以下字符:
/ : * ? " < > |
脚本会把这些字符替换为下划线,并限制文件名长度。若业务系统对中文或文件名长度有额外限制,可以在 clean_filename() 中进一步调整。
批处理后是否应该删除原文件
不建议脚本自动删除原文件。更安全的做法是:
- 先保留
input原件。 - 检查
output和process_log.csv。 - 随机打开若干份文件,确认内容没有被改变。
- 确认异常文件已经单独备份后,再由你手动清理输入目录。
适合采用的处理边界
这套 Python 脚本适合处理规则相对稳定、数量较多、需要减少重复命名工作的 PDF,例如:
- 从邮件或共享目录集中收到的合同;
- 需要按发票号码归档的票据;
- 文件名混乱但正文包含固定字段的资料;
- 需要先分出扫描件和加密文件的资料包。
它不适合直接承担以下任务:
- 判断合同条款是否合法;
- 自动确认发票金额是否正确;
- 在没有授权的情况下解密文件;
- 对手写内容或复杂表格做可靠识别;
- 仅凭 OCR 结果决定财务、法律或合规结论。
你可以先用 10 到 20 个样本验证提取结果和命名规则,再扩大到完整目录。把“文本提取成功”和“业务内容确认”分开处理,才能让批量改名真正节省时间,同时避免把 OCR 边界误当成自动审核能力。

评论列表 (1条):
加载更多评论 Loading...