从零写一个Python文件批量整理器:自动按类型归档桌面文件

专栏:《从零写一个小工具》 | 语言:Python | 依赖:纯标准库

一、场景介绍

公司发的报表、浏览器下的安装包、临时截图、各种格式的文档……几个月不收拾,桌面和下载文件夹就能堆成山。手动拖文件分类?太费时间,而且拖不了几次就放弃了。

这篇文章带你从零写一个小工具,把指定文件夹里的文件按类型自动归类,图片进图片夹,文档进文档夹,可执行文件单独放,整理完还给你输出一份统计报告。代码不到100行,拷走就能跑。

二、需求说明

这个小工具要解决这几个实际问题:

  1. 指定一个待整理目录(比如桌面或下载文件夹)
  2. 按文件扩展名自动分类:.jpg/.png 归图片,.docx/.pdf 归文档,.exe/.msi 归安装包,等等
  3. 如果目标分类文件夹不存在,自动创建
  4. 遇到同名文件不要覆盖,自动重命名保留
  5. 整理完打印一份统计报告:每类多少文件,总共多少

不需要装任何第三方库,只用 Python 自带的 osshutilpathlib

三、环境准备

Python 3.7+ 就行,不用装任何东西。打开终端确认一下:

python --version

四、逐段代码编写

4.1 定义文件类型映射

先把扩展名和分类文件夹对应起来。这是整个工具的核心规则,你可以按自己习惯改:

