批量重命名这件事,我忍了很久。每次从相机里导出一批照片,文件名全是IMG_0001.jpg这种流水账;从网上下载的资料包,满屏都是(1)、副本、final_v3之类的后缀;给一整个项目里的文件做规范化处理,用系统自带的重命名只能一条条点右键。那些大而全的重命名软件又总要装一堆没用的功能还要到处找注册码。所以我花了一个周末,写了这个小工具,名字就叫rea,取的是rename all前三个字母,听起来也像“嘿,重来一遍”的口令。
这篇文章不是讲某个现成框架的二次开发,而是完整记录我设计这个纯命令行批量重命名工具的过程:从命令形态的取舍,到冲突检测和预演机制的实现,再到跨平台踩过的几个编码坑。如果你也经常跟一堆乱糟糟的文件名打交道,想自己动手写一个趁手的小命令行工具,或者想看看一个“只有几百行代码”的小项目是怎么一步步变可靠的,这篇应该能给你一些参考。
1. 为什么我会为一个改名工具写几百行代码
1.1 三个让我实在受不了的场景
第一个场景是照片整理。手机、相机、无人机拍出来的文件命名规则完全不一样,有的叫IMG_20230101_123456.jpg,有的叫DSC_0001.JPG,把它们的日期信息统一到文件名前缀,按月份归档,这件事手工做会让人崩溃。
第二个场景是代码项目里的脚手架文件。比如我经常要复制一个项目模板,然后把里面所有old_project改成new_project,包括目录名、文件名、代码注释里的文字。这时候我需要的不是“重命名”,而是“批量替换文件名里的文本”和“批量修改文件内容”两件事同时做。
第三个场景是下载文件的规范化。英文 PDF、中文 PDF、带%20的 URL 转义、带一堆空格和特殊符号的文件,我要把它们统一成“小写字母 + 连字符”的风格,顺便去掉那些奇怪的字符。
现有的工具不是不能做,但要么是一次性的脚本,改完就删;要么是 GUI 软件里点来点去,完全没法自动化。我需要的是一个能记住我的规则、在命令行里几秒钟搞定一套流程的东西。
1.2 市面上现有方案的短板
我试过不少办法。Linux 自带的rename命令很强,但语法是 Perl 风格,表达式稍微复杂一点就要查文档;macOS 的 Finder 自带重命名只能做“替换文本”“加前缀后缀”,没有正则,批量改扩展名只能靠手动选“文件类型”;Windows 的资源管理器更不用说了,一次只能改一批,规则几乎为零。
我也写过一段时间的 Python 脚本:os.listdir拿到文件、os.rename改名,看起来很简单,但每次要新写一个脚本时都一样麻烦——处理扩展名要分两步、过滤隐藏文件要自己写、遇到重名就抛异常、跑错了还没有后悔药。写了几次之后发现,我其实是在重复造一个轮子,只是每次都造得不太圆。
真正让我下决心写一个固定工具的原因,是某次批量重命名因为大小写冲突直接报错,文件列表走了一半之后中断,剩下几十个文件处于“有的改了有的没改”的状态。那一刻我想,如果有一个工具替我把这些边界情况全想好,我不会省下最多时间吗?
2. rea 的命令接口:怎么设计才顺手
2.1 核心命令形态
我给自己定的目标是:一个命令,能覆盖绝大多数重命名需求,复杂到能做正则替换,简单到只加个日期前缀。没有子命令,没有配置文件,一切通过参数表达。
最终的命令形态长这样:
rea [pattern] [replacement] [options]pattern是你要匹配的文本或正则表达式,replacement是替换后的文本。如果只给一个参数,那意思就是“只按枚举方式改名”,比如给每个文件加个01、02、03……的序号。
具体例子:
# 把所有 .JPG 改成 .jpg rea '\.JPG$' '.jpg' --extension-only # 去掉文件名里的所有空格,改成下划线 rea ' ' '_' --all # 给所有文件加日期前缀,并带上递增序号 rea --prefix 2023-01-01 --counter --start 1 --pad 3 # 预览会执行的操作(不真正改名) rea '副本' '' --preview2.2 为什么参数要做到“不输入也能猜对”
设计参数时我给自己定了一个原则:默认行为一定要是最安全的行为。比如默认情况只处理pattern匹配的文件名,不会递归子目录;默认只替换文件名主体里的内容,不动扩展名;默认执行前展示预览,除非加--yes直接执行。
这套默认值加在一起意味着:一个新手拿到rea,哪怕只看rea --help也能安全操作,最坏的结果也就是文件没被改名,绝不会把事情搞乱。
所以我把复杂的、可能造成误操作的能力全部放在显式参数后面:--recursive才会递归子目录、--include-extension会连同扩展名一起改、--force会绕过冲突检查。这样每个危险动作都是一次有意识的确认。
2.3 选项的取舍:哪些功能进入核心
其实我一开始列的需求清单特别长:要支持通过正则提取捕获组反引用、要做交互式确认、要支持撤销、要支持把文件移动到新目录……后来砍掉了很多。留下来的核心功能是这些:
| 功能 | 参数 | 理由 |
|---|---|---|
| 文本/正则替换 | rea pattern replacement | 最核心的能力 |
| 预览模式 | --preview | 所有批处理工具的底线 |
| 枚举序号 | --counter --start N --pad N | 整理照片/文件必备 |
| 前缀/后缀 | --prefix,--suffix | 最常用的简单需求 |
| 大小写处理 | --lower,--upper,--title | 规范化文件名常用 |
| 递归处理 | --recursive | 需要显式开启 |
| 日志记录 | --log file.txt | 给自己留后路 |
有一件事我明确砍掉了:移动文件到别的目录。重命名和移动在系统层面都是rename系统调用,但实际行为差很多。移动文件会牵扯跨文件系统、目标目录是否存在、路径分隔符转换等问题,这些会让工具变得复杂,而我自己真正常用的还是“原地改名”。需要批量移动时我会用一行 shell 命令搞定,不必硬塞进 rea。
3. 核心代码里的几个关键决定
3.1 用 pathlib 而不是 os.path
写这个工具时我用了 Python 3 的pathlib。它的好处不只是语法好看,而是把“路径是一个对象”这个语义贯彻到底了。比如我需要拿到“文件名主体”“扩展名”“父目录”这些部分,用Path.stem、Path.suffix、Path.parent处理起来直观很多,不用自己写字符串处理逻辑。
from pathlib import Path p = Path("/home/user/photo.2023.jpg") print(p.stem) # photo.2023 print(p.suffix) # .jpg print(p.parent) # /home/user有了这套抽象,重命名的核心逻辑就变成一件相对干净的事:
def build_new_name(path: Path, args) -> Path: stem = path.stem if args.pattern: new_stem = re.sub(args.pattern, args.replacement, stem) else: new_stem = stem if args.counter: num = str(args.start + self.counter_index).zfill(args.pad) new_stem = f"{num}_{new_stem}" if args.lower: new_stem = new_stem.lower() if args.prefix: new_stem = args.prefix + new_stem if args.suffix: new_stem = new_stem + args.suffix return path.with_name(new_stem + (path.suffix if not args.strip_ext else ""))但pathlib有一个坑:Path.with_name只处理文件名,让它替换整个路径的“最后一部分”很合适,但它不允许你给新的名字里带路径分隔符,比如把a/b作为新文件名的一部分会直接报错。这个限制反而帮了忙,阻止了用户在文件名里塞斜杠导致创建出意外的目录结构。
3.2 排序与收集文件的顺序
文件改名时会遇到一个隐含问题:如果目录里既有a.txt又有b.txt,我先改谁后改谁会影响最终结果。尤其是当你用枚举序号给它们编号时,枚举的顺序必须稳定、可预期。
我在收集文件时选择使用os.scandir而不是Path.rglob,因为前者可以让我明确控制是否递归、是否过滤链接文件、是否跳过隐藏文件。拿到条目后排序,我选择了自然排序习惯(natsort的思路,但没引第三方库),简单实现一下:
def natural_key(text: str): return [int(part) if part.isdigit() else part.lower() for part in re.split(r'(\d+)', text)]这样file2.txt会排在file10.txt前面,而不是按照纯字典序排在file10.txt后面。很多人写批量重命名时忽略这个细节,最后得到的编号顺序是乱的,因为系统遍历目录的顺序和人类直觉不一样。
3.3 冲突检测:宁可什么都干不了,也不要改错东西
批量重命名最危险的场景是:你改a为b,结果目录里已经有一个b了。有的工具会直接覆盖,有的会报错中断。
我的设计是两段式:
第一段,构建目标路径集合,检查目标之间是否互相冲突。这包括了“两个不同源文件改成了同一个目标”的重名问题,也包括了“目标路径已经存在于磁盘上”的碰撞问题。
第二段,真正执行前再做一次检查,因为磁盘状态在第一段检查和实际执行之间可能发生变化(极端情况,但我是安全主义者)。
def plan_renames(paths, build_new_name): plans = [] for path in paths: new_path = build_new_name(path) if new_path != path: plans.append((path, new_path)) # 冲突检查 1:目标路径彼此之间 seen = {} for _, new in plans: if new in seen: raise RuntimeError(f"多个文件将重命名为同一个名字: {new.name}") seen[new] = True # 冲突检查 2:目标路径与磁盘现有文件 for _, new in plans: if new.exists() and (new not in [p for p, _ in plans]): raise RuntimeError(f"目标文件已存在: {new}") return plans这块代码看起来简单,但实际写的时候还要考虑大小写问题。一个名叫Photo.JPG的文件改名为photo.jpg,在 Linux 上没问题,在 Windows 和 macOS 的默认文件系统上,新名字和旧名字其实是同一个文件(因为它们不区分大小写)。
碰到这种情况我选择直接警告并跳过,而不是强行改名。原因很实际:强行改名在某些文件系统上可能出现“文件看起来消失了”的假象,用户会以为工具把文件删了。
3.4 预演、日志与后悔药
真正执行改名之前,rea会把你将要做的事完整打印出来:
$ rea ' ' '_' --preview [预览] 项目报告 final.docx -> 项目报告_final.docx [预览] 项目报告 final(1).docx -> 项目报告_final(1).docx [预览] 项目报告 final(2).docx -> 项目报告_final(2).docx用户确认后,加--yes才会真正执行。执行时我会把所有动作写入日志,记录新名、旧名和时间。没有--log参数时日志默认写在当前目录的rea-log.txt里。
这招在后来救过我一次:某次我一把改了三百多个文件,过了两天想要恢复其中一部分,翻日志就能定位到旧名字是什么。如果你想做更完善的撤销功能,理论上可以加一个--undo参数从日志反推。我还没实现,但日志格式就是为此设计的。
4. 跨平台踩坑:从编码到大小写的四个麻烦
4.1 Unicode 规范化:同一个文件名,在不同系统上是不同字节
这个坑在 macOS 上最典型。macOS 的 HFS+ / APFS 文件系统在存储文件名时使用 NFD(分解形式),也就是像é这样的字符会被存成e+ 组合重音符号两个 Unicode 码位;而 Linux 和 Windows 上最常用的是 NFC(合成形式),é是单独一个码位。
也就是说,一个叫café.txt的文件,在 macOS 创建的 U 盘里拷到 Linux 上,你在终端里输入café.txt去ls它,可能找不到,因为字节不一样。
处理方式是在工具内部统一转成 NFC 再比较:
import unicodedata def normalize_name(text: str) -> str: return unicodedata.normalize("NFC", text)这样做不能保证所有环境都完美,但至少让rea在处理和比较时有一致的判断标准。
4.2 大小写冲突在各平台上的不同表现
Windows 的 NTFS 默认不区分大小写,macOS 的 APFS 也是默认不区分大小写但可以声明区分。Linux 默认区分大小写。
这意味着同一个操作,在 Linux 上顺利执行,在 Windows 上就会碰到“文件名已存在”的错误。
比如你有一堆Report.docx、REPORT.docx,你想统一改成小写report.docx。在 Linux 上,两个文件都会改成report.docx,第二个会冲突,被 rea 拦下;在 Windows 上,系统会直接认为Report.docx和report.docx是同一个文件,你根本就没法在同一个目录里同时拥有它们。
我给 rea 加了一个--case-insensitive-check开关,在 Windows 上默认开启:
def paths_conflict(a: Path, b: Path) -> bool: if args.case_insensitive: return a.name.lower() == b.name.lower() return a.name == b.name这个设计让我在 Windows 机器上使用 rea 时,很少出现中途崩溃的情况。
4.3 跨平台路径分隔符
Windows 路径用反斜杠\,macOS 和 Linux 用正斜杠/。pathlib会帮你处理好这件事,但有一个地方需要小心:当你在命令行里传入pattern时,如果 pattern 里面包含\,在 shell 里可能会被当成转义符消费掉。
最典型的情况是想把路径分隔符替换掉:
rea '\\' '_' --include-extension在 Windows 的 cmd 里这个写法会出问题,因为 cmd 对反斜杠的处理跟 Bash 不太一样。我的建议是:在交互场景下尽量避免用 rea 去处理路径分隔符替换,更稳妥的方法是直接处理Path.name,再结合--recursive和with_name逻辑来调整目录结构,而不是在文件名里手动塞路径分隔符。这也呼应了前面说的:重命名盘符路径的一种安全边界就是“文件名里不许出现斜杠”。
4.4 文件名的特殊字符与保留字
Windows 有一批保留字符:< > : " / \ | ? *,不能出现在文件名里。macOS 和 Linux 只把/当作不能出现在文件名里的字符,但:和?在 macOS 下也会在 Finder 里显示很怪。
rea 在构建新文件名之前会做一次安全检查,把非法字符替换成下划线或删除,而不是让系统抛异常:
def safe_name(name: str, platform="auto") -> str: if platform in ("windows", "auto"): name = re.sub(r'[<>:"/\\|?*]', "_", name) # macOS 上的冒号和问号也处理一下 name = re.sub(r'[@:?]', "_", name) return name.strip(". ")这个函数看起来很小,但如果你不处理它,批量重命名中途会一直被各种异常打断。我甚至遇到过一个文件名末尾带点号的情况,Windows 会静默去掉末尾的点,Linux 不会,导致日志记录的文件名和实际文件名对不上。
5. 三个上手就会用到的实战场景
5.1 整理相机照片:按日期前缀 + 序号重排
我导出一个月的照片,文件名乱成一片,现在用 rea 一把就搞定:
# 先预览 rea --prefix "2023-01-" --counter --start 1 --pad 3 --extension-only --preview # 确认无误后执行 rea --prefix "2023-01-" --counter --start 1 --pad 3 --extension-only --yes--extension-only的意思是只改文件名主体,不动扩展名,这样我还想保留.jpg和.raw的区别。加了--counter后,文件名会变成2023-01-001_img001.jpg这种风格,如果要按拍摄日期而不是按文件顺序排,可以先用stat或 EXIF 工具把文件按日期重排,再来调 rea 加前缀。
5.2 批量把文件名中的空格替换为连字符
下载的 PDF 文件经常会带有%20或空格,传到网站 / 服务器上作为 URL 时非常麻烦。我用 rea 一条命令把所有空格替换成连字符:
rea ' ' '-' --all --lower--all是直接替换文件中的所有匹配(默认只替换第一个)。这样Design Document Final Draft.pdf就变成了design-document-final-draft.pdf。
需要注意:--lower会把扩展名一起转小写吗?目前不会,因为默认rea只处理文件主体,扩展名单独处理。但如果你确实想连扩展名统一小写,可以加--include-extension。
5.3 项目文件名的批量规范化
比如某个项目里目录结构全是FooBarBaz.ts、foo-bar-baz.module.ts这种混乱的命名,我用 rea 加上--recursive可以一遍跑完:
rea '(?<=[a-z])(?=[A-Z])' '-' --recursive --include-extension --preview这条命令是在骆驼命名法(camelCase)的字母交界处插入连字符,FooBarBaz.ts会变成Foo-Bar-Baz.ts。再配合--lower就能得到完全规范的文件名。
这种场景里,--recursive非常值得注意,因为它的执行顺序是“先深度优先排序再改”,避免子目录改名影响到父目录的路径。这个细节花了我不少时间调试:如果你先改了父目录名,子目录路径里的旧名字就失效了;反过来,先改子目录再改父目录就没有问题。rea 内部会按照目录层级排序处理,保证父目录的最后才改。
写在最后:关于命令行工具的一个小建议
我给 rea 加的最值钱的功能不是正则,不是序号,而是--preview。所有批处理工具都应该默认先给你看一遍“将要发生什么”,因为重命名这件事,眼睛看一遍比脑子想一遍可靠得多。
另外一个习惯是:给自己写工具时不要一味追求功能的堆砌。我最初列的需求里有一半后来都没做,因为真实用起来时发现根本用不到。一个只有两三百行的命令行小工具,自己用得顺手、行为可预期、关键步骤有保护,比什么都重要。
如果你也打算动手写一个类似的工具,我的建议是:先花半小时把你过去一年里做过的“重复命名操作”全部列出来,给它们排个优先级;然后只实现使用频率最高的三种场景,其他的一律不加。等真的在实战中撞到缺口,再回头补功能,那个功能会比你凭空想象的合理得多。