大话批量重命名工具:Python实现详解
各位看官,今天咱们要聊的这个项目,可不是什么高深莫测的人工智能,也不是什么复杂难懂的分布式系统。它就是一个普普通通的批量重命名工具。但别小看它,麻雀虽小,五脏俱全。咱们今天就来好好唠唠,这个工具是怎么用Python一点点搭建起来的。
第一章:需求分析——从"手动改文件名"说起
话说在很久很久以前,有一个程序员,他有一堆电子书,文件名乱七八糟的:
【精品】三体全集_精校版.epub 【转载】百年孤独_[哥伦比亚]加西亚·马尔克斯.pdf 【高清】红楼梦_曹雪芹.epub
他想把这些文件名整理得干净一点,变成:
三体全集.epub 百年孤独.pdf 红楼梦.epub
于是他开始手动一个个改,改到第三个文件的时候,他崩溃了——文件太多了!
需求就这样产生了:
批量处理文件重命名
支持移除前缀、后缀、中间任意字符串
支持替换字符串
支持修改扩展名
支持添加前缀、后缀
这就好比你有一堆乱码的书籍,你需要一个图书管理员来帮你整理。
第二章:项目架构——麻雀虽小,五脏俱全
咱们先来看看这个项目的目录结构:
ChangeFileName/ ├── src/ │ ├── __init__.py │ ├── main.py # 主程序入口,核心逻辑 │ ├── cli.py # 命令行参数解析 │ ├── gui.py # Tkinter GUI界面 │ ├── qt_gui/ # Qt5 GUI界面 │ │ ├── __init__.py │ │ └── main_window.py │ ├── file_scanner.py # 文件扫描模块 │ ├── renamer.py # 重命名核心算法 │ └── history.py # 历史记录管理 ├── tests/ # 单元测试 ├── dist/ # 打包产物 └── README.md # 使用说明
这个架构就像一个小工厂:
file_scanner.py是采购员,负责去各个目录"采购"文件renamer.py是加工车间,负责对文件名进行各种"加工"history.py是仓库,负责保存历史记录,方便"退货"main.py是厂长,负责协调各个部门gui.py和qt_gui/是销售部,负责和用户打交道
第三章:文件扫描——目录树的深度遍历
咱们先来聊聊文件扫描这个模块。这个模块的核心任务,就是遍历指定目录,找出符合条件的文件。
3.1 单线程扫描——一个人的长征
最开始,咱用的是单线程扫描:
def scan_directory(directory: str, extensions: List[str], depth: int = 1) -> List[Tuple[Path, str]]: result = [] dir_path = Path(directory) if depth == 1: # 只扫当前目录,就像在自家院子里找东西 for file_path in dir_path.iterdir(): if file_path.is_file(): # 检查扩展名是否匹配 file_ext = file_path.suffix.lower().strip('.') if file_ext in ext_set: result.append((file_path, file_path.name)) elif depth == 0: # 无限深度扫描,就像孙悟空钻进铁扇公主的肚子里 for file_path in dir_path.rglob("*"): if file_path.is_file(): file_ext = file_path.suffix.lower().strip('.') if file_ext in ext_set: result.append((file_path, file_path.name)) else: # 指定深度扫描,就像挖井,挖到指定深度就停 for file_path in dir_path.rglob("*"): if file_path.is_file(): rel_path = file_path.relative_to(dir_path) if len(rel_path.parts) <= depth: file_ext = file_path.suffix.lower().strip('.') if file_ext in ext_set: result.append((file_path, file_path.name)) return result这段代码的逻辑很简单:
depth=1:只扫描当前目录,用iterdir()depth=0:无限深度扫描,用rglob("*")depth=n:扫描指定深度,用rglob("*")然后判断相对路径的层级
3.2 多线程扫描——众人拾柴火焰高
后来发现单线程扫描太慢了,尤其是文件多的时候。于是咱引入了多线程:
def scan_directory(directory: str, extensions: List[str], depth: int = 1, threads: int = 1) -> List[Tuple[Path, str]]: result = [] dir_path = Path(directory) ext_set = {ext.lower().strip('.') for ext in extensions} if threads <= 1: # 单线程,老规矩 return _scan_single_directory(dir_path, ext_set, depth, dir_path) # 多线程扫描,就像派出多个小分队 from concurrent.futures import ThreadPoolExecutor subdirs = [p for p in dir_path.iterdir() if p.is_dir()] with ThreadPoolExecutor(max_workers=threads) as executor: futures = [] for subdir in subdirs: futures.append(executor.submit( _scan_single_directory, subdir, ext_set, depth - 1, dir_path )) for future in futures: result.extend(future.result()) # 别忘了扫当前目录 result.extend(_scan_single_directory(dir_path, ext_set, 1, dir_path)) return result多线程扫描的思路是:
把当前目录下的子目录分配给不同的线程
每个线程负责扫描自己的子目录
最后合并所有结果
这里用到了concurrent.futures.ThreadPoolExecutor,这是Python标准库中非常好用的线程池实现。
第四章:重命名算法——文件名的七十二变
重命名算法是这个工具的核心,就像孙悟空的七十二变,能把文件名变成各种样子。
4.1 核心数据结构
咱们先看看重命名过程中用到的数据结构:
class RenameResult: """重命名结果""" def __init__(self): self.status = ResultStatus.NO_MATCH # 状态 self.changes = [] # 变更列表 [(old_name, new_name), ...] self.scanned_files = [] # 扫描到的文件列表 self.skipped_files = [] # 跳过的文件(无效文件名) self.conflict_files = [] # 冲突文件(新文件名已存在) self.error_message = "" # 错误信息 self.success_count = 0 # 成功数量 self.fail_count = 0 # 失败数量
这个数据结构就像一个账本,记录了重命名过程中的所有情况。
4.2 文件名处理流程
文件名的处理流程就像一条流水线:
原文件名 → 移除前缀 → 移除后缀 → 移除任意字符串 → 替换字符串 → 修改扩展名 → 添加前缀 → 添加后缀 → 新文件名
咱们来看看具体实现:
def generate_new_filename( filename: str, prefix: Optional[str] = None, suffix: Optional[str] = None, remove: Optional[List[str]] = None, replace: Optional[List[Tuple[str, str]]] = None, replace_ext: Optional[Tuple[str, str]] = None, add_prefix: Optional[str] = None, add_suffix: Optional[str] = None, use_regex: bool = False, ignore_case: bool = False ) -> str: """生成新文件名""" name, ext = os.path.splitext(filename) # 移除前缀 if prefix: name = remove_prefix(name, prefix, use_regex, ignore_case) # 移除后缀 if suffix: name = remove_suffix(name, suffix, use_regex, ignore_case) # 移除任意字符串 if remove: for s in remove: name = remove_string(name, s, use_regex, ignore_case) # 替换字符串 if replace: for old_str, new_str in replace: name = replace_string(name, old_str, new_str, use_regex, ignore_case) # 修改扩展名 if replace_ext: old_ext, new_ext = replace_ext if old_ext is None or ext.lower() == f'.{old_ext.lower()}': ext = f'.{new_ext}' # 添加前缀 if add_prefix: name = add_prefix + name # 添加后缀 if add_suffix: name = name + add_suffix return name + ext这段代码的逻辑很清晰,就像按顺序执行一系列操作。
4.3 字符串处理函数
咱们来看看几个关键的字符串处理函数:
def remove_prefix(name: str, prefix: str, use_regex: bool = False, ignore_case: bool = False) -> str: """移除前缀""" if use_regex: flags = re.IGNORECASE if ignore_case else 0 return re.sub(r'^' + re.escape(prefix), '', name, flags=flags) else: if ignore_case: if name.lower().startswith(prefix.lower()): return name[len(prefix):] else: if name.startswith(prefix): return name[len(prefix):] return name
这个函数支持两种模式:
普通模式:直接判断是否以指定前缀开头
正则模式:使用正则表达式匹配
def replace_string(name: str, old_str: str, new_str: str, use_regex: bool = False, ignore_case: bool = False) -> str: """替换字符串""" if use_regex: flags = re.IGNORECASE if ignore_case else 0 return re.sub(old_str, new_str, name, flags=flags) else: if ignore_case: import re return re.sub(re.escape(old_str), new_str, name, flags=re.IGNORECASE) else: return name.replace(old_str, new_str)
替换函数也类似,支持普通替换和正则替换。
第五章:历史记录——时光倒流的秘密
重命名操作是不可逆的,万一改错了怎么办?别担心,咱有历史记录功能。
5.1 历史记录的数据结构
class HistoryEntry: """历史记录条目""" def __init__(self): self.timestamp = 0 # 时间戳 self.changes = [] # 变更列表 def to_dict(self): return { 'timestamp': self.timestamp, 'changes': self.changes } @classmethod def from_dict(cls, data): entry = cls() entry.timestamp = data['timestamp'] entry.changes = data['changes'] return entry历史记录就像一本日记,记录了每次重命名操作。
5.2 历史记录的存储
历史记录存储在一个JSON文件里,放在目标目录的.rename_history文件夹中:
def save_history(directory: str, changes: List[Tuple[str, str]]): """保存历史记录""" history_dir = os.path.join(directory, '.rename_history') os.makedirs(history_dir, exist_ok=True) entry = HistoryEntry() entry.timestamp = int(time.time()) entry.changes = changes history_file = os.path.join(history_dir, f'{entry.timestamp}.json') with open(history_file, 'w', encoding='utf-8') as f: json.dump(entry.to_dict(), f, ensure_ascii=False)每次执行重命名操作后,都会保存一条历史记录。
5.3 撤销操作——时光倒流
撤销操作就是读取最近的一条历史记录,然后反向执行重命名:
def undo_rename(directory: str) -> RenameResult: """撤销最近一次重命名操作""" result = RenameResult() if not has_history(directory): result.error_message = "没有可撤销的历史记录" return result entry = undo_last(directory) if entry is None: result.error_message = "没有可撤销的历史记录" return result # 反向执行重命名 for old_name, new_name in entry.changes: file_path = os.path.join(directory, old_name) if os.path.exists(file_path): try: rename_file(Path(file_path), new_name) result.success_count += 1 except Exception as e: result.fail_count += 1 else: result.fail_count += 1 return result
这里的关键是,历史记录中保存的是(原文件名, 新文件名),撤销时就把新文件名改回原文件名。
第六章:GUI界面——用户与程序的桥梁
一个好的工具,不仅要有强大的功能,还要有友好的界面。
6.1 Tkinter版本——朴实无华
Tkinter是Python自带的GUI库,就像一个朴素的农家小院,虽然不华丽,但很实用。
class RenameApp: def __init__(self, root): self.root = root self.root.title("批量重命名工具") # 创建各种控件 self.directory = tk.StringVar() self.extensions = tk.StringVar(value="epub pdf") # ... self._build_ui() def _build_ui(self): # 构建界面布局 main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) notebook = ttk.Notebook(main_frame) # 添加配置、文件列表、预览三个选项卡Tkinter的界面很简单,用Frame、Label、Entry、Button等控件就能搭建起来。
6.2 Qt5版本——华丽转身
后来咱用PyQt5重写了GUI,就像把农家小院改成了别墅:
class RenameQtApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("批量重命名工具") self.setGeometry(100, 100, 1000, 750) self.init_ui() def init_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) self.tab_widget = QTabWidget() main_layout.addWidget(self.tab_widget) self.build_config_panel() self.build_file_list_panel() self.build_preview_panel()Qt5的界面更加现代化,支持更多的样式和功能。
6.3 异步文件扫描——界面不卡顿的秘密
在GUI版本中,文件扫描是一个耗时操作,如果在主线程中执行,会导致界面卡顿。所以咱用了异步处理:
Tkinter版本:
def _refresh_file_list(self): # 在后台线程中执行扫描 def scan_worker(): files = scan_directory(directory, self._get_extensions(), self._get_depth(), self._get_threads()) self.root.after(0, self._update_file_list, files) thread = threading.Thread(target=scan_worker) thread.daemon = True thread.start()
Qt5版本:
class ScanWorker(QThread): finished = pyqtSignal(list) def run(self): files = scan_directory(self.directory, self.extensions, self.depth, self.threads) self.finished.emit(files) def refresh_file_list(self): self.scan_worker = ScanWorker(directory, extensions, depth, threads) self.scan_worker.finished.connect(self.update_file_list) self.scan_worker.start()
两种版本都用了后台线程来执行扫描操作,避免阻塞主线程。
第七章:命令行接口——程序员的最爱
除了GUI,咱还提供了命令行接口,方便程序员使用:
def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog='batch-rename', description='批量重命名工具' ) parser.add_argument('directory', help='目标目录路径') parser.add_argument('-e', '--extensions', nargs='+', default=['epub', 'pdf']) parser.add_argument('-p', '--prefix', help='要移除的前缀') parser.add_argument('-s', '--suffix', help='要移除的后缀') parser.add_argument('-r', '--remove', action='append', help='要移除的字符串') parser.add_argument('--replace', action='append', help='替换字符串,格式 OLD:NEW') parser.add_argument('--replace-ext', metavar='OLD:NEW', help='替换扩展名') parser.add_argument('--add-prefix', help='添加前缀') parser.add_argument('--add-suffix', help='添加后缀') parser.add_argument('--regex', action='store_true', help='使用正则表达式') parser.add_argument('--ignore-case', action='store_true', help='忽略大小写') parser.add_argument('--depth', type=int, default=1, help='遍历深度') parser.add_argument('--threads', type=int, default=1, help='线程数') parser.add_argument('-d', '--dry-run', action='store_true', help='预览模式') parser.add_argument('-f', '--force', action='store_true', help='强制执行') parser.add_argument('-u', '--undo', action='store_true', help='撤销') parser.add_argument('--gui', choices=['tk', 'qt'], help='启动GUI') return parser.parse_args(args)使用示例:
# 移除前缀【精品】 python -m src.main /path/to/dir -p "【精品】" # 替换字符串 python -m src.main /path/to/dir --replace "【转载】:" # 修改扩展名 python -m src.main /path/to/dir --replace-ext "epub:mobi" # 预览模式 python -m src.main /path/to/dir -p "【精品】" -d # 无限深度扫描 python -m src.main /path/to/dir --depth 0 --threads 4
第八章:打包发布——从代码到可执行文件
最后,咱用PyInstaller把项目打包成了exe文件,方便用户使用:
# 打包GUI版本 pyinstaller --onefile --windowed --name "BatchRenameTool" src/main.py # 打包CLI版本 pyinstaller --onefile --name "BatchRenameTool_CLI" src/main.py
打包过程中遇到了一些坑,比如缺少platformdirs依赖、Tcl/Tk DLL缺失等,但都一一解决了。
第九章:总结——一个工具的诞生
回顾这个项目的开发过程,咱用到了:
| 技术点 | 实现方式 |
|---|---|
| 文件扫描 | Pathlib + 递归遍历 + 多线程 |
| 字符串处理 | 正则表达式 + 字符串方法 |
| 历史记录 | JSON文件存储 |
| GUI界面 | Tkinter + PyQt5 |
| 命令行接口 | argparse |
| 打包发布 | PyInstaller |
这个工具虽然简单,但它体现了软件开发的基本流程:
需求分析:明确用户需求
架构设计:设计合理的模块划分
核心实现:实现核心功能
界面开发:提供友好的用户界面
测试验证:确保功能正确
打包发布:方便用户使用
就像《大话数据结构》里说的,数据结构和算法是编程的基本功。同样,良好的代码结构和清晰的逻辑也是开发一个好工具的关键。
希望这篇文章能让你对这个项目有更深入的了解,也希望能给你带来一些启发。如果你有什么想法或建议,欢迎留言讨论!
后记:这个项目还在不断进化中,说不定哪天就会加入更多神奇的功能。如果你想了解最新动态,欢迎关注项目的GitHub仓库。