首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >#WorkBuddy# 文件整理这活儿别手动干:批量重命名 / 归类 / 去重一条脚本搞定,附安全边界(完整 Python 可复制)

#WorkBuddy# 文件整理这活儿别手动干:批量重命名 / 归类 / 去重一条脚本搞定,附安全边界(完整 Python 可复制)

原创
作者头像
用户12406184
修改于 2026-10-20 14:32:07
修改于 2026-10-20 14:32:07
1571
举报

一、你会遇到的场景

下载文件夹里躺着几百个文件:报表2024.3.12.xlsx、报表2024-3-12 (1).xlsx、产品图 最终版.png、产品图 最终版 副本.png、宣传册.pdf、宣传册 副本.pdf……

手动整理有三个毛病:一是慢,二是手滑就覆盖((1) 那个到底是新版还是旧版,你根本记不住),三是最要命的——你想删重复文件,删完才发现删的是改过的那版。

让 AI 直接帮你删也不靠谱:大模型对"哪个是最新版"没有真实判断依据,它只是猜。所以这事的正确做法是让脚本做确定性的判断(哈希比对、改名规则),人只做最后一分钟的确认。

二、结论先行

结论:文件整理要拆成「机器能证明的」和「人必须拍板的」两半。

  • 机器能证明的:内容是否 100% 相同(SHA-256)、文件名是否符合规范、该归到哪个类型目录 → 交给脚本,默认只读,出报告。
  • 人必须拍板的:重复的两份留哪份、冲突的名字归谁 → 脚本一律不猜,跳过并单独列出来。

适用边界:单目录几千到几万文件的日常整理。不适合:正被程序占用的工作目录(数据库、正在运行的应用)、云同步盘正在同步中的目录、需要保留精确目录层级的项目源码。

三、完整操作步骤(macOS,Apple Silicon)

步骤 1:先建一个沙箱目录练手,别直接对个人目录动手

代码语言:bash
复制
# 造 40 个"脏"文件当靶场(文件名、内容均为虚构示例)
mkdir -p ~/file_tidy_lab && cd ~/file_tidy_lab
for i in $(seq 1 12); do
  head -c 20480 /dev/urandom > "报表2024.3.${i}.xlsx"
  head -c 20480 /dev/urandom > "产品图 最终版 ${i}.png"
done
cp 报表2024.3.1.xlsx "报表2024-3-12 (1).xlsx"   # 制造一个真重复
ls -1 | wc -l

铁律:第一次一定在沙箱里跑。脚本逻辑一旦写错(比如正则多匹配了一个字),对个人目录就是批量事故。

步骤 2:让 WorkBuddy 生成脚本(附可直接复用的提示词)

在 WorkBuddy 里直接粘贴下面这段,它会产出一份带安全约束的脚本,比你自己描述再改快得多:

代码语言:plaintext
复制
帮我写一个 macOS 用的 Python 文件整理脚本,只用标准库,要求:
1. 默认只读扫描,只打印报告;必须加 --apply 才真正动文件;
2. 做三件事:文件名规范化(去"副本/(1)"、全角转半角、日期补零、连续分隔符压缩)、
   按扩展名归类到子目录、按 SHA-256 找重复文件;
3. 绝对不能 delete,重复文件移到 _dup_quarantine 隔离目录;
4. 每次 --apply 都生成 undo.sh,能一行命令完整回滚;
5. 目标路径已存在就跳过,绝不覆盖;
6. 跳过 .git / node_modules / 隐藏文件 / 隔离目录自身。
写完附 5 条你知道的坑。

步骤 3:只读扫描,先看报告再决定

把脚本存成 file_tidy.py(完整代码见文末附录),然后:

代码语言:bash
复制
python3 file_tidy.py --root ~/file_tidy_lab

报告长这样(本机真实输出,演示数据为虚构):

代码语言:plaintext
复制
- 文件总数:**40**
- 占用空间:**6.66 MB**
- 扫描耗时:**2.2 ms**
- 建议重命名:**11** 个(另有 **11** 个因同名冲突被跳过)
- 重复文件组:**10** 组(可隔离 10 个)

