· 喵窝资源铺
相册导出几千张照片,想按「哪一年哪个月拍的」分文件夹,手动建目录拖文件点到眼花。这篇用 Python 读 EXIF DateTimeOriginal(按下快门那一刻的时间),把照片归档到 年/月 两层目录:无 EXIF 的自动回退文件修改时间并标注来源,损坏/锁文件/非图片逐条报告不中断,同名不覆盖,move/copy 双模式。代码实跑验证,真实输出贴在下面,坑也是实跑踩出来的。
与站内两篇的分工:按EXIF重命名照片是改文件名(文件还在原目录),本篇是分文件夹;按修改时间分层归档用的是 mtime(下载/复制时间不可信),本篇优先用 EXIF(拍摄的真实时间)。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""照片按EXIF拍摄日期归档到 年/月 两层文件夹:
EXIF优先,无EXIF回退修改时间;损坏/锁文件/非图片逐条报告;
同名文件进同一目标目录不覆盖;mode=move/copy 可选"""
import os, sys, shutil
from PIL import Image
from PIL import UnidentifiedImageError
SRC = sys.argv[1] if len(sys.argv) > 1 else "."
MODE = sys.argv[2] if len(sys.argv) > 2 else "move" # move / copy
OUT = os.path.join(SRC, "by_exif_date")
done, skipped = [], []
for f in sorted(os.listdir(SRC)):
p = os.path.join(SRC, f)
if not os.path.isfile(p):
continue # 只扫顶层,输出目录也在此被跳过
if f.startswith("~$"):
skipped.append((f, "Office/临时锁文件")); continue
if not f.lower().endswith((".jpg", ".jpeg", ".png", ".webp", ".heic")):
skipped.append((f, "非图片")); continue
try:
im = Image.open(p)
ex = im.getexif()
raw = ex.get(36867) or ex.get(306) # DateTimeOriginal / DateTime
if raw:
d = raw.split()[0].replace(":", "-") # 只把日期部分的冒号换掉
src_note = "EXIF"
else:
import datetime
ts = os.path.getmtime(p)
d = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
src_note = "无EXIF,回退修改时间"
year, month = d.split("-")[0], d.split("-")[1]
dst_dir = os.path.join(OUT, year, month)
dst = os.path.join(dst_dir, f)
if os.path.exists(dst):
skipped.append((f, "目标同名已存在,不覆盖")); continue
os.makedirs(dst_dir, exist_ok=True)
(shutil.move if MODE == "move" else shutil.copy2)(p, dst)
done.append((f, f"{year}/{month}/", src_note))
except (UnidentifiedImageError, OSError) as e:
skipped.append((f, "读取失败: " + str(e)[:50]))
print(f"共归档 {len(done)} 个,跳过 {len(skipped)} 个(模式 {MODE})")
for f, to, note in done: print(f" [移] {f} -> {to} ({note})" if MODE=="move" else f" [拷] {f} -> {to} ({note})")
for f, why in skipped: print(f" [跳] {f}: {why}")
用法:python3 exif_dir_archive.py 照片目录 move(或 copy 只拷贝不动源文件)。依赖 pip install pillow。
测试集 8 个文件:4 张带 EXIF 的图(2022/2023×2/2024 四个年月,PIL 写入 DateTimeOriginal)、1 张无 EXIF 的《截图01.png》、Office 锁文件 ~$tmp.jpg、3 字节坏的《坏图.jpg》(\xff\xd8notajpeg)、说明.txt。跑 python3 exif_dir_archive.py exif_dir_test move:
共归档 5 个,跳过 3 个(模式 move) [移] IMG_0001.jpg -> 2023/05/ (EXIF) [移] IMG_0002.jpg -> 2023/05/ (EXIF) [移] IMG_0003.jpg -> 2024/12/ (EXIF) [移] 微信图片.jpg -> 2022/01/ (EXIF) [移] 截图01.png -> 2026/10/ (无EXIF,回退修改时间) [跳] ~$tmp.jpg: Office/临时锁文件 [跳] 坏图.jpg: 读取失败: cannot identify image file 'exif_dir_test/坏图.jpg' [跳] 说明.txt: 非图片
归档结果逐张核对:每个文件都落在 EXIF 对应的年/月目录,find exif_dir_test -type f 确认源文件已移走、5 个产物各就各位。再用 PIL 反读产物 EXIF:IMG_0001.jpg → 2023:05:01 10:30:00、微信图片.jpg → 2022:01:15 08:00:00——EXIF 随文件完整保留。
第二遍再跑:共归档 0 个,跳过 3 个——只剩锁文件/坏图/txt,幂等 ✅。copy 模式在新鲜测试集上同样 5 拷 3 跳全对,源文件原地保留 ✅。
同词干大小写变体专项:目录里放 IMG_1.jpg、IMG_1.JPG、IMG_1.png(EXIF 各不相同),归档后三个文件同名共存于 2023/05/,MD5 各不相同,反读 EXIF 各归各的拍摄时间——归档按原文件名落盘,不经过词干拼接,天然没有「产物同名互相覆盖」问题(对比 PDF 选页那篇的坑 1)。
2023:05:01 10:30:00,不能全局替换冒号。EXIF 标准里日期时间的分隔符就是 :,图省事 raw.replace(":", "-") 会把时间也改写成 10-30-00——本次侥幸只取年月没出错,但只要你想顺手用整串做目录名或文件名就直接炸,而且时间部分已不可逆。修复:先 split() 取日期部分,只对日期段做 replace(":", "-")。(EXIF) / (无EXIF,回退修改时间)),让人一眼知道哪些目录的时间是推定值、需要人工复核。UnidentifiedImageError(信息是 cannot identify image file),而文件被占用/无权限抛的是普通 OSError——两类原因完全不同:前者这文件真没救,后者可能明天重跑就好了。如果图省事 except Exception 一律写「损坏」,会把权限问题也盖棺定论,用户永远不再尝试。修复:明确 catch 这两类,错误原文截断进报告。isfile 跳过,千万别换成 os.walk。本篇输出目录 by_exif_date/ 就建在源目录下,顶层 os.listdir + isfile 天然把它当目录跳过。但「改进」成 os.walk 递归扫全盘后,二次运行会把已归档的照片再归档一遍——by_exif_date/2023/05/ 里的文件被搬进 by_exif_date/2023/05/by_exif_date/... 无限套娃(和 mtime 归档那篇的坑 2 同源)。pillow-heif 插件才能打开,打不开会带原因进跳过清单,不会中断批处理。