Projects / Python Tool

clean-jpg-cleaner 项目整理

一个用于清理同目录同名 JPG/JPEG 副本的摄影工作流工具,包含 Python 跨平台实现、macOS Bash 实现、README 模板和 GitHub 提交流程。

目标:清理同一文件夹内“同文件名但后缀为 .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/bashfindxargsosascript

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 风险高,不推荐作为默认行为