注意那个"11 个因同名冲突被跳过"——这是整个脚本最重要的输出。比如 宣传册 副本.pdf 想规范成 宣传册.pdf,但 宣传册.pdf 已经存在,脚本不动它,只在报告里列出来让你自己判断。

步骤 4:确认无误才执行

代码语言:bash
复制
python3 file_tidy.py --root ~/file_tidy_lab --apply

执行结果(本机真实):

代码语言:plaintext
复制
[已执行] 重命名 11 / 归类 40 / 隔离重复 10
回滚脚本:sh /private/tmp/file_tidy_demo/_tidy_reports/undo.sh

步骤 5:不对就一行回滚

代码语言:bash
复制
sh ~/file_tidy_lab/_tidy_reports/undo.sh

步骤 6:接回 WorkBuddy,形成日常习惯

两种接法,按需选:

  • 接法 A(推荐,零成本):WorkBuddy 更新后已上线「独立文件浏览器」,本地 md / html / docx / xlsx / pdf 可直接在一个窗口里打开并让 Buddy 处理,打开和编辑本地文件不消耗积分。整理完的目录直接用它翻,比 Finder 快。
  • 接法 B(托管):把步骤 3 的只读扫描挂到 WorkBuddy 自动化任务,每周一 09:00 跑一次,只出报告不落盘,报告进你的资料库。真正执行永远留给你手动那一次。

四、实测数据

以下全部为本机(Apple Silicon Mac,Python 3.13.12)真实跑出来的数字,演示文件为虚构构造:

项目

实测值

演示集规模

40 个文件 / 6.66 MB

只读扫描耗时

2.2 ms

--apply 全链路耗时

0.046 s(user 0.03s)

实际动作

重命名 11 / 归类 40 / 隔离重复 10

回滚完整性

sh undo.sh 后 40 个文件名与原名逐一比对完全还原,缺失 0、多余 0

500 文件 / 9.8 MB 扫描

13 ms

2000 文件 / 39.1 MB 扫描

54 ms

5000 文件 / 97.7 MB 扫描

136 ms(最坏情况:这 5000 个文件大小全部相同,全部要算哈希)

未实测项

状态

WorkBuddy 生成/维护该脚本的积分消耗

【待本人实测补充】

个人真实目录(非演示集)跑一次的耗时

【待本人实测补充】

挂到 WorkBuddy 自动化任务后的稳定性

【待本人实测补充】

结论:扫描耗时几乎线性,5000 文件最坏 136 ms——真正的成本不在算力,在你确认报告的那 30 秒。所以别省"看报告"这一步。

五、踩坑与边界

