目标:清理同一文件夹内“同文件名但后缀为 .jpg / .jpeg”的冗余图片。例如同目录已有 IMG_0001.CR2 时,将 IMG_0001.jpg 视为可清理副本。
功能需求
- 递归扫描指定目录。
- 只处理
.jpg/.jpeg,大小写不敏感。 - 只有同目录存在同名但不同扩展名的主文件时,才处理 JPG/JPEG。
- 支持 dry-run 预览。
- 支持移动到废纸篓,避免误删。
- 支持
--force永久删除。 - 支持日志记录。
- 支持并发处理大量文件。
- 提供 macOS Bash 版和跨平台 Python 版。
推荐方案
优先使用 Python 版:
- 跨 macOS / Windows / Linux。
- 用
send2trash进废纸篓,比rm安全。 - 参数化更清楚,适合放到 GitHub 项目里维护。
macOS Bash 版适合临时本机处理,依赖 /opt/homebrew/bin/bash、find、xargs、osascript。
Python 实现
文件名建议:clean_jpg_cleaner.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import os
from pathlib import Path
from typing import Iterable
try:
from send2trash import send2trash
except ImportError:
send2trash = None
JPG_EXTS = {".jpg", ".jpeg"}
def iter_files(root: Path) -> Iterable[Path]:
for dirpath, _, filenames in os.walk(root):
base = Path(dirpath)
for filename in filenames:
yield base / filename
def find_duplicate_jpgs(root: Path) -> list[tuple[Path, list[Path]]]:
groups: dict[tuple[Path, str], list[Path]] = {}
for path in iter_files(root):
key = (path.parent, path.stem.lower())
groups.setdefault(key, []).append(path)
result: list[tuple[Path, list[Path]]] = []
for files in groups.values():
jpgs = [p for p in files if p.suffix.lower() in JPG_EXTS]
masters = [p for p in files if p.suffix.lower() not in JPG_EXTS]
if jpgs and masters:
for jpg in jpgs:
result.append((jpg, masters))
return sorted(result, key=lambda item: str(item[0]).lower())
def trash_or_delete(path: Path, force: bool) -> tuple[Path, str]:
if force:
path.unlink()
return path, "deleted"
if send2trash is None:
raise RuntimeError("send2trash is not installed. Run: pip install send2trash")
send2trash(str(path))
return path, "trashed"
def write_log(log_file: Path, lines: list[str]) -> None:
log_file.parent.mkdir(parents=True, exist_ok=True)
with log_file.open("a", encoding="utf-8") as f:
for line in lines:
f.write(line.rstrip() + "\n")
def main() -> int:
parser = argparse.ArgumentParser(
description="Clean .jpg/.jpeg files when same-name non-JPG master files exist in the same folder."
)
parser.add_argument("target", help="Directory to scan")
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not delete or trash files")
parser.add_argument("--force", action="store_true", help="Permanently delete instead of moving to trash")
parser.add_argument("--threads", type=int, default=4, help="Parallel workers for deletion/trash")
parser.add_argument("--log", default=None, help="Log file path")
args = parser.parse_args()
root = Path(args.target).expanduser().resolve()
if not root.is_dir():
print(f"Invalid directory: {root}")
return 1
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = Path(args.log or f"clean_jpg_deleted_{timestamp}.log").expanduser().resolve()
matches = find_duplicate_jpgs(root)
if not matches:
print("No matching .jpg/.jpeg files found.")
return 0
print(f"Found {len(matches)} .jpg/.jpeg files to process:\n")
preview_lines: list[str] = []
for jpg, masters in matches:
print(f"- JPG: {jpg}")
preview_lines.append(f"JPG: {jpg}")
for master in masters:
print(f" master: {master}")
preview_lines.append(f" master: {master}")
write_log(log_file, ["# clean-jpg-cleaner preview", f"# root: {root}", *preview_lines, ""])
if args.dry_run:
print(f"\nDry-run enabled. No files changed. Log: {log_file}")
return 0
confirm = input("\nConfirm process these files? [y/N]: ").strip().lower()
if confirm != "y":
print("Cancelled.")
return 0
targets = [jpg for jpg, _ in matches]
workers = max(1, args.threads)
action = "delete" if args.force else "trash"
result_lines = [f"# action: {action}", f"# workers: {workers}"]
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(trash_or_delete, path, args.force) for path in targets]
for future in concurrent.futures.as_completed(futures):
path, status = future.result()
print(f"{status}: {path}")
result_lines.append(f"{status}: {path}")
write_log(log_file, result_lines)
print(f"\nDone. Log: {log_file}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Python 使用方式
安装依赖:
pip install send2trash
预览,不删除:
python3 clean_jpg_cleaner.py ~/Pictures --dry-run
移动到废纸篓:
python3 clean_jpg_cleaner.py ~/Pictures
永久删除:
python3 clean_jpg_cleaner.py ~/Pictures --force
指定日志和线程数:
python3 clean_jpg_cleaner.py ~/Pictures --threads 8 --log ./clean.log
默认移动到废纸篓。只有确认不需要恢复时才使用 --force。
macOS Bash 实现
文件名建议:clean-jpg-cleaner.sh
#!/opt/homebrew/bin/bash
read -e -p "输入要扫描的目录(默认当前目录): " INPUT_DIR
TARGET_DIR="${INPUT_DIR:-.}"
if [[ ! -d "$TARGET_DIR" ]]; then
echo "路径无效:$TARGET_DIR"
exit 1
fi
echo "请选择删除方式:"
echo "1. 移动到废纸篓(推荐)"
echo "2. 永久删除(不可恢复)"
read -p "请输入选项 [1/2](默认 1): " DELETE_MODE
DELETE_MODE="${DELETE_MODE:-1}"
read -p "启用 dry-run 模式?[y/N]: " DRY_RUN_CONFIRM
DRY_RUN=false
[[ "$DRY_RUN_CONFIRM" =~ ^[Yy]$ ]] && DRY_RUN=true
LOG_FILE="./clean_jpg_deleted_$(date +"%Y%m%d_%H%M%S").log"
touch "$LOG_FILE"
MAX_PARALLEL=4
declare -A file_map
mapfile -d '' all_files < <(find "$TARGET_DIR" -type f -print0)
for file in "${all_files[@]}"; do
base="${file%.*}"
ext="${file##*.}"
ext_lc=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
file_map["$base"]+="${ext_lc} "
done
to_delete=()
for base in "${!file_map[@]}"; do
exts="${file_map[$base]}"
if [[ "$exts" =~ (jpg|jpeg) ]] && [[ ! "$exts" =~ ^(jpg|jpeg)[[:space:]]*$ ]]; then
for ext in $exts; do
if [[ "$ext" == "jpg" || "$ext" == "jpeg" ]]; then
file="${base}.${ext}"
to_delete+=("$file")
fi
done
fi
done
if [[ ${#to_delete[@]} -eq 0 ]]; then
echo "没有符合条件的 JPG 文件。"
exit 0
fi
echo "以下 ${#to_delete[@]} 个文件将被处理:"
for file in "${to_delete[@]}"; do
echo "$file"
done
if $DRY_RUN; then
echo "dry-run 模式开启,仅预览,无文件被删除。"
exit 0
fi
read -p "确认删除这些文件?[y/N]: " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "操作已取消。"
exit 0
fi
delete_file() {
f="$1"
if [[ "$DELETE_MODE" == "2" ]]; then
rm "$f"
else
osascript -e 'tell application "Finder" to delete POSIX file "'"$f"'"'
fi
echo "$f" >> "$LOG_FILE"
}
export -f delete_file
export DELETE_MODE LOG_FILE
printf "%s\n" "${to_delete[@]}" | xargs -P "$MAX_PARALLEL" -I{} bash -c 'delete_file "$@"' _ {}
echo "删除完成,共 ${#to_delete[@]} 个文件。日志保存在:$LOG_FILE"
Bash 使用方式
chmod +x clean-jpg-cleaner.sh
./clean-jpg-cleaner.sh
Bash 版本依赖 macOS 的 osascript。Windows 原生环境不适用,Linux/WSL 需要把废纸篓逻辑改成 gio trash 或直接用 Python 版。
项目结构
clean-jpg-cleaner/
├── clean_jpg_cleaner.py
├── clean-jpg-cleaner.sh
├── README.md
├── requirements.txt
├── LICENSE
└── .gitignore
requirements.txt
send2trash
.gitignore
__pycache__/
.venv/
*.pyc
.DS_Store
clean_jpg_deleted_*.log
clean.log
README 模板
# clean-jpg-cleaner
Clean duplicate `.jpg` / `.jpeg` files when same-name master files exist in the same folder.
## Features
- Recursively scans a target folder
- Detects `.jpg` / `.jpeg` files with same-name non-JPG master files
- Supports dry-run preview
- Moves files to trash by default
- Supports permanent delete with `--force`
- Writes audit logs
- Supports concurrent processing
## Install
```bash
pip install -r requirements.txt
```
## Usage
Preview only:
```bash
python3 clean_jpg_cleaner.py ~/Pictures --dry-run
```
Move to trash:
```bash
python3 clean_jpg_cleaner.py ~/Pictures
```
Permanent delete:
```bash
python3 clean_jpg_cleaner.py ~/Pictures --force
```
Custom log and threads:
```bash
python3 clean_jpg_cleaner.py ~/Pictures --threads 8 --log ./clean.log
```
## License
MIT
GitHub 首次提交流程
mkdir clean-jpg-cleaner
cd clean-jpg-cleaner
git init
git add .
git commit -m "feat: add clean jpg cleaner"
git remote add origin https://github.com/YOUR_USERNAME/clean-jpg-cleaner.git
git branch -M main
git push -u origin main
实现取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
| Python 版 | 跨平台、参数清楚、适合 GitHub 项目 | 需要安装 send2trash |
| Bash 版 | macOS 本机快速运行 | Windows 不适用,废纸篓依赖 osascript |
直接 rm |
快 | 风险高,不推荐作为默认行为 |