Skip to content

删文件的脚本,先得学会别乱删 ​

我收图包的习惯很差。看到喜欢的画师,整本下载,解压,丢进 acg 文件夹,然后就不管了。几个月后打开一看:一层套一层的子文件夹,GIF 混在 WebP 里,JPG 混在 PNG 里,还有一堆解压完只剩空壳的目录。

手动整理是不可能的。几千个文件,靠眼睛看、靠鼠标点,点着点着手指就比脑子快了,早晚删错。

后来写了个带菜单的脚本,跑起来长这样:

txt
========== ACG 清理工具 ==========
目标文件夹:/mnt/c/Users/Forty/acg/1
  1. 删除所有 GIF 文件(含子文件夹)
  2. 把所有文件重命名为 0000001 递增序号(保留扩展名)
  3. 删除所有非 WebP 文件(需三次确认)
  4. 删除所有空文件夹
  0. 退出
==================================

功能不复杂,但这个脚本里有几处我自己挺满意。删文件的工具,花哨没用,别删错就行。

完整代码 ​

代码一共两百来行,先放全,再挑几处说。

python
# /// 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。

python
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,确认三次之后,一个文件都不剩。

所以我加了这道闸:

python
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 可能还没改,直接被覆盖。

我的做法是两阶段:

python
    # 两阶段改名,避免目标名与树中现有文件撞名
    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(),就算同一秒有两个脚本在跑也不会撞。

第二阶段失败还会回滚到原文件名:

python
        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}"))

回滚也失败时,它会把临时名报出来,而不是假装成功。文件至少还在磁盘上,知道名字就能找回来。

错误不吞,退出不乱 ​

整个脚本有一个统一的错误出口:

python
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 的时候,先确认这个目录值得删。

Nine and AI — Licensed under the MIT License