这 6 条全是本次真跑踩出来的,不是理论推测:

  1. 回滚脚本必须倒序写,正序会全军覆没。 第一版我按"重命名→归类→隔离"的正序写 undo,结果回滚时 21 条 mv 全部报 No such file or directory——因为隔离那步已经把文件挪走了,重命名那步记录的旧路径早就不存在。改成 reversed(steps) 后一次通过。凡是要生成撤销脚本的,一律倒序。
  2. 保留项别按字典序选,否则"正主"会被关进隔离区。 最初我用 sorted(g)[0] 挑保留文件,结果 归档 (1).zip 因为 ( 的 ASCII 码小于 .,把 归档.zip 判成重复扔进隔离区——错的正是你要留的那个。改成"噪音词少 > 路径短 > 字典序"的三级优先级后,保留项才正确。
  3. 去重必须加"同扩展名"约束。 只按"大小相同 + 哈希相同"判断,我这次实测里就误判出 3 组:.pptx 和 .md、.jpg 和 .xlsx 这种恰好字节数相同的跨类型文件被当成重复。加上 p.suffix.lower() 一起做分桶 key 后,10 组重复里 3 组噪音被正确过滤。
  4. 同名冲突一律跳过,不要自动加后缀。 产品图 最终版.png 和 产品图 最终版 副本.png 规范化后都指向 产品图_最终版.png,谁该改名?脚本不知道,就不猜。我让它按"名字越干净越优先"排队,抢不到名字的直接跳过并单独列表。
  5. Windows 非法字符 + 全角符号必须一起处理。 只转全角不删非法字符,将来往 Windows 拷会直接失败;只删非法字符不转全角,(v2) 和 (v2) 会被当成两个文件。
  6. 边界(这些情况别用):
    • 正被程序占用的目录(数据库文件、正在运行的应用)→ 移动会失败或破坏程序;
    • 云同步盘同步中(iCloud / 网盘)→ 移动和同步打架,容易产生冲突副本;
    • 依赖精确层级的项目源码 → 归类会打散结构,务必把这类目录加进 SKIP_DIRS;
    • 个人目录(桌面 / 下载 / 文档)首次运行,必须先 --root 指向沙箱练一遍。

六、小结

文件整理自动化,技术难点从来不是"怎么改",而是"改错了怎么回来"。默认只读 + 冲突跳过 + 隔离而非删除 + 倒序回滚,这四道闸才是敢把它挂上定时任务的底气。


附录:完整脚本

完整脚本 318 行(含重命名 / 归类 / 隔离 / 倒序回滚全逻辑,纯标准库零依赖),完整代码见下方,直接复制保存为 file_tidy.py 即可运行。

代码语言:python
复制
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
file_tidy.py —— 本地文件整理三件事:批量重命名 / 按类型归类 / 重复文件隔离

设计原则(安全优先):
  1. 默认只读扫描,只输出报告;必须显式 --apply 才动文件。
  2. 绝不 delete:重复文件移入 _dup_quarantine/ 隔离目录,随时可整体还原。
  3. --apply 前自动生成 undo.sh,一行命令全部回滚。
  4. 自动跳过 .git / node_modules / 隐藏目录 / 隔离目录自身,避免套娃。
  5. 目标路径已存在时自动加后缀,绝不覆盖同名文件。

运行环境:macOS(Apple Silicon)+ Python 3.13.12,本机实测通过
依赖:仅标准库,无需 pip install。

用法:
    python3 file_tidy.py --root ~/Downloads/待整理                 # 只读扫描,看报告
    python3 file_tidy.py --root ~/Downloads/待整理 --apply         # 真正执行
    sh ~/Downloads/待整理/_tidy_reports/undo.sh                    # 回滚
"""

import argparse
import hashlib
import os
import re
import shutil
import sys
import time
import unicodedata
from collections import defaultdict
from datetime import datetime
from pathlib import Path

# ---------- 配置区:按自己习惯改 ----------

# 归类规则:扩展名 -> 子目录名
CLASSIFY_RULES = {
    "图片": {".jpg", ".jpeg", ".png", ".heic", ".gif", ".webp", ".svg", ".bmp", ".tiff"},
    "文档": {".md", ".txt", ".doc", ".docx", ".pdf", ".pages", ".rtf"},
    "表格": {".xls", ".xlsx", ".csv", ".numbers", ".tsv"},
    "幻灯片": {".ppt", ".pptx", ".key"},
    "压缩包": {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"},
    "音视频": {".mp3", ".mp4", ".mov", ".wav", ".m4a", ".aac"},
    "代码": {".py", ".js", ".ts", ".sh", ".zsh", ".json", ".yaml", ".yml", ".html", ".css"},
}

# 永远跳过的目录名
SKIP_DIRS = {".git", ".svn", "node_modules", "__pycache__", ".workbuddy",
             "_dup_quarantine", "_tidy_reports", ".Trash", "venv", ".venv"}

# 文件名里要剔除的危险/噪音字符(Windows 非法字符 + 常见噪音)
ILLEGAL_CHARS = r'[\\/:*?"<>|\r\n\t]'
NOISE_WORDS = ["副本", "副本(1)", "-副本", " (1)", "(1)", "副本 ", "final", "Final", "FINAL"]

# 全角括号/符号映射(半角化)
FULLWIDTH_MAP = str.maketrans({
    "(": "(", ")": ")", "【": "[", "】": "]", "[": "[", "]": "]",
    ",": ",", " ": " ", ":": "-", "+": "+",
})

# 日期归一:2024.3.12 / 2024-3-12 / 2024_3_12 -> 2024-03-12
DATE_RE = re.compile(r"(?<!\d)(\d{4})[.\-_/年](\d{1,2})[.\-_/月](\d{1,2})[日号]?")


# ---------- 工具函数 ----------

def normalize_name(name: str) -> str:
    """文件名规范化:NFC 归一 -> 去噪音词 -> 全角转半角 -> 日期补零 -> 压缩分隔符"""
    stem = Path(name).stem
    suffix = Path(name).suffix

    # macOS HFS+/APFS 常见 NFD 存储,先归一化,否则同名文件比不出来
    stem = unicodedata.normalize("NFC", stem)

    # 去重复副本词(只在末尾或分隔符附近去掉,避免误伤正文)
    for w in NOISE_WORDS:
        stem = stem.replace(w, "")

    stem = stem.translate(FULLWIDTH_MAP)
    stem = re.sub(ILLEGAL_CHARS, "", stem)
    stem = DATE_RE.sub(lambda m: f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}", stem)

    # 连续空格/下划线/点 压成一个分隔符
    stem = re.sub(r"\s+", "_", stem.strip())
    stem = re.sub(r"[._\-]{2,}", "_", stem)
    stem = stem.strip("._- ")

    # 后缀统一小写(.HEIC -> .heic),Mac 上不区分大小写,统一了才好看
    return (stem + suffix.lower()) if stem else name


def noise_hits(name: str) -> int:
    """名字里命中了多少个"副本/final"类噪音词——越少说明这个名字越接近干净状态"""
    return sum(1 for w in NOISE_WORDS if w in name)


def sha256_of(path: Path, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            b = f.read(chunk)
            if not b:
                break
            h.update(b)
    return h.hexdigest()


def iter_files(root: Path):
    """递归遍历,跳过黑名单目录与隐藏文件"""
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for fn in filenames:
            if fn.startswith(".") or fn == ".DS_Store":
                continue
            yield Path(dirpath) / fn


def unique_target(target: Path) -> Path:
    """目标已存在则加 _1/_2 后缀,绝不覆盖"""
    if not target.exists():
        return target
    i = 1
    while True:
        cand = target.with_name(f"{target.stem}_{i}{target.suffix}")
        if not cand.exists():
            return cand
        i += 1


def classify_of(path: Path) -> str:
    ext = path.suffix.lower()
    for folder, exts in CLASSIFY_RULES.items():
        if ext in exts:
            return folder
    return "其他"


# ---------- 主流程 ----------

def scan(root: Path):
    files = list(iter_files(root))
    t0 = time.perf_counter()

    rename_plan, classify_plan, conflicts = [], [], []
    by_size = defaultdict(list)

    claimed = {}   # 目标路径 -> 已占位的文件,防止两条改名规则撞到同一个名字
    # 排队顺序 = 优先级:名字越干净(噪音词越少、越短)越先挑,避免让"副本"抢走正主的名字
    queue = sorted(files, key=lambda x: (noise_hits(x.name), len(x.name), str(x)))
    for p in queue:
        new_name = normalize_name(p.name)
        if new_name != p.name:
            dst = p.with_name(new_name)
            # 关键安全闸:规范后的名字若已被占用(磁盘上已存在,或本轮已被别的规则占用),
            # 一律跳过不改名。典型场景:宣传册 副本.pdf -> 宣传册.pdf,而宣传册.pdf 本来就存在。
            if dst in claimed:
                conflicts.append((p, dst, f"与 `{claimed[dst].name}` 抢同一个名字,后者更干净,本条跳过"))
            elif dst.exists():
                conflicts.append((p, dst, "磁盘上已有同名文件,跳过避免覆盖"))
            else:
                claimed[dst] = p
                rename_plan.append((p, dst))
        by_size[p.stat().st_size].append(p)
        classify_plan.append((p, root / classify_of(p)))

    # 只对「同扩展名 + 同大小」的文件算 hash:
    # ① 省掉大量无用 IO;② 避免把 .docx 和 .pdf 这种"恰好字节数一样"的跨类型文件判成重复
    dup_groups = []
    for size, group in by_size.items():
        if len(group) < 2 or size == 0:
            continue
        buckets = defaultdict(list)
        for p in group:
            buckets[(p.suffix.lower(), sha256_of(p))].append(p)
        for (_ext, h), g in buckets.items():
            if len(g) > 1:
                # 保留项优先级:名字越干净越优先(噪音词少 > 路径短 > 字典序),
                # 否则"归档 (1).zip"会因为字典序小而压过"归档.zip",把正主关进隔离区
                keeper = sorted(g, key=lambda x: (noise_hits(x.name), len(x.parts), str(x)))[0]
                dup_groups.append({"hash": h[:12], "size": size, "keeper": keeper,
                                   "dupes": [x for x in g if x != keeper]})

    elapsed = (time.perf_counter() - t0) * 1000
    return {"files": files, "rename_plan": rename_plan, "classify_plan": classify_plan,
            "dup_groups": dup_groups, "scan_ms": elapsed, "conflicts": conflicts,
            "bytes": sum(p.stat().st_size for p in files)}


def apply_plan(root: Path, result: dict, report_dir: Path):
    steps = []   # 每一步的反向 mv;写文件时整体倒序,否则后一步的路径已经被前一步改掉了
    done = {"rename": 0, "classify": 0, "quarantine": 0}
    errors = []

    # 1) 重命名
    for src, dst in result["rename_plan"]:
        try:
            real_dst = unique_target(dst)
            src.rename(real_dst)
            steps.append(f'mv "{real_dst}" "{src}"')
            done["rename"] += 1
        except OSError as e:
            errors.append(f"重命名失败 {src}: {e}")

    # 2) 归类(重命名后重新扫一遍,避免路径失效)
    for src, folder in scan(root)["classify_plan"]:
        try:
            folder.mkdir(exist_ok=True)
            target = unique_target(folder / src.name)
            if target.parent != src.parent:
                shutil.move(str(src), str(target))
                steps.append(f'mv "{target}" "{src}"')
                done["classify"] += 1
        except (OSError, shutil.Error) as e:
            errors.append(f"归类失败 {src}: {e}")

    # 3) 重复文件隔离(不删除)
    qdir = root / "_dup_quarantine"
    for g in scan(root)["dup_groups"]:
        for dup in g["dupes"]:
            try:
                qdir.mkdir(exist_ok=True)
                rel = dup.relative_to(root)
                target = unique_target(qdir / str(rel).replace("/", "__"))
                shutil.move(str(dup), str(target))
                steps.append(f'mv "{target}" "{dup}"')
                done["quarantine"] += 1
            except (OSError, shutil.Error, ValueError) as e:
                errors.append(f"隔离失败 {dup}: {e}")

    report_dir.mkdir(exist_ok=True)
    undo_path = report_dir / "undo.sh"
    undo_lines = ["#!/bin/sh", "# 由 file_tidy.py 自动生成:倒序回滚本次整理的全部动作",
                  f"# 生成时间:{datetime.now():%Y-%m-%d %H:%M:%S}",
                  "# 用法:sh undo.sh  (务必用倒序,正序会因路径已变更而全部失败)", ""]
    undo_lines += reversed(steps)
    undo_path.write_text("\n".join(undo_lines) + "\n", encoding="utf-8")
    return done, errors, undo_path


def render_report(root: Path, result: dict) -> str:
    files = result["files"]
    lines = [
        f"# 文件整理扫描报告",
        "",
        f"- 扫描根目录:`{root}`",
        f"- 文件总数:**{len(files)}**",
        f"- 占用空间:**{result['bytes'] / 1024 / 1024:.2f} MB**",
        f"- 扫描耗时:**{result['scan_ms']:.1f} ms**",
        f"- 建议重命名:**{len(result['rename_plan'])}** 个"
        f"(另有 **{len(result['conflicts'])}** 个因同名冲突被跳过)",
        f"- 重复文件组:**{len(result['dup_groups'])}** 组(可隔离 {sum(len(g['dupes']) for g in result['dup_groups'])} 个)",
        "",
        "## 一、重命名建议(前 10 条)",
        "",
        "| 原文件名 | 规范后 |",
        "|---|---|",
    ]
    for src, dst in result["rename_plan"][:10]:
        lines.append(f"| `{src.name}` | `{dst.name}` |")
    if not result["rename_plan"]:
        lines.append("| — | 无 |")

    if result["conflicts"]:
        lines += ["", "## 一·补、同名冲突已自动跳过(不动,需人工判断)", "",
                  "| 原文件名 | 想改成 | 说明 |", "|---|---|---|"]
        for src, dst, why in result["conflicts"]:
            lines.append(f"| `{src.name}` | `{dst.name}` | {why} |")

    lines += ["", "## 二、重复文件组", "", "| 内容指纹 | 大小 | 保留 | 可隔离 |", "|---|---|---|---|"]
    for g in result["dup_groups"]:
        lines.append(f"| `{g['hash']}` | {g['size'] / 1024:.1f} KB | "
                     f"`{g['keeper'].relative_to(root)}` | {len(g['dupes'])} 个 |")
    if not result["dup_groups"]:
        lines.append("| — | — | — | 无 |")

    counts = defaultdict(int)
    for _, folder in result["classify_plan"]:
        counts[folder.name] += 1
    lines += ["", "## 三、归类分布", "", "| 目标目录 | 文件数 |", "|---|---|"]
    for k, v in sorted(counts.items(), key=lambda x: -x[1]):
        lines.append(f"| {k} | {v} |")

    return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser(description="本地文件整理:重命名 / 归类 / 去重隔离(默认只读)")
    ap.add_argument("--root", required=True, help="要整理的目录")
    ap.add_argument("--apply", action="store_true", help="真正执行(默认只出报告)")
    ap.add_argument("--report-dir", default=None, help="报告与 undo.sh 输出目录")
    args = ap.parse_args()

    root = Path(args.root).expanduser().resolve()
    if not root.is_dir():
        sys.exit(f"目录不存在:{root}")

    report_dir = Path(args.report_dir).expanduser() if args.report_dir else root / "_tidy_reports"
    report_dir.mkdir(parents=True, exist_ok=True)

    result = scan(root)
    print(render_report(root, result))

    if not args.apply:
        print("\n[只读模式] 未改动任何文件。确认无误后加 --apply 执行。")
        return

    done, errors, undo_path = apply_plan(root, result, report_dir)
    print(f"\n[已执行] 重命名 {done['rename']} / 归类 {done['classify']} / "
          f"隔离重复 {done['quarantine']}")
    if errors:
        print("\n[错误]")
        for e in errors:
            print("  -", e)
    print(f"\n回滚脚本:sh {undo_path}")


if __name__ == "__main__":
    main()

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、你会遇到的场景
  • 二、结论先行
  • 三、完整操作步骤(macOS,Apple Silicon)
    • 步骤 1:先建一个沙箱目录练手,别直接对个人目录动手
    • 步骤 2:让 WorkBuddy 生成脚本(附可直接复用的提示词)
    • 步骤 3:只读扫描,先看报告再决定
    • 步骤 4:确认无误才执行
    • 步骤 5:不对就一行回滚
    • 步骤 6:接回 WorkBuddy,形成日常习惯
  • 四、实测数据
  • 五、踩坑与边界
  • 六、小结
  • 附录:完整脚本
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档