iT邦幫忙

1

[Day14]批次檔名改名器:前綴/後綴/大小寫/取代字串/連號,支援試跑輸出 CSV(Dry-run)

  • 分享至 

  • xImage
  •  

大量檔案(作業照片、下載資料、爬到的檔)名稱亂成一團?
今天要做的這支工具可一次整理好:前綴、後綴、大小寫、字串取代、空白轉符號、改副檔名、加連號
檔名:rename_tool.py

# rename_tool.py — Day 14:批次檔名改名器(支援 Dry-run 與 CSV 匯出)
from __future__ import annotations
import argparse, csv, re, shutil
from pathlib import Path
from typing import List, Tuple, Dict

INVALID_WIN = r'<>:"/\\|?*'  # Windows 不允許的字元

def normalize_spaces(name: str, fill: str | None) -> str:
    if not fill:
        return name
    # 所有空白(含連續空白、tab)轉成 fill,並收斂重複
    s = re.sub(r"\s+", fill, name)
    s = re.sub(rf"{re.escape(fill)}+", fill, s)
    return s

def apply_case(stem: str, mode: str | None) -> str:
    if not mode: return stem
    if mode == "lower": return stem.lower()
    if mode == "upper": return stem.upper()
    if mode == "title": return stem.title()
    return stem

def apply_replacements(stem: str, pairs: List[Tuple[str,str]]) -> str:
    for a, b in pairs:
        stem = stem.replace(a, b)
    return stem

def sanitize(name: str) -> str:
    # 移除 Windows 不合法字元,避免改名失敗
    for ch in INVALID_WIN:
        name = name.replace(ch, "")
    # 避免結尾為空白或點
    return name.strip(" .")

def plan_new_name(p: Path, args, seq: int) -> str:
    stem, ext = p.stem, p.suffix
    # 大小寫、取代、空白
    stem = apply_case(stem, args.case)
    stem = apply_replacements(stem, args.replace or [])
    stem = normalize_spaces(stem, args.spaces)

    # 前綴/後綴
    new_stem = f"{args.prefix or ''}{stem}{args.suffix or ''}"

    # 連號
    if args.number:
        num = f"{seq:0{args.num_digits}d}"
        if args.num_pos == "front":
            new_stem = f"{num}_{new_stem}"
        else:
            new_stem = f"{new_stem}_{num}"

    # 改副檔名
    out_ext = args.to_ext if args.to_ext else ext
    if out_ext and not out_ext.startswith("."):
        out_ext = "." + out_ext

    new_name = sanitize(new_stem + out_ext)
    return new_name

def collect_files(src: Path, recursive: bool, match: List[str] | None) -> List[Path]:
    pats = match or ["*"]
    files: List[Path] = []
    for pat in pats:
        if recursive:
            files.extend([p for p in src.rglob(pat) if p.is_file()])
        else:
            files.extend([p for p in src.glob(pat) if p.is_file()])
    # 去重(不同 pattern 可能抓到同檔)
    uniq = []
    seen = set()
    for p in files:
        if p.resolve() not in seen:
            uniq.append(p)
            seen.add(p.resolve())
    return uniq

