9 点整的恐慌:200 个文件等你在 30 分钟内改完名 / The 9 AM Panic
第 1 章:9 点整的恐慌:200 个文件等你在 30 分钟内改完名 / The 9 AM Panic
周一早上 9 点 02 分,Alex 推开公司前台那扇总是卡顿的玻璃门,外套还没来得及挂上衣架,主管 Wendy 已经站在他工位旁边,手里攥着一张被咖啡渍晕开一角的便签纸。
"200 张从相机导出的照片,全部要按 `项目A_序号_日期.jpg` 这个格式改名,11 点前要交。"Wendy 说话的语气像在报天气预报,"下午的客户提案就指着这些图呢。"
Alex 接过便签扫了一眼,手心开始出汗。两小时,200 张文件,就算每个文件名只用 10 秒敲完,那也是 2000 秒——纯机械劳动都需要 33 分钟,更别提还要小心别改错、别覆盖、别漏掉哪个客户临时加进来的特殊格式。这不是一份"有点急"的任务,这是对一个内容运营人员执行力底线的公开处刑。
"我能用 Python 吗?"Alex 问。
Wendy 耸了耸肩:"我只要你 11 点前看到正确的文件名。怎么实现是你的事。"
她转身走了。Alex 看着屏幕上那个刚被自动备份软件刷出来的文件夹 `DCIM/`,里面静静地躺着 200 个 `IMG_20240315_HDR_001.jpg`、`IMG_20240315_HDR_002.jpg`……光是把它们在资源管理器里点开看一眼就让人窒息。
接下来的两个小时里,Alex 即将完成他职业生涯中最重要的认知升级之一:任何超过 5 次的重复性操作,都应该被自动化掉。而批处理文件重命名,恰好是这条自动化之路最完美的起点。
---
为什么手动操作注定失败
在动手写代码之前,Alex 先做了一件聪明事:他打开手机的秒表,试着手动重命名了 5 个文件。
结果让他沮丧:5 个文件花了 1 分 12 秒,中间还把 `IMG_20240315_HDR_007.jpg` 看成了 `IMG_20240315_HDR_070.jpg`。如果按这个速度线性外推,200 个文件需要大约 48 分钟——而这还是在完全不分心、不犯任何错的理想情况下。
但人不是机器。真实办公场景里,你会:
- **被即时通讯软件打断**:同事一句"在吗",你的命名序号就可能从 042 跳到 043,然后忘记 042。
- **陷入认知疲劳**:从第 50 个文件开始,大脑的"模式识别模块"开始偷懒,会下意识套用最近一次的操作模板,结果就是连续 30 个文件都被命名为同一个序号。
- **无法回滚**:手动重命名是不可逆操作,一旦覆盖了原始信息,再想找回就只能去翻相机存储卡或备份目录。
- **格式不一致**:手动敲击会让 `项目A` 变成 `项目a`、`项目A `(多了空格)、`项目_A`,所有微小的差异都会在下午的提案会上被客户放大成"你们连个文件命名都做不专业"。
一个朴素的数学事实是:人类不擅长精确重复。我们擅长的是一次性创造、模式识别、异常处理——而不是像机器人一样机械地执行第 200 次同样的动作。把这种工作交给 Python,本质上是在向最合适的对象分配最合适的任务。
这就是本书全部内容的一个缩影:当一个工作本质上是机械的、可枚举的、规则的,那它就是 Python 的领地。从这一章开始,我们就把"机械重复"这四个字写进你的肌肉记忆里。
---
pathlib 与 os.path:两条不同的路径处理哲学
Alex 打开 PyCharm,新建了一个叫 `rename_photos.py` 的文件。他在搜索框里敲下"python 重命名文件",第一个搜索结果告诉他要用 `os.rename`,第二个结果告诉他用 `pathlib.Path.rename` 更好。
"这俩到底啥区别?"Alex 皱起了眉头。
这其实是每一个从 `os.path` 时代走过来、又要面对 `pathlib` 时代的 Python 开发者都会遇到的问题。我们先看一段最直观的对比:
```python import os from pathlib import Path
os.path 风格:一切都是字符串
file_path = os.path.join("photos", "subfolder", "IMG_001.jpg") parent = os.path.dirname(file_path) filename = os.path.basename(file_path) name, ext = os.path.splitext(filename) exists = os.path.exists(file_path)
pathlib 风格:一切都是对象
file_path = Path("photos") / "subfolder" / "IMG_001.jpg" parent = file_path.parent filename = file_path.name name = file_path.stem ext = file_path.suffix exists = file_path.exists() ```
从表面看,它们都能完成同样的事情。但 `pathlib` 实际上代表了 Python 标准库的一次重要范式转移:从过程式的字符串操作,转向了面向对象的路径建模。这种转移带来了三个关键收益:
第一,跨平台不再靠运气
`os.path.join` 在 Windows 上会拼出 `photos\subfolder\IMG_001.jpg`,在 Linux 上会拼出 `photos/subfolder/IMG_001.jpg`。如果你的脚本里硬编码了 `"/"`,在 Windows 上运行就会失败。`pathlib` 内部用 `PurePath` 子类处理这个问题,`PosixPath` 和 `WindowsPath` 会自动选择正确的分隔符。`/` 操作符重载后,`Path("a") / "b" / "c"` 在任何平台上都是合法的。
第二,方法链让代码更紧凑
`pathlib` 把原本散落在 `os`、`glob`、`shutil` 里的函数都聚合到了 `Path` 对象上。举个真实的例子——你想找某个目录下所有 `.jpg` 文件,把它们的后缀统一改成 `.jpeg`,并把文件名里的空格替换成下划线:
```python from pathlib import Path
for src in Path("DCIM").glob("*.jpg"): new_name = src.stem.replace(" ", "_") + ".jpeg" dst = src.with_name(new_name) src.rename(dst) ```
如果你坚持用 `os` 风格的代码,同样的逻辑你需要写 8 行以上,而且还要记得 `import os`、`import glob` 等等。
第三,可读性对团队协作至关重要
一个常见争议是:`os.path` 历史悠久,团队里老同事都用它,更"兼容"。这话有一定道理,但有个细节经常被忽略——`pathlib` 是从 Python 3.4 引入并持续增强的,到 3.6 已经稳定,3.12 的 `pathlib` API 已经成熟到几乎没有 breaking change。对于新写的代码,默认使用 `pathlib` 是更明智的选择。
一个实用的判断准则是:
- **新项目 / 新脚本 / 跨平台工具**:默认用 `pathlib`。
- **需要兼容 Python 2.7 的遗留代码**:继续用 `os.path`。
- **需要和某些第三方库深度交互**(比如一些老旧的图像处理库内部只接受字符串路径):先用 `str(path_object)` 转成字符串再传进去。
- **混合使用也是允许的**:在 `pathlib` 主导的代码里,偶尔调用 `os.path` 的某个特定函数(如 `os.path.getsize`)完全没问题,不要陷入"必须二选一"的执念。
Alex 最终选择了 `pathlib`。这个选择将贯穿本书后续所有的批处理脚本。
---
glob 模式匹配:批量文件的第一道筛子
解决了路径表示的问题,下一步就是"如何把 200 个文件找出来"。手动遍历当然可以,但 Alex 很快就发现——只要文件名遵循某种模式,就有更优雅的解决方案。这个模式匹配工具,就叫 `glob`。
`glob` 这个名字来源于 Unix 的一个早期命令 `global`,它的核心思想是:用几个通配符描述一类文件名,让操作系统帮你把符合的文件挑出来。Python 的 `pathlib` 把这个能力内化成了 `Path.glob()` 方法。
glob 的三个核心通配符
先记住这三个符号,就掌握了 80% 的 glob 用法:
| 通配符 | 含义 | 示例 | | --- | --- | --- | | `*` | 匹配任意数量字符(不含路径分隔符) | `*.jpg` 匹配所有 .jpg 文件 | | `?` | 匹配恰好一个字符 | `IMG_?.jpg` 匹配 IMG_1.jpg 但不匹配 IMG_10.jpg | | `[...]` | 匹配字符集合中的任意一个 | `IMG_[0-9].jpg` 匹配 IMG_1.jpg 到 IMG_9.jpg | | `[!...]` | 匹配不在集合中的字符 | `IMG_[!0-9].jpg` 匹配 IMG_a.jpg 但不匹配 IMG_1.jpg |
看一个完整的例子。Alex 的相机导出文件结构是这样的:
``` DCIM/ ├── IMG_20240315_HDR_001.jpg ├── IMG_20240315_HDR_002.jpg ├── IMG_20240316_HDR_001.jpg ├── thumbnail/ │ └── thumb_IMG_001.jpg └── notes.txt ```
他想找出所有顶层目录下的照片,但不要 `thumbnail/` 子目录里的缩略图,也不想选中 `notes.txt`:
```python from pathlib import Path
dcim = Path("DCIM") top_photos = sorted(dcim.glob("*.jpg"))
返回 [DCIM/IMG_20240315_HDR_001.jpg, DCIM/IMG_20240315_HDR_002.jpg, DCIM/IMG_20240316_HDR_001.jpg]
```
注意这里 `*.jpg` 不会匹配 `thumbnail/` 里的东西,因为 `*` 不跨目录。
recursive glob:用 `**` 横扫整个目录树
如果 Alex 想把包括子目录在内的所有 jpg 都找出来,需要开启递归模式:
```python all_photos = list(dcim.glob("**/*.jpg"))
现在 thumbnail/thumb_IMG_001.jpg 也会被找到
```
`Path.rglob()` 是 `glob("**/*")` 的语法糖,更简洁:
```python all_photos = list(dcim.rglob("*.jpg")) ```
但 recursive glob 有一个重要的边界条件很容易踩坑:它会顺从符号链接。如果你的目录里有指向 `/tmp` 的软链接,`rglob` 会把 `/tmp` 下所有的 jpg 都捞进来,可能导致处理时间爆炸或者权限错误。解决办法是显式传 `follow_symlinks=False`(Python 3.13 之前的写法是先检查 `pathlib` 版本,或者干脆先 `os.walk` 自己做)。
另一个边界条件是隐藏文件:在 Unix 系统上,以 `.` 开头的文件默认不被 `*` 匹配。如果你的目录里有 `.DS_Store` 或 `._IMG_001.jpg`(macOS 的资源分叉文件),`*.jpg` 是抓不到它们的,需要用 `.*.jpg` 或者写两个 pattern 分别抓。
glob 的"假阴性"陷阱
有时候你以为 glob 没找到文件,其实只是 pattern 写错了。一个快速验证的方法:
```python
列出目录下所有 .jpg 文件,但 pattern 故意写错成 .JPG(大写)
results = list(Path("DCIM").glob("*.JPG")) print(len(results)) # 可能是 0,但 ls 显示明明有 ```
在区分大小写的文件系统(如 Linux 的 ext4)上,`*.jpg` 不会匹配 `IMG.JPG`。在 macOS 的 APFS 默认配置和 Windows 的 NTFS 上是大小写不敏感的,这种问题就不存在。写跨平台脚本时,要么 pattern 里把大小写都列上,要么把文件名预处理成统一大小写。
---
从简单到复杂:三种批处理方案的演进
工具准备好了,现在 Alex 要开始动手重命名了。他没有一上来就写最复杂的版本,而是渐进式地演化出了三种方案。这个过程本身就是软件工程里非常重要的"增量开发"思路的体现。
方案一:最简单的字符串拼接
Alex 第一个能跑通的版本是这样的:
```python from pathlib import Path
source_dir = Path("DCIM") files = sorted(source_dir.glob("*.jpg"))
for index, file_path in enumerate(files, start=1): seq = f"{index:03d}" date = "20240315" new_name = f"项目A_{seq}_{date}{file_path.suffix}" file_path.rename(file_path.with_name(new_name)) print(f"已重命名: {file_path.name} -> {new_name}") ```
这个脚本能完成基本任务:用 `enumerate` 生成三位数序号(`001`、`002`...),加上固定日期,再拼上原后缀。但 Alex 一眼就看出了三个明显的问题:
1. 所有文件都用了同一个日期 `20240315`,但实际相机导出的文件可能横跨好几天。 2. `项目A` 是硬编码的,万一 Wendy 明天又让他做"项目B"呢? 3. 没有错误处理,如果某个文件被 Excel 临时占用,`rename` 会抛 `PermissionError`,整个脚本就崩溃了,前面已经改好的文件也回不去了。
所以 Alex 觉得这个版本"能用,但太脆"。
方案二:用 pathlib 解构原文件名
第二个版本,Alex 决定从原始文件名里提取日期。他注意到相机导出的文件名遵循 `IMG_YYYYMMDD_HDR_NNN.jpg` 这样的规律——这种规律正是可以用程序解析的信号:
```python from pathlib import Path
source_dir = Path("DCIM") files = sorted(source_dir.glob("*.jpg"))
for index, file_path in enumerate(files, start=1):
stem 是去掉后缀的文件名,例如 IMG_20240315_HDR_001
parts = file_path.stem.split("_")
parts = ["IMG", "20240315", "HDR", "001"]
if len(parts) < 4 or not parts[1].isdigit(): print(f"跳过不符合格式的文件: {file_path.name}") continue
date = parts[1] # 直接从原文件名抽取日期 seq = f"{index:03d}" project = "项目A" new_name = f"{project}_{seq}_{date}{file_path.suffix}"
new_path = file_path.with_name(new_name) if new_path.exists(): print(f"目标已存在,跳过: {new_name}") continue
file_path.rename(new_path) print(f"✓ {file_path.name} -> {new_name}") ```
这个版本明显比第一个健壮。它做了白名单校验(格式不对就跳过)、冲突检测(目标已存在不覆盖)、日志输出(每一步都有反馈)。但 Alex 仍然觉得不够优雅——`split("_")` 这种字符串切片操作,本质上是"我知道这个文件名的结构"这种隐式知识在硬编码。如果文件名格式稍微变一变(比如某些相机用的是 `IMG_20240315-001.jpg`,中间是短横线不是下划线),整个解析逻辑就要重写。
于是他想到了更通用的工具:正则表达式。
方案三:正则表达式,让规则显式化
正则表达式的精髓在于"用一种声明式的方式描述字符串的形状"。Alex 把相机文件名的解析规则写成了一条正则:
```python import re from pathlib import Path
PATTERN = re.compile( r"^IMG_(?P<date>\d{8})_HDR_(?P<seq>\d+)$" ) PROJECT = "项目A" source_dir = Path("DCIM") files = sorted(source_dir.glob("*.jpg"))
for index, file_path in enumerate(files, start=1): match = PATTERN.match(file_path.stem) if not match: print(f"跳过不符合正则的文件: {file_path.name}") continue
date = match.group("date") ne