2.1 文件批处理:rename 与格式转换

场景:一个目录里五百张照片

上周市场部丢给我一个素材包,五百多张活动照片,文件名什么样都有:IMG_2034.jpg、3E5A8892.JPG、微信图片_20231017_152301.jpg,还有 新建照片 2.png。需求很朴素:按拍摄日期重排成 2023-10-17_001.jpg 这种统一格式,顺带把 PNG 统一转成 JPG,方便后续上传到云端素材库(我们团队用的腾讯云 AI 办公助手活动页附带了素材管理位,正好一并归档)。

这种活手动改一天也能改完,但下次活动还会来。我选择的不是写一次性脚本,而是做一个带规则配置的小工具。后来这个工具进了我们团队内部工具箱,和全书目录里其他脚本放在一起,成了被复用最多的代码之一。

方案:先预演,再动手

批量 rename 最怕两件事:改名覆盖、删错文件。所以脚本设计的铁律是 dry-run 优先——先把改名计划打印出来,确认没问题再落盘。

核心结构三步走:扫描目录得到文件列表,按规则生成新旧名映射,校验通过后才执行重命名。扫描用 pathlib.Path.iterdir,比 os.listdir 干净;重命名计数用 itertools.count;冲突检测用集合提前发现。

from pathlib import Path
from itertools import count
from datetime import datetime
import re

def plan_rename(src_dir, pattern=r".*", dry_run=True):
    src = Path(src_dir)
    files = sorted(f for f in src.iterdir() if f.is_file())
    mapping = []       # (旧路径, 新路径)
    used = set()       # 新名占用检测
    seq = count(1)
    for f in files:
        if not re.match(pattern, f.name, re.IGNORECASE):
            continue
        mtime = datetime.fromtimestamp(f.stat().st_mtime)
        date = mtime.strftime("%Y-%m-%d")
        new = f"{date}_{next(seq):03d}.jpg"
        if new in used:
            raise ValueError(f"新名冲突: {new}")
        used.add(new)
        mapping.append((f, src / new))
    if dry_run:
        for old, new in mapping:
            print(f"{old.name}  ->  {new.name}")
    return mapping

注意 next(seq):03d 这个序号位宽,三位数够用到 999 张,超过会自动变宽但不会撞名,因为 used 集合做了兜底。排序那行也重要:sorted 保证每天跑的结果一致,否则批量脚本的输出顺序不稳定,联调时很难复现问题。

格式转换:Pillow 流水线

格式转换的坑比 rename 多。PNG 转 JPG 会丢透明通道,直接转得到一团黑底;HEIC 在 Windows 上连默认看图软件都打不开。我的做法是统一走 Pillow,转之前先转 RGB:

from PIL import Image

def to_jpg(src, dst, quality=88):
    with Image.open(src) as im:
        im = im.convert("RGB")          # 丢透明通道,避免模式报错
        im.save(dst, "JPEG", quality=quality, optimize=True)

一句 convert("RGB") 看起来多余,但少了它会直接报 cannot write mode RGBA as JPEG。这就是典型的先跑通再补坑:AI 生成的转换代码大概有一半会漏掉这一步,我在代码审查那篇里专门聊过怎么识别这类缺兜底的生成代码。

规则配置表

工具要复用,就得让规则可配置。我把常用模式做成了对照表,新人照着填就行:

场景 匹配 pattern 生成规则 注意事项
相机导出 IMG_\d+\.[jJ][pP][gG] 拍摄日期加三位序号 正则区分大小写需兼容
微信图片 微信图片_\d+_\d+\.jpg 沿用原名中的日期 名字里已含日期可直接取
截图导出 [Ss]creenshot.*\.png 截图日期加序号 PNG 建议保留不转 JPG
未知来源 .* 前缀加序号保留扩展名 兜底规则,需人工抽检

表格里最容易出事的是第一行:Windows 文件系统不区分大小写,但 Python 的正则区分,写死 .JPG 会漏掉所有小写后缀,要么用字符组兼容,要么直接加 re.IGNORECASE,我上面的代码选了后者。

落盘与回滚

改名计划确认后,真正的执行也要留后手。操作系统层面 rename 是原子操作,但脚本是批量循环,中途报错就会留下一半改完的目录。我的执行函数带事务式回滚:

def apply_rename(mapping, log="rename.log"):
    done = []
    with open(log, "w", encoding="utf-8") as lf:
        try:
            for old, new in mapping:
                old.rename(new)
                lf.write(f"OK\t{old}\t{new}\n")
                done.append((old, new))
        except Exception as e:
            lf.write(f"FAIL\t{old}\t{e}\n")
            for o, n in reversed(done):     # 回滚已执行部分
                n.rename(o)
            raise

日志文件 一行记录一条,下次出事直接拿日志做核对清单。回滚这个设计看着多余,直到有一天目录里混进来一个只读文件,跑到三百多张时报错——没有回滚的话,那三百多张的名字和原始计划对不上,等于二次灾难。

踩坑与排查

坑一:dry_run 形同虚设。 有一次市场部同事拿到脚本,直接把参数改成 dry_run=False 跑,五百张图新旧名挤在一起,最后靠回收站找回来。后来我给落盘步骤加了二次确认,并强制写日志文件记录每一步新旧名。

坑二:修改时间不是拍摄时间。 文件 mtime 是最后修改时间,从聊天软件转发的图片 mtime 其实是下载时间。真要拍摄日期得读 EXIF,Pillow 的 im._getexif() 能拿,更稳的是专门的 exifread 库。需求确认时一定问清楚:按文件名里的日期、按 EXIF、还是按 mtime,三个答案可能完全不一样。

坑三:路径里的空格和中文。 新建照片 2.png 这种名称在 Python 内部用 pathlib 没问题,一旦脚本被 subprocess 拼命令调用就会炸。我的原则:批量脚本内部一律传 Path 对象,绝不拼字符串命令。

坑四:转换后体积反而变大。 PNG 截图里有大量纯色区域,转成 JPG 有时更大,quality 调到 75 还是不划算。后来定了规矩:截图留 PNG,照片才转 JPG,转换函数加体积对比日志,变大就跳过并告警。

小结

批处理脚本看似简单,真正稳定运行靠的是处理异常路径、命名冲突和格式边缘情况。核心心得三条:dry-run 先行、配置与代码分离、每次变更留日志。这个小工具后来被我们接到了云端素材处理流水线上,用云函数触发后自动跑批,那是第三篇智能应用和部署篇的内容,感兴趣的可以先翻Serverless 部署入门做铺垫。

延伸阅读