def write_csv(plan: List[Dict], out: Path):
    out.parent.mkdir(parents=True, exist_ok=True)
    cols = ["src", "dst", "status", "reason"]
    with out.open("w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        for row in plan:
            w.writerow({k: row.get(k, "") for k in cols})

def main():
    ap = argparse.ArgumentParser(
        description="批次檔名改名器:前綴/後綴/大小寫/取代/空白/副檔名/連號。預設為 dry-run。")
    ap.add_argument("--src", type=Path, required=True, help="來源資料夾")
    ap.add_argument("--recursive", action="store_true", help="包含子資料夾")
    ap.add_argument("--match", nargs="*", help="檔名過濾 pattern,例如 *.jpg *.png *.pdf(可多個)")

    # 規則
    ap.add_argument("--prefix", default="", help="加在檔名前面的前綴")
    ap.add_argument("--suffix", default="", help="加在檔名後面的後綴")
    ap.add_argument("--spaces", default=None, help="把所有空白換成此字元,例如 _ 或 -")
    ap.add_argument("--case", choices=["lower","upper","title"], help="大小寫模式")
    ap.add_argument("--replace", nargs="*", metavar="A=B",
                    help="字串取代,可多個,格式 A=B(從檔名中把 A 換成 B)")
    ap.add_argument("--to-ext", help="改副檔名,例如 jpg / png / pdf(不含點),不填則保留原始")

    # 連號
    ap.add_argument("--number", action="store_true", help="加入連號")
    ap.add_argument("--num-start", type=int, default=1, help="連號起始(預設 1)")
    ap.add_argument("--num-digits", type=int, default=3, help="連號位數(預設 3)")
    ap.add_argument("--num-pos", choices=["front","end"], default="front", help="連號位置(前/後)")

    # 執行
    ap.add_argument("--apply", action="store_true", help="真的改名(預設不改,只試跑)")
    ap.add_argument("--out", type=Path, default=Path("exports/rename_preview.csv"), help="輸出 CSV")
    args = ap.parse_args()

    src = args.src
    if not src.exists():
        print("❌ 來源不存在")
        return

    # 解析 replace 參數
    pairs: List[Tuple[str,str]] = []
    if args.replace:
        for token in args.replace:
            if "=" not in token:
                print(f"⚠️ 忽略不合法 replace:{token}(需 A=B)")
                continue
            a, b = token.split("=", 1)
            pairs.append((a, b))
    args.replace = pairs

    files = collect_files(src, args.recursive, args.match)
    if not files:
        print("⚠️ 找不到檔案(檢查 --match pattern 或資料夾路徑)")
        return

    plan: List[Dict] = []
    seq = args.num_start
    dst_used = set()

    for p in files:
        new_name = plan_new_name(p, args, seq if args.number else 0)
        if args.number:
            seq += 1

        dst = p.with_name(new_name)
        status = "ok"
        reason = ""

        # 目標已存在或重複
        key = (dst.parent.resolve(), dst.name)
        if dst.exists() or key in dst_used:
            status = "conflict"
            reason = "目標檔名已存在或重複"
            # 追加 _1, _2… 解決衝突
            base = dst.stem
            ext  = dst.suffix
            k = 1
            while True:
                alt = dst.with_name(f"{base}_{k}{ext}")
                key2 = (alt.parent.resolve(), alt.name)
                if not alt.exists() and key2 not in dst_used:
                    dst = alt
                    status = "ok"
                    reason = "自動加序號避免衝突"
                    key = key2
                    break
                k += 1

        dst_used.add(key)
        plan.append({"src": str(p), "dst": str(dst), "status": status, "reason": reason})

    # 輸出預覽 CSV
    write_csv(plan, args.out)
    print(f"📝 已輸出預覽:{args.out}")

    # 列出前 10 筆預覽
    print("\n預覽(前 10 筆):")
    for row in plan[:10]:
        print(f"- {Path(row['src']).name}  ->  {Path(row['dst']).name}  [{row['status']}]")

    if not args.apply:
        print("\n(Dry-run)預設不會真的改名,加上 --apply 參數才會執行。")
        return

    # 真的執行改名
    done_ok = done_fail = 0
    for row in plan:
        if row["status"] != "ok":
            done_fail += 1
            continue
        src_p = Path(row["src"])
        dst_p = Path(row["dst"])
        try:
            if src_p == dst_p:
                continue
            shutil.move(str(src_p), str(dst_p))
            done_ok += 1
        except Exception as e:
            row["status"] = "error"
            row["reason"] = str(e)
            done_fail += 1

    # 重新輸出執行後的結果
    final_csv = args.out.with_name(args.out.stem + "_applied.csv") if args.apply else args.out
    write_csv(plan, final_csv)
    print(f"\n✅ 改名完成:成功 {done_ok},失敗/跳過 {done_fail}")
    print(f"📄 變更明細:{final_csv}")

if __name__ == "__main__":
    main()

使用方式

先在專案資料夾開終端機(VS Code 內建 Terminal),路徑要在有檔案的資料夾。
1)最基本:把所有空白改成底線,轉小寫

python rename_tool.py --src . --spaces _ --case lower

輸出 exports/rename_preview.csv,不會動到檔案。
2)加入前綴 & 後綴、限定副檔名、加連號,真的改名

python rename_tool.py --src . ^
  --match *.jpg *.png ^
  --prefix Trip_ --suffix _Taipei ^
  --number --num-start 1 --num-digits 3 --num-pos front ^
  --apply

3)字串取代(把「_副本」→「」;把「空白」→「-」)

python rename_tool.py --src D:\photos ^
  --recursive --match *.jpg ^
  --replace _副本=  " =-" ^
  --apply

4)改副檔名(把 .jpeg 全部轉成 .jpg),並輸出到自訂 CSV

python rename_tool.py --src . --match *.jpeg --to-ext jpg --out exports/rename_jpeg.csv

Windows 開啟輸出資料夾小技巧:ii .\exports
實作:
https://ithelp.ithome.com.tw/upload/images/20250930/20169368RzWHzyxCtT.png

功能重點
Dry-run 預設:不會誤動檔案;先看 CSV 預覽再決定
自動避免衝突:若目標檔已存在,會加 _1、_2…
大小寫/空白/取代:常見整理規則一次到位
通配過濾:--match 接多個 *.jpg *.png *.pdf
子資料夾:--recursive 會一路處理

小提醒
預覽和實際不同?
改名前後都會各輸出一次 CSV( _applied.csv ),可比對。
權限不足 / 檔案被占用
關閉正在使用該檔案的程式,或用管理員開終端機再試。
改副檔名 ≠ 轉檔
這只是改檔名的副檔名,不會轉格式

今日小結
完成一支零相依的批次改名工具:可先試跑、輸出 CSV、再一次套用
規則彈性:前綴/後綴/大小寫/取代/空白/連號/副檔名


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言