几千个文件按修改时间分层归档:年/月两层一次理清(含 4 个实跑大坑)

2026-10-06 · 喵窝资源铺

桌面、下载文件夹、微信自动接收目录——用半年就堆出上千个文件,名字还全是 IMG_2023(12).jpg、新建文档(3).docx。手动按日期分文件夹要人命,网盘的自动整理又不听你的。这篇给一份按文件修改时间归到「年/年-月/」两层目录的脚本,支持 move(搬走)和 copy(复制留原件)两种模式,代码完整实跑过,真实输出贴在下面。

它做的判断顺序

  1. 扫到 .py / .sh 工具文件直接跳过——归档脚本别把自己和同事的脚本搬走;
  2. Thumbs.db、.DS_Store、desktop.ini 系统垃圾不归档也不删,原地留着;
  3. 取 os.path.getmtime 换算成 2026/2026-09 两层目录,os.makedirs(..., exist_ok=True) 建好;
  4. 目标已有同名文件就自动追加 -1、-2 后缀,绝不覆盖(见坑 1,这个判断不是锦上添花,是保命的);
  5. 遍历时跳过自己生成的纯年份目录,二次运行零移动(幂等)。

脚本全文

import os, sys, shutil, time

SRC = sys.argv[1]           # 待归档目录
MODE = sys.argv[2] if len(sys.argv) > 2 else 'move'   # move | copy
SKIP_EXT = {'.py', '.sh'}   # 坑:归档脚本别把自己搬走
SKIP_NAME = {'Thumbs.db', '.DS_Store', 'desktop.ini'}  # 坑:系统垃圾别归档

def bucket(ts):
    t = time.localtime(ts)
    return time.strftime('%Y', t), time.strftime('%Y-%m', t)

moved, skipped = [], []
if MODE not in ('move', 'copy'):
    sys.exit('mode 只能是 move 或 copy')

for root, dirs, files in os.walk(SRC):
    # 跳过已经生成出的 年/月 目录,防止二次运行扫自己产出
    rel = os.path.relpath(root, SRC)
    if rel != '.' and (len(rel) == 4 and rel[:4].isdigit()):
        dirs[:] = []
        continue
    for fn in sorted(files):
        p = os.path.join(root, fn)
        ext = os.path.splitext(fn)[1].lower()
        if ext in SKIP_EXT or fn in SKIP_NAME:
            skipped.append((fn, '系统/工具文件不归档')); continue
        y, m = bucket(os.path.getmtime(p))
        dest_dir = os.path.join(SRC, y, m)
        os.makedirs(dest_dir, exist_ok=True)
        dest = os.path.join(dest_dir, fn)
        if os.path.exists(dest):
            base, e = os.path.splitext(fn)
            i = 1
            while os.path.exists(os.path.join(dest_dir, f'{base}-{i}{e}')):
                i += 1
            dest = os.path.join(dest_dir, f'{base}-{i}{e}')
        (shutil.move if MODE == 'move' else shutil.copy2)(p, dest)
        moved.append((os.path.relpath(p, SRC), os.path.relpath(dest, SRC)))

print(f'{MODE} {len(moved)} 个文件,跳过 {len(skipped)}')
for src, dst in moved: print(f'  {src} -> {dst}')
for fn, why in skipped: print(f'  [跳] {fn}:{why}')

用法:python3 sort_by_mtime.py 目录 move(或 copy)。拿不准就先用 copy 跑一遍——原件原地不动,月份目录里是归好档的副本,确认没丢东西再删原件。

实跑输出

造了 8 类样本:10 天前的照片、50 天前的报表、400 天前的扫描件(跨年)、顶层与子目录同名的两个 same.txt、撞名场景、一个 Thumbs.db 和一个 .py:

move 6 个文件,跳过 2
  same-copy.txt -> 2026/2026-10/same-copy.txt
  same.txt -> 2026/2026-10/same.txt
  合同扫描.pdf -> 2025/2025-08/合同扫描.pdf
  合影.jpg -> 2026/2026-09/合影.jpg
  报表.xlsx -> 2026/2026-08/报表.xlsx
  sub/same.txt -> 2026/2026-10/same-1.txt
  [跳] Thumbs.db:系统/工具文件不归档
  [跳] clean.py:系统/工具文件不归档

逐个核对内容(防「搬了但内容坏了」):

文件核对结果
2026/2026-10/same.txt内容 = 顶层原版「content of same.txt」✅
2026/2026-10/same-1.txt内容 = 子目录版「sub version」,撞名自动 -1 后缀✅
2025/2025-08/合同扫描.pdf400 天前正确跨到去年✅
Thumbs.db / clean.py原地未动✅ 跳过

连跑第二次:move 0 个文件——年月目录被显式跳过,不重扫自己产出。幂等通过。copy 模式另测 2 文件:归档后原件仍在原位,copy2 保留原 mtime。

4 个真坑(都是实跑踩出来的)

坑 1:os.walk 全扫 + 不查重 = shutil.move 静默覆盖,数据永久丢失。常见写法是 os.walk 全目录扫一遍直接 shutil.move(p, dest)。实跑复现:顶层一个 same.txt(写 TOP VERSION)、子目录一个 same.txt(写 SUB VERSION),修改时间同月——两个都会被归到同一个月份目录,shutil.move 遇到目标已存在时直接覆盖且不报任何错。跑完月份目录里只剩 SUB VERSION,TOP VERSION 无声无息消失,回收站里都没有。修复:目标存在就换名(-1、-2 往后排),这不是可选优化,是必须。
坑 2:不跳过自己产出的年目录,二次运行制造套娃副本。实跑复现:初版不剪枝,a.jpg 归到 2026/2026-09/ 后,第二次运行 os.walk 又扫进这个文件——它的 mtime 没变、目标路径计算结果相同,但「目标已存在」触发的是撞名逻辑,于是又生成 a-1.jpg。跑两遍目录里就有 a.jpg 和 a-1.jpg 两份。修复:walk 到纯 4 位数字的年份目录直接 dirs[:]=[] 剪枝。修完连跑第二次是 0 移动。
坑 3:Thumbs.db / .DS_Store 会被当成正经文件搬进月份目录。实跑确认:不排除的话,Windows 缩略图缓存和 macOS 的 Finder 元数据会被归档进去,散落在各个年月里,清理的时候反而要再翻一遍。顺带说一句别图省事直接删它们——脚本只跳过不删除,删不删留给主人决定。
坑 4:mtime 是「最后写入时间」,不是「真实日期」。实跑确认:一个文件名里明明写着 photo_2020_trip.jpg,从微信/网盘另存下来后 mtime 就是下载那一刻——被归到 2026 年而不是 2020 年。微信接收的文件、邮件附件、网盘同步下来的文件全有这个问题。mtime 归档只保证「文件最后一次变动是什么时候」,想要真实日期得读图片 EXIF(拍摄时间)或文件名里的日期串,那是另一个脚本的事,别指望 mtime 一个字段包打天下。

边界说明(如实)

不想自己折腾归档?把目录结构告诉我。几千个文件要按日期/类型/项目分目录、网盘和本地要同步归档、照片库要按拍摄时间理,我可以替你写好脚本、用你的真实文件实测后交付(¥19.9 表格/文件批量处理 / ¥29.9 脚本定制)。
→ 去下单页看看 | 虚拟物品交付后不退款;未收到或未达承诺免费补发/重做。

相关文章:全盘重复文件 MD5 检测清理 · 附件按文件名批量归档改名 · 乱码文件批量转 UTF-8 · 按扩展名分文件夹

← 更多实跑干货 | 喵窝资源铺