在日常文件管理中,1.rar.1、document.pdf.bak、report.docx.bak 这类文件名很常见。备份、下载重命名、分卷压缩都可能产生多重扩展名。Windows 默认只识别最后一个扩展名,例如 report.docx.bak 会被当成 .bak 文件,打开方式会受影响。本文用一个 Python 脚本解决冗余扩展名清理问题:识别多余扩展、保留主扩展名、删除附加扩展名,并处理重名冲突。
需求拆解:识别文件名中多余的扩展部分;保留真正标识文件类型的主扩展名;删除后续附加扩展名;处理后的文件名保持唯一,避免覆盖。
扩展名识别原理:文件扩展名通常指最后一个点之后部分,但 .tar.gz、隐藏文件 .config、无扩展名文件 Makefile/README 需要特殊处理。因此要建立白名单:合法多重扩展名组合保留,冗余扩展名清除。
核心算法流程:文件名分割、扩展名分析、规则匹配、重命名执行。伪代码:- def clean_extension(filename):
- base, ext = os.path.splitext(filename)
- while ext in REDUNDANT_EXTENSIONS:
- base, new_ext = os.path.splitext(base)
- ext = new_ext + ext
- return base + ext
复制代码
冗余扩展名集合:备份 .bak .old .tmp;版本号 .1 .2 .3 .001 .002;下载 .download .partial .crdownload;临时 .swp .swo .swn .temp。保护组合:.tar.gz .tar.bz2 .tar.xz .gz .bz2 .xz .zip。注意大小写不敏感比较。
完整实现:- import os
- import re
- from typing import List, Tuple
- REDUNDANT_EXTS = {
- '.bak', '.backup', '.old', '.tmp',
- '.1', '.2', '.3', '.001', '.002',
- '.download', '.partial', '.crdownload',
- '.swp', '.swo', '.swn', '.temp'
- }
- PROTECTED_EXT_COMBOS = {
- '.tar.gz', '.tar.bz2', '.tar.xz',
- '.gz', '.bz2', '.xz', '.zip'
- }
- def split_all_extensions(filename: str) -> Tuple[str, List[str]]:
- extensions = []
- while True:
- filename, ext = os.path.splitext(filename)
- if not ext:
- break
- extensions.insert(0, ext)
- return filename, extensions
- def should_keep_extension(ext: str, prev_exts: List[str]) -> bool:
- if ext in PROTECTED_EXT_COMBOS:
- return True
- if prev_exts and (prev_exts[-1] + ext) in PROTECTED_EXT_COMBOS:
- return True
- return ext.lower() not in REDUNDANT_EXTS
- def clean_filename_extensions(filename: str) -> str:
- dirname, basename = os.path.split(filename)
- base, exts = split_all_extensions(basename)
- keep_exts = []
- for ext in reversed(exts):
- if should_keep_extension(ext, keep_exts):
- keep_exts.append(ext)
- new_basename = base + ''.join(reversed(keep_exts))
- return os.path.join(dirname, new_basename)
复制代码
关键函数说明:split_all_extensions 递归使用 os.path.splitext 拆分所有扩展名,返回基础名和有序扩展名列表;should_keep_extension 先检查保护列表,再检查相邻组合,最后看是否在冗余列表,比较时用 lower;clean_filename_extensions 处理完整路径,保留目录结构并重建文件名。
测试用例:- import unittest
- class TestExtensionCleaner(unittest.TestCase):
- def test_basic_cases(self):
- self.assertEqual(clean_filename_extensions('file.txt.bak'), 'file.txt')
- self.assertEqual(clean_filename_extensions('image.jpg.1'), 'image.jpg')
- def test_protected_combos(self):
- self.assertEqual(clean_filename_extensions('archive.tar.gz.1'), 'archive.tar.gz')
- self.assertEqual(clean_filename_extensions('data.tar.bz2.bak'), 'data.tar.bz2')
- def test_multiple_redundant(self):
- self.assertEqual(clean_filename_extensions('doc.docx.tmp.old'), 'doc.docx')
- def test_no_extension(self):
- self.assertEqual(clean_filename_extensions('README'), 'README')
- self.assertEqual(clean_filename_extensions('.hidden'), '.hidden')
- def test_full_path(self):
- self.assertEqual(
- clean_filename_extensions('/path/to/file.log.1'),
- '/path/to/file.log'
- )
- if __name__ == '__main__':
- unittest.main()
复制代码
冲突处理:当清理后目标文件已存在,可加序号后缀、保留部分冗余或交互询问。示例:- def get_unique_filename(original_path: str) -> str:
- if not os.path.exists(original_path):
- return original_path
- base, ext = os.path.splitext(original_path)
- counter = 1
- while True:
- new_path = f'{base}({counter}){ext}'
- if not os.path.exists(new_path):
- return new_path
- counter += 1
复制代码
性能优化:批量处理用 os.scandir 代替 os.listdir,可缓存已处理模式,并用线程池。示例:- from concurrent.futures import ThreadPoolExecutor
- def batch_clean_files(directory: str):
- with ThreadPoolExecutor() as executor:
- for entry in os.scandir(directory):
- if entry.is_file():
- executor.submit(process_single_file, entry.path)
复制代码
配置支持:JSON/YAML 管理规则:- # extensions_config.yaml
- redundant_extensions:
- - .bak
- - .tmp
- - .1
- protected_combos:
- - .tar.gz
- - .tar.bz2
复制代码 加载:- import yaml
- def load_config(config_path: str) -> dict:
- with open(config_path) as f:
- return yaml.safe_load(f)
复制代码
实际应用与排查:Windows 右键菜单注册表命令并关联打包 exe;Linux 建 .desktop 加 Nautilus/Dolphin 脚本菜单;macOS Automator 设 Finder 快速操作。常见问题:处理后文件无法打开,原因是误删必要扩展名,检查白名单并添加例外;大量文件卡顿,原因是同步处理,改批量异步;网络路径失败,原因是权限或延迟,加重试和超时。
日志与撤销:- import logging
- from datetime import datetime
- def setup_logging():
- logging.basicConfig(
- filename='extension_cleaner.log',
- level=logging.INFO,
- format='%(asctime)s - %(message)s'
- )
- def log_operation(original: str, new: str):
- logging.info(f'Renamed: {original} -> {new}')
复制代码- import shutil
- class UndoManager:
- def __init__(self):
- self.backup_dir = os.path.expanduser('~/.extension_cleaner_backups')
- os.makedirs(self.backup_dir, exist_ok=True)
- def backup_file(self, filepath: str) -> str:
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
- backup_path = os.path.join(
- self.backup_dir,
- f'{os.path.basename(filepath)}.{timestamp}'
- )
- shutil.copy2(filepath, backup_path)
- return backup_path
复制代码
扩展场景:压缩工具 7-Zip 自定义命令示例:- Clean Extensions -os.path.join(script_dir, 'clean_extensions.py') '%1'
复制代码 Git pre-commit 钩子:- #!/bin/sh
- find . -name '*.*.*' -exec python3 clean_extensions.py {} \;
- git add -u
复制代码 rsync 同步前预处理:- python3 clean_extensions.py /source/path
- rsync -avz /source/path /target/path
复制代码 Outlook VBA 附件:- Sub CleanAttachmentExtensions()
- Dim attachment As Attachment
- For Each attachment In Application.ActiveInspector.CurrentItem.Attachments
- Dim cleanPath As String
- cleanPath = CleanExtension(attachment.Filename)
- If cleanPath <> attachment.Filename Then
- attachment.SaveAsFile cleanPath
- End If
- Next
- End Sub
复制代码
程序员常见需求是处理版本控制临时文件,如 Git 合并冲突产生的 .orig、IDE 自动备份。可在配置文件中添加开发扩展名规则组,按工作模式切换。 |