CATEGORY_MAP = {
    "图片": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"],
    "文档": [".doc", ".docx", ".pdf", ".txt", ".md", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"],
    "视频": [".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv"],
    "音频": [".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"],
    "压缩包": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"],
    "安装包": [".exe", ".msi", ".dmg", ".pkg", ".deb", ".rpm"],
    "代码": [".py", ".js", ".html", ".css", ".java", ".c", ".cpp", ".go", ".rs", ".json", ".xml", ".yaml", ".yml"],
}

这里有个设计细节:用一个扩展名到分类的反向字典,后续查找更快,不用每次遍历:

EXT_TO_CATEGORY = {}
for category, exts in CATEGORY_MAP.items():
    for ext in exts:
        EXT_TO_CATEGORY[ext.lower()] = category

为什么要转小写? Windows 上扩展名可能是 .PDF.pdf,统一转小写再匹配,避免漏网之鱼。

4.2 获取分类文件夹名称

传入一个文件路径,返回它该去哪个分类文件夹:

from pathlib import Path

def get_category(file_path: Path) -> str:
    """根据扩展名返回分类文件夹名称"""
    ext = file_path.suffix.lower()  # 获取扩展名并转小写
    return EXT_TO_CATEGORY.get(ext, "其他")  # 未知类型丢进"其他"

Path.suffixpathlib 的方法,直接拿到带点的扩展名,比如 .jpg,比用字符串分割干净多了。

4.3 处理重名文件

同一个分类里可能已经有一个 报告.pdf,再移一个 报告.pdf 过来,默认会覆盖。我们要保留两份:

def safe_target_path(target_dir: Path, original_name: str) -> Path:
    """如果目标路径已存在,自动加序号重命名"""
    target = target_dir / original_name
    if not target.exists():
        return target

    # 有重名,开始加序号
    stem = Path(original_name).stem      # 文件名(不含扩展名)
    suffix = Path(original_name).suffix  # 扩展名
    counter = 1
    while True:
        new_name = f"{stem}_{counter}{suffix}"
        candidate = target_dir / new_name
        if not candidate.exists():
            return candidate
        counter += 1

这里用 stemsuffix 拆分原文件名,序号从 1 开始递增,直到找到不冲突的名字。逻辑简单粗暴,但稳。

4.4 核心整理逻辑

遍历源目录,逐个文件判断类型、创建目标文件夹、移动文件:

def organize_folder(source_dir: str):
    """执行整理"""
    source = Path(source_dir).expanduser().resolve()  # 支持 ~/Desktop 这种写法
    if not source.exists():
        print(f"目录不存在: {source}")
        return

    stats = {}  # 统计每类文件数量

    # 只处理直接子文件,不递归进子文件夹
    for item in source.iterdir():
        if not item.is_file():
            continue  # 跳过子文件夹

        category = get_category(item)
        target_dir = source / category
        target_dir.mkdir(exist_ok=True)  # 分类文件夹不存在就创建

        target_path = safe_target_path(target_dir, item.name)

        try:
            item.rename(target_path)  # 移动文件
            stats[category] = stats.get(category, 0) + 1
            print(f"[移动] {item.name} -> {category}/")
        except Exception as e:
            print(f"[失败] {item.name}: {e}")

    # 打印统计报告
    print("\n" + "=" * 30)
    print("整理完成,统计如下:")
    total = 0
    for category, count in sorted(stats.items()):
        print(f"  {category}: {count} 个文件")
        total += count
    print(f"  总计: {total} 个文件")
    print("=" * 30)

几个关键选择:

  • Path.rename() 做移动,同一盘符下是瞬时操作,没有拷贝开销
  • expanduser()~/Desktop 这种路径能正常解析
  • resolve() 把路径转成绝对路径,避免相对路径带来的意外
  • 只处理直接子文件(iterdir()),不递归进子文件夹——这是故意的,防止把已经有层级的目录结构打烂。如果你想递归,把 is_file() 的判断逻辑改一下就行,后面拓展部分会说

4.5 入口与用法

if __name__ == "__main__":
    import sys

    # 支持命令行传路径,没传就默认整理当前目录
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    print(f"开始整理目录: {Path(target).resolve()}\n")
    organize_folder(target)

五、完整源码

把上面所有片段拼起来,就是一份可直接保存运行的完整脚本:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文件批量整理器
按扩展名自动分类归档指定目录下的文件
"""

import sys
from pathlib import Path

# ========== 配置区:按需修改 ==========
CATEGORY_MAP = {
    "图片": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"],
    "文档": [".doc", ".docx", ".pdf", ".txt", ".md", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"],
    "视频": [".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv"],
    "音频": [".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"],
    "压缩包": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"],
    "安装包": [".exe", ".msi", ".dmg", ".pkg", ".deb", ".rpm"],
    "代码": [".py", ".js", ".html", ".css", ".java", ".c", ".cpp", ".go", ".rs", ".json", ".xml", ".yaml", ".yml"],
}

# 构建扩展名 -> 分类的反向映射
EXT_TO_CATEGORY = {}
for category, exts in CATEGORY_MAP.items():
    for ext in exts:
        EXT_TO_CATEGORY[ext.lower()] = category


def get_category(file_path: Path) -> str:
    """根据扩展名返回分类文件夹名称,未知类型归到"其他""""
    ext = file_path.suffix.lower()
    return EXT_TO_CATEGORY.get(ext, "其他")


def safe_target_path(target_dir: Path, original_name: str) -> Path:
    """如果目标路径已存在,自动加序号重命名,避免覆盖"""
    target = target_dir / original_name
    if not target.exists():
        return target

    stem = Path(original_name).stem
    suffix = Path(original_name).suffix
    counter = 1
    while True:
        new_name = f"{stem}_{counter}{suffix}"
        candidate = target_dir / new_name
        if not candidate.exists():
            return candidate
        counter += 1


def organize_folder(source_dir: str):
    """整理指定目录下的文件"""
    source = Path(source_dir).expanduser().resolve()
    if not source.exists():
        print(f"目录不存在: {source}")
        return

    stats = {}

    for item in source.iterdir():
        if not item.is_file():
            continue

        category = get_category(item)
        target_dir = source / category
        target_dir.mkdir(exist_ok=True)

        target_path = safe_target_path(target_dir, item.name)

        try:
            item.rename(target_path)
            stats[category] = stats.get(category, 0) + 1
            print(f"[移动] {item.name} -> {category}/")
        except Exception as e:
            print(f"[失败] {item.name}: {e}")

    # 统计报告
    print("\n" + "=" * 30)
    print("整理完成,统计如下:")
    total = 0
    for category, count in sorted(stats.items()):
        print(f"  {category}: {count} 个文件")
        total += count
    print(f"  总计: {total} 个文件")
    print("=" * 30)


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    print(f"开始整理目录: {Path(target).resolve()}\n")
    organize_folder(target)

保存为 file_organizer.py

六、运行测试

先准备几个测试文件:

mkdir test_folder
cd test_folder
touch report.pdf photo.jpg song.mp3 script.py archive.zip unknown.dat

运行:

python file_organizer.py ./test_folder

你会看到类似输出:

开始整理目录: D:\workspace\test_folder

[移动] archive.zip -> 压缩包/
[移动] photo.jpg -> 图片/
[移动] report.pdf -> 文档/
[移动] script.py -> 代码/
[移动] song.mp3 -> 音频/
[移动] unknown.dat -> 其他/

==============================
整理完成,统计如下:
  代码: 1 个文件
  压缩包: 1 个文件
  图片: 1 个文件
  文档: 1 个文件
  音频: 1 个文件
  其他: 1 个文件
  总计: 6 个文件
==============================

目录结构变成:

test_folder/
├── 图片/
│   └── photo.jpg
├── 文档/
│   └── report.pdf
├── 音频/
│   └── song.mp3
├── 代码/
│   └── script.py
├── 压缩包/
│   └── archive.zip
└── 其他/
    └── unknown.dat

七、功能拓展

到这儿工具已经能用了,但还可以加几个实用功能:

7.1 递归整理子文件夹

if not item.is_file(): continue 改成递归逻辑,可以把嵌套在子文件夹里的文件也捞出来统一分类:

def organize_folder(source_dir: str, recursive: bool = False):
    # ...
    items = source.rglob("*") if recursive else source.iterdir()
    for item in items:
        if not item.is_file():
            continue
        # ... 后续逻辑不变

注意:递归整理会把子文件夹里的文件全抽出来,原来的子文件夹会成空壳。可以顺手清理空文件夹:

# 整理完后清理空文件夹
for subdir in source.iterdir():
    if subdir.is_dir() and subdir.name not in CATEGORY_MAP and subdir.name != "其他":
        try:
            subdir.rmdir()  # 只删空文件夹
        except OSError:
            pass  # 非空就跳过

7.2 按修改日期归档

如果你不是想按类型分,而是想按"几月份"分,把 get_category 改成读文件修改时间就行:

import time

def get_category_by_date(file_path: Path) -> str:
    mtime = file_path.stat().st_mtime
    return time.strftime("%Y-%m", time.localtime(mtime))  # 返回 "2025-05" 这种格式

7.3 加个 “只预览不执行” 的开关

整理前先看一眼它会动哪些文件,心里有个底:

def organize_folder(source_dir: str, dry_run: bool = False):
    # ...
    action = "[预览]" if dry_run else "[移动]"
    # rename 之前判断 if not dry_run:

命令行传 --dry-run 就只看不动手。

八、关键要点总结

  • 路径处理用 pathlib,比字符串拼接可靠得多,suffixstemrename() 都是原生支持的
  • 扩展名匹配转小写,Windows 上大小写不敏感,不转小写会漏掉 .PDF 这种
  • 重名处理必须做,不然批量整理时很容易误覆盖,加序号是最稳妥的方案
  • rename() 在同盘符下是移动,跨盘符才会真正拷贝+删除,整理桌面/下载文件夹一般都在 C 盘,性能没问题
  • 默认不递归是保护机制,避免把已有目录结构打散,需要递归时自己打开开关

以上就是这个小工具的完整实现。代码保存下来,设个定时任务每月跑一遍,桌面再也不会乱了。

更多推荐