前言
"文件自动分类"要解决的是一个很朴素的痛点:下载目录、桌面、微信文件夹里堆了几百个文件,想按类型(图片、文档、压缩包、音视频)自动归到不同子目录里。这件事用 Python 做只需要几十行,但能跑通和敢在真实目录上跑是两码事——前者是几行for循环,后者要考虑重名覆盖、移动失败、误伤隐藏文件这些问题。
本文先把分类逻辑拆成"扫描 → 判定 → 目标路径 → 移动"四步,再给出一份完整的、可以先空跑(dry-run)确认再真正执行的脚本。核心依赖是标准库里的pathlib和shutil,不装任何第三方包。
先明确一个边界:本文只讨论按扩展名分类。按文件内容(比如判断一个 PDF 是合同还是论文)需要读内容甚至上模型,那属于另一个话题。
一、四步拆解
第一步:扫描。用Path.iterdir()只列当前一层,不递归。注意它会连隐藏文件、目录一起列出来,需要自己筛。第二步:判定类别。取文件后缀.suffix(如.jpg),转小写后查一张映射表。第三步:算出目标路径。目标目录 = 源目录 / 类别名;若文件已在目标目录里,跳过,否则会死循环。第四步:移动。用shutil.move(src, dst),它跨磁盘分区时自动退化为"先复制再删除",比os.rename()更安全——os.rename()跨分区会直接抛OSError。
关于扩展名,有一个必须记住的细节:Path.suffix返回的是最后一个点之后的部分,archive.tar.gz的.suffix是".gz",要拿全部后缀得用.suffixes(返回列表)。分类时按最后一个后缀通常够用。
二、类别映射与判定
# 适用于 Python 3.8+
from pathlib import Path
CATEGORIES = {
"图片": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"},
"文档": {".txt", ".md", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"},
"压缩包": {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"},
"音视频": {".mp3", ".wav", ".flac", ".mp4", ".mkv", ".mov", ".avi"},
"代码": {".py", ".js", ".ts", ".html", ".css", ".json", ".yaml", ".yml"},
}
def classify(path):
"""根据后缀返回类别名,没匹配上就归到 '其他'。"""
ext = path.suffix.lower()
for name, exts in CATEGORIES.items():
if ext in exts:
return name
return "其他"用set而不是list存后缀,判定是 O(1);其实对几百个文件差别可以忽略,但写成集合语义更贴切。path.suffix.lower()必须转小写,否则.JPG和.jpg会被分到两类。
三、处理重名:不要静默覆盖
如果目标目录里已经有同名文件,shutil.move的行为取决于os.rename()的语义——官方文档的原话是"若dst已存在但不是目录,它可能被覆盖,取决于os.rename()的语义"。这意味着在 POSIX 上它默认静默覆盖,在 Windows 上则倾向于抛FileExistsError。两种结果都不是"整理文件"想要的,而且行为随平台漂移,所以必须自己加一层防冲突逻辑:检测到重名就追加_1、_2。
# 适用于 Python 3.8+
def unique_path(dst):
"""dst 已存在时,返回带序号的新路径:a.txt -> a_1.txt -> a_2.txt"""
if not dst.exists():
return dst
stem, suffix, parent = dst.stem, dst.suffix, dst.parent
i = 1
while True:
candidate = parent / f"{stem}_{i}{suffix}"
if not candidate.exists():
return candidate
i += 1四、完整脚本(含 dry-run)
把上面的碎片拼成一个可执行的脚本。它默认只打印将要做什么,加--apply才真正移动。
# 适用于 Python 3.8+
import argparse
import shutil
from pathlib import Path
CATEGORIES = {
"图片": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"},
"文档": {".txt", ".md", ".pdf", ".docx", ".xlsx"},
"压缩包": {".zip", ".rar", ".7z", ".tar", ".gz"},
}
def classify(path):
ext = path.suffix.lower()
for name, exts in CATEGORIES.items():
if ext in exts:
return name
return "其他"
def unique_path(dst):
if not dst.exists():
return dst
stem, suffix, parent = dst.stem, dst.suffix, dst.parent
i = 1
while (parent / f"{stem}_{i}{suffix}").exists():
i += 1
return parent / f"{stem}_{i}{suffix}"
def organize(root, apply=False):
root = Path(root)
moved, skipped = 0, 0
for item in root.iterdir():
if not item.is_file() or item.name.startswith("."):
continue # 跳过目录、隐藏文件(含 .DS_Store 之类)
category = classify(item)
target_dir = root / category
if item.parent == target_dir:
continue # 已经在目标目录里,避免自我移动
dst = unique_path(target_dir / item.name)
if apply:
target_dir.mkdir(exist_ok=True)
shutil.move(str(item), str(dst))
moved += 1
else:
print(f"[dry-run] {item.name} -> {category}/{dst.name}")
print(f"共处理 {moved} 个文件,其余未匹配或已就位")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="按扩展名整理目录")
ap.add_argument("root", help="要整理的目录")
ap.add_argument("--apply", action="store_true", help="真正执行移动")
args = ap.parse_args()
organize(args.root, apply=args.apply)几点值得说明。shutil.move(src, dst)从Python 3.9 起接受路径类对象(path-like object),所以在 3.9+ 上直接传Path也可以;为了兼容 3.8 及更早版本,写成str(dst)更保险。mkdir(exist_ok=True)只会创建一层,如果类别目录本身是嵌套的(比如图片/2024),要用parents=True。item.is_file()会跟随符号链接判断,如果不希望跟链接,用item.is_symlink()先排除。
| 候选函数 | 行为 | 适合度 |
|---|
os.rename() | 同分区改名,跨分区抛OSError | 只在确定同盘时用 |
shutil.move() | 跨分区自动退化为复制+删除 | 推荐 |
shutil.copy2() | 复制并保留元数据,源文件还在 | 想保留原文件时 |
Path.rename() | 底层就是os.rename(),同样有跨盘限制 | 同分区改名 |
常见坑点
- ❌ 边遍历目录边往里移动文件,导致重复处理
✅ 先list(root.iterdir())固化文件列表再操作,并跳过已经处于目标目录内的项
- ❌ 直接
shutil.move同名文件,以为行为一定一致
✅ 文档说"是否覆盖取决于os.rename()语义":POSIX 上可能静默覆盖,Windows 上可能抛FileExistsError;整理前统一做重名检测并生成_1、_2后缀,别依赖平台差异
- ❌ 用
path.suffix去解析archive.tar.gz,以为拿到.tar.gz
✅.suffix只给最后一个".gz";要全部后缀用path.suffixes
- ❌ 忘了
.lower(),.JPG和.jpg分进两类
✅ 一律先path.suffix.lower()再比对
- ❌ 把隐藏文件和系统文件(
.DS_Store、Thumbs.db)也一起归类
✅ 用name.startswith(".")跳过,必要时再维护一张黑名单
- ❌ 用
os.rename()把文件从 C 盘移到 D 盘
✅ 跨分区会抛OSError: [WinError 17],改用shutil.move()
- ❌ 脚本直接在真实目录上第一次就执行移动
✅ 先跑 dry-run 打印计划,确认无误再加--apply;重要目录先备份
- ❌ 手工拼路径字符串
root + "/" + name
✅ 用Path的/运算符拼接,它会按平台选择分隔符
总结
| 步骤 | 用什么 | 关键点 |
|---|
| 扫描 | Path.iterdir() | 只列一层,筛掉目录与隐藏文件 |
| 判定 | path.suffix.lower() | 后缀映射用集合 |
| 定目标 | 重名检测 | 避免静默覆盖丢数据 |
| 移动 | shutil.move() | 跨分区安全 |
自动分类的难点从来不在分类算法,而在"移动"这个不可逆动作上的防护。把 dry-run、重名检测、跳过隐藏文件这三件事做进去,几十行的脚本就能从"玩具"变成"敢用"。