scripts/organize.py
6.7 KB| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | organize.py — 整理文件夹内的文件 |
| 4 | |
| 5 | 默认行为(安全): |
| 6 | - 仅扫描目标文件夹顶层的“文件”,不移动子文件夹,不递归; |
| 7 | - 按扩展名归类到中文分类子文件夹(图片/文档/表格/...); |
| 8 | - 默认只预览(dry-run),必须加 --execute 才真正移动; |
| 9 | - 跳过隐藏文件(以 . 开头);目标已存在同名文件时自动加序号,不覆盖。 |
| 10 | |
| 11 | 常用用法: |
| 12 | python organize.py "D:\\下载" # 预览整理计划,不动任何文件 |
| 13 | python organize.py "D:\\下载" --execute # 确认后真正执行(移动) |
| 14 | python organize.py "D:\\下载" --by-date # 改为按修改时间 年-月 分类 |
| 15 | python organize.py "D:\\下载" --copy # 复制而非移动 |
| 16 | python organize.py "D:\\下载" --recursive # 递归处理子文件夹中的文件 |
| 17 | """ |
| 18 | |
| 19 | import argparse |
| 20 | import shutil |
| 21 | import sys |
| 22 | from datetime import datetime |
| 23 | from pathlib import Path |
| 24 | |
| 25 | # 分类文件夹名 -> 扩展名集合(全部小写) |
| 26 | CATEGORY_MAP = { |
| 27 | "图片": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", |
| 28 | ".heic", ".heif", ".tiff", ".tif", ".ico", ".raw"}, |
| 29 | "文档": {".pdf", ".doc", ".docx", ".txt", ".md", ".rtf", ".odt", |
| 30 | ".wps", ".tex", ".epub"}, |
| 31 | "表格": {".xls", ".xlsx", ".csv", ".ods", ".et"}, |
| 32 | "演示文稿": {".ppt", ".pptx", ".key", ".odp"}, |
| 33 | "音频": {".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a", ".ape"}, |
| 34 | "视频": {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", |
| 35 | ".m4v", ".mpg", ".mpeg"}, |
| 36 | "压缩包": {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz"}, |
| 37 | "程序": {".exe", ".msi", ".bat", ".cmd", ".ps1", ".sh", ".com", ".dll"}, |
| 38 | "代码": {".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".json", |
| 39 | ".xml", ".yaml", ".yml", ".java", ".c", ".cpp", ".h", ".go", |
| 40 | ".rs", ".php", ".sql", ".vue"}, |
| 41 | } |
| 42 | DEFAULT_CATEGORY = "其他" |
| 43 | CATEGORY_DIRS = set(CATEGORY_MAP.keys()) | {DEFAULT_CATEGORY} |
| 44 | |
| 45 | |
| 46 | def classify(file_path: Path) -> str: |
| 47 | """按扩展名返回分类文件夹名。""" |
| 48 | ext = file_path.suffix.lower() |
| 49 | for category, exts in CATEGORY_MAP.items(): |
| 50 | if ext in exts: |
| 51 | return category |
| 52 | return DEFAULT_CATEGORY |
| 53 | |
| 54 | |
| 55 | def target_dir_for(file_path: Path, root: Path, by_date: bool) -> Path: |
| 56 | """决定单个文件应进入的子文件夹。""" |
| 57 | if by_date: |
| 58 | # 取文件修改时间的 年-月 |
| 59 | ts = datetime.fromtimestamp(file_path.stat().st_mtime) |
| 60 | return root / ts.strftime("%Y-%m") |
| 61 | return root / classify(file_path) |
| 62 | |
| 63 | |
| 64 | def unique_destination(dest: Path) -> Path: |
| 65 | """目标已存在时,在文件名后追加 _1、_2…,避免覆盖。""" |
| 66 | if not dest.exists(): |
| 67 | return dest |
| 68 | stem, suffix, parent = dest.stem, dest.suffix, dest.parent |
| 69 | i = 1 |
| 70 | while True: |
| 71 | candidate = parent / f"{stem}_{i}{suffix}" |
| 72 | if not candidate.exists(): |
| 73 | return candidate |
| 74 | i += 1 |
| 75 | |
| 76 | |
| 77 | def is_protected(path: Path) -> bool: |
| 78 | """拦截对驱动器根、Windows 系统目录、用户主目录等危险位置的整理。""" |
| 79 | resolved = path.resolve() |
| 80 | if resolved.parent == resolved: # 驱动器根,如 C:\\ |
| 81 | return True |
| 82 | protected_names = {"windows", "system32", "program files", |
| 83 | "program files (x86)"} |
| 84 | parts = {p.lower() for p in resolved.parts} |
| 85 | if parts & protected_names: |
| 86 | return True |
| 87 | if resolved == Path.home(): |
| 88 | return True |
| 89 | return False |
| 90 | |
| 91 | |
| 92 | def collect_files(root: Path, recursive: bool) -> list[Path]: |
| 93 | """收集待整理的文件,跳过隐藏文件与已生成的分类子目录。""" |
| 94 | files = [] |
| 95 | if recursive: |
| 96 | for p in root.rglob("*"): |
| 97 | if not p.is_file(): |
| 98 | continue |
| 99 | # 路径中任一级是分类文件夹则视为已整理,跳过 |
| 100 | rel_parts = p.relative_to(root).parts |
| 101 | if any(part in CATEGORY_DIRS for part in rel_parts[:-1]): |
| 102 | continue |
| 103 | if p.name.startswith("."): |
| 104 | continue |
| 105 | files.append(p) |
| 106 | else: |
| 107 | for p in root.iterdir(): |
| 108 | if p.is_file() and not p.name.startswith("."): |
| 109 | files.append(p) |
| 110 | return sorted(files, key=lambda x: x.name.lower()) |
| 111 | |
| 112 | |
| 113 | def main() -> int: |
| 114 | parser = argparse.ArgumentParser(description="整理文件夹内的文件") |
| 115 | parser.add_argument("folder", help="要整理的文件夹路径") |
| 116 | parser.add_argument("--execute", action="store_true", |
| 117 | help="真正执行;缺省只预览(dry-run)") |
| 118 | parser.add_argument("--by-date", action="store_true", |
| 119 | help="按修改时间 年-月 分类,而非按类型") |
| 120 | parser.add_argument("--copy", action="store_true", |
| 121 | help="复制文件而非移动") |
| 122 | parser.add_argument("--recursive", action="store_true", |
| 123 | help="递归处理子文件夹中的文件") |
| 124 | args = parser.parse_args() |
| 125 | |
| 126 | root = Path(args.folder) |
| 127 | if not root.exists() or not root.is_dir(): |
| 128 | print(f"错误:文件夹不存在或不是目录:{root}") |
| 129 | return 1 |
| 130 | if is_protected(root): |
| 131 | print("错误:为避免误操作,不能整理驱动器根、系统目录或用户主目录。") |
| 132 | return 1 |
| 133 | |
| 134 | files = collect_files(root, args.recursive) |
| 135 | if not files: |
| 136 | print("没有需要整理的文件。") |
| 137 | return 0 |
| 138 | |
| 139 | action = "复制" if args.copy else "移动" |
| 140 | mode = "按修改时间(年-月)" if args.by_date else "按文件类型" |
| 141 | print(f"目标文件夹:{root.resolve()}") |
| 142 | print(f"整理方式:{mode};操作:{action};" |
| 143 | f"模式:{'执行' if args.execute else '预览(未改动任何文件)'}") |
| 144 | print("-" * 60) |
| 145 | |
| 146 | counts: dict[str, int] = {} |
| 147 | for src in files: |
| 148 | dest_dir = target_dir_for(src, root, args.by_date) |
| 149 | dest = unique_destination(dest_dir / src.name) |
| 150 | label = dest_dir.name |
| 151 | counts[label] = counts.get(label, 0) + 1 |
| 152 | rel_dest = dest.relative_to(root) |
| 153 | print(f"[{label}] {src.name} -> {rel_dest}") |
| 154 | |
| 155 | if args.execute: |
| 156 | dest_dir.mkdir(parents=True, exist_ok=True) |
| 157 | final_dest = unique_destination(dest_dir / src.name) |
| 158 | if args.copy: |
| 159 | shutil.copy2(src, final_dest) |
| 160 | else: |
| 161 | shutil.move(str(src), str(final_dest)) |
| 162 | |
| 163 | print("-" * 60) |
| 164 | summary = ",".join(f"{k} {v} 个" for k, v in sorted(counts.items())) |
| 165 | print(f"共 {len(files)} 个文件:{summary}") |
| 166 | if not args.execute: |
| 167 | print("这是预览,未改动任何文件。确认无误后加 --execute 执行。") |
| 168 | else: |
| 169 | print("整理完成。") |
| 170 | return 0 |
| 171 | |
| 172 | |
| 173 | if __name__ == "__main__": |
| 174 | sys.exit(main()) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

