删文件的脚本,先得学会别乱删
我收图包的习惯很差。看到喜欢的画师,整本下载,解压,丢进 acg 文件夹,然后就不管了。几个月后打开一看:一层套一层的子文件夹,GIF 混在 WebP 里,JPG 混在 PNG 里,还有一堆解压完只剩空壳的目录。
手动整理是不可能的。几千个文件,靠眼睛看、靠鼠标点,点着点着手指就比脑子快了,早晚删错。
后来写了个带菜单的脚本,跑起来长这样:
========== ACG 清理工具 ==========
目标文件夹:/mnt/c/Users/Forty/acg/1
1. 删除所有 GIF 文件(含子文件夹)
2. 把所有文件重命名为 0000001 递增序号(保留扩展名)
3. 删除所有非 WebP 文件(需三次确认)
4. 删除所有空文件夹
0. 退出
==================================功能不复杂,但这个脚本里有几处我自己挺满意。删文件的工具,花哨没用,别删错就行。
完整代码
代码一共两百来行,先放全,再挑几处说。
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""批量清理目标文件夹:删 GIF、批量重命名、删非 WebP、删空文件夹。
运行:uv run acg_cleaner.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
TARGET = Path("/mnt/c/Users/Forty/acg/1")
GIF_SUFFIX = ".gif"
WEBP_SUFFIX = ".webp"
NUMBER_WIDTH = 7
MENU = """
========== ACG 清理工具 ==========
目标文件夹:{target}
1. 删除所有 GIF 文件(含子文件夹)
2. 把所有文件重命名为 0000001 递增序号(保留扩展名)
3. 删除所有非 WebP 文件(需三次确认)
4. 删除所有空文件夹
0. 退出
=================================="""
def rel(path: Path) -> str:
try:
return str(path.relative_to(TARGET))
except ValueError:
return str(path)
def collect_files(root: Path) -> list[Path]:
"""返回 root 下所有文件的路径(含隐藏文件和系统文件),按相对路径排序。"""
files: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
files.extend(Path(dirpath) / name for name in filenames)
files.sort(key=lambda p: (rel(p).lower(), rel(p)))
return files
def print_list(header: str, items: list[Path]) -> None:
print(f"\n{header}(共 {len(items)} 个):")
for item in items:
print(f" {rel(item)}")
def report_errors(errors: list[tuple[Path, str]]) -> None:
if not errors:
return
print(f"\n有 {len(errors)} 项失败:")
for path, message in errors:
print(f" {rel(path)}:{message}")
def ask(expected: str, prompt: str) -> bool:
"""回答必须与 expected 完全一致(区分大小写)才算确认。"""
try:
answer = input(prompt).strip()
except (EOFError, KeyboardInterrupt):
print()
return False
return answer == expected
def delete_files(files: list[Path]) -> None:
deleted = 0
errors: list[tuple[Path, str]] = []
for path in files:
try:
os.unlink(path)
deleted += 1
except OSError as exc:
errors.append((path, str(exc)))
print(f"\n已删除 {deleted} 个文件。")
report_errors(errors)
def delete_gifs() -> None:
gifs = [p for p in collect_files(TARGET) if p.suffix.lower() == GIF_SUFFIX]
if not gifs:
print("没有 GIF 文件,无需删除。")
return
print_list("将要删除的 GIF 文件", gifs)
if not ask("Y", "\n输入 Y 确认删除(不可恢复):"):
print("已取消,什么都没删。")
return
delete_files(gifs)
def rename_files() -> None:
files = collect_files(TARGET)
if not files:
print("没有文件可重命名。")
return
plan = [
(src, src.with_name(f"{index:0{NUMBER_WIDTH}d}{src.suffix}"))
for index, src in enumerate(files, start=1)
]
print(f"\n将要重命名的文件(共 {len(plan)} 个):")
for src, dst in plan:
print(f" {rel(src)} -> {dst.name}")
if not ask("Y", "\n输入 Y 确认重命名(不可恢复):"):
print("已取消,什么都没改。")
return
# 两阶段改名,避免目标名与树中现有文件撞名
staged: list[tuple[Path, Path, Path]] = []
errors: list[tuple[Path, str]] = []
for index, (src, dst) in enumerate(plan):
tmp = src.with_name(f".__renaming_{os.getpid()}_{index}")
try:
os.rename(src, tmp)
staged.append((tmp, dst, src))
except OSError as exc:
errors.append((src, f"暂存失败:{exc}"))
renamed = 0
for tmp, dst, src in staged:
try:
os.rename(tmp, dst)
renamed += 1
except OSError as exc:
errors.append((src, f"改名失败:{exc}"))
try:
os.rename(tmp, src)
except OSError as rollback_exc:
errors.append((tmp, f"回滚失败,文件停在临时名:{rollback_exc}"))
print(f"\n已重命名 {renamed} 个文件。")
report_errors(errors)
def delete_non_webp() -> None:
files = collect_files(TARGET)
webp = [p for p in files if p.suffix.lower() == WEBP_SUFFIX]
if not webp:
print("\n【拒绝执行】目标文件夹及其子文件夹里没有任何 WebP 文件。")
print("执行后会一个文件都不剩,判定为误操作,已中止。")
return
targets = [p for p in files if p.suffix.lower() != WEBP_SUFFIX]
if not targets:
print("除 WebP 外没有其他文件,无需删除。")
return
print_list("将要删除的非 WebP 文件", targets)
print(f"\n删除后保留 {len(webp)} 个 WebP 文件。")
for round_no, expected in enumerate(("Y", "yes", "YES"), start=1):
if not ask(expected, f"第 {round_no}/3 次确认,输入 {expected}(不可恢复):"):
print("已取消,什么都没删。")
return
delete_files(targets)
def delete_empty_dirs() -> None:
removed: list[Path] = []
errors: list[tuple[Path, str]] = []
for dirpath, _dirnames, _filenames in os.walk(TARGET, topdown=False):
folder = Path(dirpath)
if folder == TARGET:
continue
try:
if any(folder.iterdir()):
continue
folder.rmdir()
removed.append(folder)
except OSError as exc:
errors.append((folder, str(exc)))
if not removed and not errors:
print("没有空文件夹。")
return
removed.sort(key=lambda p: (rel(p).lower(), rel(p)))
print_list("已删除的空文件夹", removed)
report_errors(errors)
def main() -> int:
if not TARGET.is_dir():
print(f"目标文件夹不存在:{TARGET}")
return 1
actions = {
"1": delete_gifs,
"2": rename_files,
"3": delete_non_webp,
"4": delete_empty_dirs,
}
while True:
print(MENU.format(target=TARGET))
try:
choice = input("请选择功能编号(0 退出):").strip()
except (EOFError, KeyboardInterrupt):
print()
return 0
if choice in ("0", "q", "Q"):
print("已退出。")
return 0
action = actions.get(choice)
if action is None:
print("无效的编号,请输入 0-4。")
continue
print()
action()
print("\n—— 操作结束,返回菜单 ——")
if __name__ == "__main__":
sys.exit(main())先看一眼它要删什么
所有删除操作都先列清单,再问 Y。
def delete_gifs() -> None:
gifs = [p for p in collect_files(TARGET) if p.suffix.lower() == GIF_SUFFIX]
if not gifs:
print("没有 GIF 文件,无需删除。")
return
print_list("将要删除的 GIF 文件", gifs)
if not ask("Y", "\n输入 Y 确认删除(不可恢复):"):
print("已取消,什么都没删。")
return
delete_files(gifs)清单里的路径是相对 TARGET 的,看着短。几万个文件全打绝对路径,终端能刷上半天,还容易看漏。
collect_files 里排了两次序:先按小写路径,再按原路径。这样同名不同大小写的文件顺序稳定,每次跑出来的清单都一模一样,不会这次删 A 下次删 B。
最狠的一条:没有 WebP 就拒绝执行
功能 3 是「只留 WebP,其余全删」。这是整个脚本最危险的操作,所以它有三重确认:Y、yes、YES,三次都得输对。
但确认再多,也挡不住一种情况——你选错了文件夹。目标目录里根本没有 WebP,确认三次之后,一个文件都不剩。
所以我加了这道闸:
def delete_non_webp() -> None:
files = collect_files(TARGET)
webp = [p for p in files if p.suffix.lower() == WEBP_SUFFIX]
if not webp:
print("\n【拒绝执行】目标文件夹及其子文件夹里没有任何 WebP 文件。")
print("执行后会一个文件都不剩,判定为误操作,已中止。")
return
...它没问「你确定吗」。它直接认定这是误操作。点错目录又连按三次确认,是有可能的;但一个连 WebP 都没有的目录,执行「只留 WebP」,怎么解释都说不通。
多这四行,挡的是把整个目录删空。
重命名要绕开自己的坑
功能 2 把所有文件改成 0000001.webp、0000002.jpg 这样的递增序号。
听起来简单,但有个经典坑:如果你直接按顺序改名,a.webp 改成 0000001.webp 的时候,原来的 0000001.webp 可能还没改,直接被覆盖。
我的做法是两阶段:
# 两阶段改名,避免目标名与树中现有文件撞名
for index, (src, dst) in enumerate(plan):
tmp = src.with_name(f".__renaming_{os.getpid()}_{index}")
try:
os.rename(src, tmp)
staged.append((tmp, dst, src))
except OSError as exc:
errors.append((src, f"暂存失败:{exc}"))先把所有文件改成带进程号的临时名,再统一改成目标名。临时名里有 os.getpid(),就算同一秒有两个脚本在跑也不会撞。
第二阶段失败还会回滚到原文件名:
except OSError as exc:
errors.append((src, f"改名失败:{exc}"))
try:
os.rename(tmp, src)
except OSError as rollback_exc:
errors.append((tmp, f"回滚失败,文件停在临时名:{rollback_exc}"))回滚也失败时,它会把临时名报出来,而不是假装成功。文件至少还在磁盘上,知道名字就能找回来。
错误不吞,退出不乱
整个脚本有一个统一的错误出口:
def report_errors(errors: list[tuple[Path, str]]) -> None:
if not errors:
return
print(f"\n有 {len(errors)} 项失败:")
for path, message in errors:
print(f" {rel(path)}:{message}")删了 1900 个成功、2 个因为权限失败,它会老老实实告诉你「已删除 1900 个」再加两行失败原因。不会因为大部分成功就吞掉那两条。
ask() 也处理了 EOFError 和 KeyboardInterrupt。菜单里按 Ctrl-C 不会甩一屏 traceback,只会当成「没确认」,什么都不做。
每周怎么用
每周跑一次,选 1 清 GIF,选 2 编号,选 4 删空目录。偶尔收的图包是混合格式,才用功能 3,那三次确认输完,手是抖的,但目录确实干净了。
一个只有两百行的脚本,没有配置文件,没有参数解析,目标路径就写死在文件顶部。改文件夹就改一行 TARGET。菜单每次占小半屏,我也没想着收起来,反正一周才看一次。
通用工具我用不上。我只要它在我按下 3 的时候,先确认这个目录值得删。