[Python]重複檔案清理工具:自動保留最舊檔案的完整教學

在這個數位時代,我們的硬碟總是被各種檔案塞滿,其中有許多是重複的檔案占用了寶貴的空間。本教學將帶您製作一個強大的Python工具,不僅能找出所有重複的檔案,還能自動識別並保留最舊的那一份,讓您的檔案整理工作變得輕鬆高效。

工具介紹與功能概述

這個工具使用Python的tkinter庫創建了一個直觀的圖形界面,能夠:

  • 掃描指定資料夾及其子資料夾中的所有檔案
  • 使用MD5雜湊值精確比對檔案內容以發現重複檔案
  • 自動按創建日期排序,標記除了最舊檔案外的所有重複檔案
  • 提供靈活的手動調整選項
  • 安全地刪除選定的重複檔案

與市面上其他類似工具相比,這個程式的特色在於它能自動識別並保留最舊的檔案,這對於那些想保留原始檔案的使用者來說非常實用。

環境準備

本工具使用Python標準庫中的模組,因此不需要安裝額外的套件。您只需要:

  1. 安裝Python 3.6或更高版本(建議使用最新的Python 3.11)
  2. 確保您的Python安裝包含tkinter庫(大多數Python安裝都預設包含)

檢查tkinter是否已安裝的方法:

python -c "import tkinter; print(tkinter.TkVersion)"

如果顯示版本號,則表示tkinter已安裝。

完整程式碼

將以下代碼複製到一個名為duplicate_file_cleaner.py的檔案中:

import os
import hashlib
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from pathlib import Path
import threading
import time

class DuplicateFileFinder:
    def __init__(self, root):
        self.root = root
        self.root.title("重複檔案比對與刪除工具")
        self.root.geometry("900x600")
        self.root.resizable(True, True)
        
        # 建立主框架
        self.main_frame = ttk.Frame(root, padding="10")
        self.main_frame.pack(fill=tk.BOTH, expand=True)
        
        # 建立控制區域
        self.control_frame = ttk.LabelFrame(self.main_frame, text="控制面板", padding="10")
        self.control_frame.pack(fill=tk.X, pady=5)
        
        # 資料夾選擇區域
        self.folder_frame = ttk.Frame(self.control_frame)
        self.folder_frame.pack(fill=tk.X, pady=5)
        
        self.folder_label = ttk.Label(self.folder_frame, text="目標資料夾:")
        self.folder_label.pack(side=tk.LEFT, padx=5)
        
        self.folder_path = tk.StringVar()
        self.folder_entry = ttk.Entry(self.folder_frame, textvariable=self.folder_path, width=50)
        self.folder_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
        
        self.browse_button = ttk.Button(self.folder_frame, text="瀏覽...", command=self.browse_folder)
        self.browse_button.pack(side=tk.LEFT, padx=5)
        
        # 自動標記選項
        self.auto_mark_frame = ttk.Frame(self.control_frame)
        self.auto_mark_frame.pack(fill=tk.X, pady=5)
        
        self.auto_mark_var = tk.BooleanVar(value=True)
        self.auto_mark_check = ttk.Checkbutton(
            self.auto_mark_frame, 
            text="自動標記重複檔案(保留最舊的檔案)", 
            variable=self.auto_mark_var
        )
        self.auto_mark_check.pack(side=tk.LEFT, padx=5)
        
        # 功能按鈕區域
        self.button_frame = ttk.Frame(self.control_frame)
        self.button_frame.pack(fill=tk.X, pady=5)
        
        self.scan_button = ttk.Button(self.button_frame, text="掃描重複檔案", command=self.start_scan)
        self.scan_button.pack(side=tk.LEFT, padx=5)
        
        self.delete_button = ttk.Button(self.button_frame, text="刪除標記的檔案", command=self.delete_selected, state=tk.DISABLED)
        self.delete_button.pack(side=tk.LEFT, padx=5)
        
        # 進度條
        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttk.Progressbar(self.control_frame, variable=self.progress_var, maximum=100)
        self.progress_bar.pack(fill=tk.X, pady=5)
        
        self.status_var = tk.StringVar(value="就緒")
        self.status_label = ttk.Label(self.control_frame, textvariable=self.status_var, anchor=tk.W)
        self.status_label.pack(fill=tk.X, pady=5)
        
        # 結果展示區域
        self.result_frame = ttk.LabelFrame(self.main_frame, text="重複檔案清單", padding="10")
        self.result_frame.pack(fill=tk.BOTH, expand=True, pady=5)
        
        # 建立樹狀視圖展示結果
        self.tree = ttk.Treeview(self.result_frame, columns=("size", "path"), show="tree headings")
        self.tree.heading("#0", text="群組/檔案")
        self.tree.heading("size", text="檔案大小")
        self.tree.heading("path", text="檔案路徑")
        self.tree.column("#0", width=120)
        self.tree.column("size", width=100)
        self.tree.column("path", width=600)
        
        self.tree_scroll_y = ttk.Scrollbar(self.result_frame, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree_scroll_x = ttk.Scrollbar(self.result_frame, orient=tk.HORIZONTAL, command=self.tree.xview)
        self.tree.configure(yscrollcommand=self.tree_scroll_y.set, xscrollcommand=self.tree_scroll_x.set)
        
        self.tree_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        self.tree_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
        
        # 建立右鍵選單
        self.context_menu = tk.Menu(self.tree, tearoff=0)
        self.context_menu.add_command(label="標記為要刪除", command=lambda: self.mark_for_deletion(True))
        self.context_menu.add_command(label="取消標記", command=lambda: self.mark_for_deletion(False))
        self.context_menu.add_separator()
        self.context_menu.add_command(label="除了這個都標記", command=self.mark_all_except)
        
        self.tree.bind("<Button-3>", self.show_context_menu)
        
        # 存儲掃描結果
        self.duplicate_groups = {}
        self.files_to_delete = set()
        
    def browse_folder(self):
        folder_path = filedialog.askdirectory(title="選擇要掃描的資料夾")
        if folder_path:
            self.folder_path.set(folder_path)
    
    def start_scan(self):
        folder_path = self.folder_path.get()
        if not folder_path or not os.path.isdir(folder_path):
            messagebox.showerror("錯誤", "請選擇有效的資料夾路徑")
            return
        
        # 重置UI
        self.tree.delete(*self.tree.get_children())
        self.duplicate_groups = {}
        self.files_to_delete = set()
        self.delete_button.config(state=tk.DISABLED)
        
        # 開始掃描線程
        threading.Thread(target=self.scan_for_duplicates, args=(folder_path,), daemon=True).start()
    
    def scan_for_duplicates(self, folder_path):
        self.status_var.set("正在掃描中...")
        self.progress_var.set(0)
        
        # 清除先前的狀態
        self.files_to_delete = set()
        
        # 階段 1: 按大小分組檔案
        self.update_status("第一階段: 按檔案大小分組...")
        size_groups = {}
        total_files = 0
        
        for root, _, files in os.walk(folder_path):
            total_files += len(files)
        
        processed_files = 0
        for root, _, files in os.walk(folder_path):
            for file in files:
                file_path = os.path.join(root, file)
                try:
                    size = os.path.getsize(file_path)
                    if size > 0:  # 忽略空檔案
                        if size not in size_groups:
                            size_groups[size] = []
                        size_groups[size].append(file_path)
                except (IOError, OSError):
                    pass  # 忽略無法訪問的檔案
                
                processed_files += 1
                self.progress_var.set((processed_files / total_files) * 50)  # 第一階段佔50%
                self.root.update_idletasks()
        
        # 階段 2: 計算MD5並尋找重複項
        self.update_status("第二階段: 計算雜湊值比對重複檔案...")
        potential_duplicates = {size: paths for size, paths in size_groups.items() if len(paths) > 1}
        total_potential_dups = sum(len(paths) for paths in potential_duplicates.values())
        
        if total_potential_dups == 0:
            self.root.after(0, lambda: self.update_status("完成!未發現重複檔案"))
            self.progress_var.set(100)
            return
        
        processed_files = 0
        for size, file_paths in potential_duplicates.items():
            hash_groups = {}
            
            for file_path in file_paths:
                try:
                    file_hash = self.calculate_md5(file_path)
                    if file_hash not in hash_groups:
                        hash_groups[file_hash] = []
                    hash_groups[file_hash].append(file_path)
                except (IOError, OSError):
                    pass  # 忽略無法讀取的檔案
                
                processed_files += 1
                progress = 50 + (processed_files / total_potential_dups) * 50  # 第二階段佔50%
                self.progress_var.set(progress)
                self.root.update_idletasks()
            
            # 儲存重複檔案組
            for file_hash, paths in hash_groups.items():
                if len(paths) > 1:
                    self.duplicate_groups[file_hash] = paths
        
        # 在主線程中更新UI
        self.root.after(0, self.update_ui_with_results)
    
    def calculate_md5(self, file_path, block_size=8192):
        md5 = hashlib.md5()
        with open(file_path, 'rb') as f:
            for block in iter(lambda: f.read(block_size), b''):
                md5.update(block)
        return md5.hexdigest()
    
    def update_status(self, message):
        self.status_var.set(message)
        self.root.update_idletasks()
    
    def update_ui_with_results(self):
        if not self.duplicate_groups:
            self.update_status("完成!未發現重複檔案")
            return
        
        # 填充樹狀視圖
        for idx, (file_hash, file_paths) in enumerate(self.duplicate_groups.items()):
            size = os.path.getsize(file_paths[0])
            size_str = self.format_size(size)
            
            group_id = f"group_{idx}"
            group_text = f"群組 {idx+1} ({len(file_paths)} 個檔案)"
            
            self.tree.insert("", "end", group_id, text=group_text, values=(size_str, f"MD5: {file_hash}"))
            
            # 按創建日期排序檔案(最舊的優先)
            sorted_paths = sorted(file_paths, key=lambda p: os.path.getctime(p))
            
            # 顯示檔案並自動標記
            for i, path in enumerate(sorted_paths):
                # 為每個檔案創建一個唯一的安全ID
                file_id = f"file_{idx}_{i}"
                file_name = os.path.basename(path)
                creation_time = os.path.getctime(path)
                creation_time_str = self.format_time(creation_time)
                
                # 分開顯示路徑和創建時間,避免字串過長
                display_path = path
                display_time = f"建立時間: {creation_time_str}"
                
                self.tree.insert(group_id, "end", file_id, text=file_name, 
                                values=(size_str, display_path))
                
                # 自動標記除了最舊檔案以外的所有檔案
                if self.auto_mark_var.get() and i > 0:
                    self.mark_item(file_id, True, path)
        
        self.update_status(f"掃描完成!發現 {len(self.duplicate_groups)} 組重複檔案")
        self.delete_button.config(state=tk.NORMAL)
    
    def format_size(self, size):
        for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
            if size < 1024.0:
                return f"{size:.2f} {unit}"
            size /= 1024.0
        return f"{size:.2f} PB"
    
    def format_time(self, timestamp):
        return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp))
    
    def show_context_menu(self, event):
        item = self.tree.identify_row(event.y)
        if item:
            self.tree.selection_set(item)
            self.context_menu.post(event.x_root, event.y_root)
    
    def mark_for_deletion(self, to_delete):
        selected_item = self.tree.selection()[0] if self.tree.selection() else None
        if not selected_item:
            return
        
        if selected_item.startswith("group_"):
            # 如果選擇了一個群組,標記所有子項目
            for child in self.tree.get_children(selected_item):
                file_path = self.tree.item(child, "values")[1]
                self.mark_item(child, to_delete, file_path)
        else:
            # 標記單個檔案
            file_path = self.tree.item(selected_item, "values")[1]
            self.mark_item(selected_item, to_delete, file_path)
    
    def mark_item(self, item_id, to_delete, file_path):
        if to_delete:
            self.files_to_delete.add(file_path)
            self.tree.item(item_id, tags=("delete",))
            self.tree.tag_configure("delete", foreground="red")
        else:
            if file_path in self.files_to_delete:
                self.files_to_delete.remove(file_path)
            self.tree.item(item_id, tags=())
    
    def mark_all_except(self):
        selected_item = self.tree.selection()[0] if self.tree.selection() else None
        if not selected_item or selected_item.startswith("group_"):
            return
        
        # 獲取父群組
        parent = self.tree.parent(selected_item)
        
        # 標記除了所選檔案以外的所有檔案
        for child in self.tree.get_children(parent):
            file_path = self.tree.item(child, "values")[1]
            if child != selected_item:
                self.mark_item(child, True, file_path)
            else:
                self.mark_item(child, False, file_path)
    
    def delete_selected(self):
        if not self.files_to_delete:
            messagebox.showinfo("提示", "沒有標記要刪除的檔案")
            return
        
        confirm = messagebox.askyesno(
            "確認刪除", 
            f"確定要刪除 {len(self.files_to_delete)} 個標記的檔案嗎?\n此操作無法復原!",
            icon="warning"
        )
        
        if not confirm:
            return
        
        deleted = 0
        errors = 0
        for file_path in self.files_to_delete:
            try:
                os.remove(file_path)
                deleted += 1
            except Exception:
                errors += 1
        
        # 更新UI,移除已刪除的項目
        for item_id in self.tree.tag_has("delete"):
            self.tree.delete(item_id)
        
        # 檢查群組是否只剩一個檔案,如果是則移除群組結構
        for group_id in [x for x in self.tree.get_children() if x.startswith("group_")]:
            children = self.tree.get_children(group_id)
            if len(children) <= 1:
                for child in children:
                    self.tree.detach(child)
                self.tree.delete(group_id)
        
        self.files_to_delete.clear()
        
        if errors > 0:
            messagebox.showwarning("警告", f"已刪除 {deleted} 個檔案,但有 {errors} 個檔案無法刪除")
        else:
            messagebox.showinfo("完成", f"成功刪除 {deleted} 個檔案")

def main():
    root = tk.Tk()
    app = DuplicateFileFinder(root)
    root.mainloop()

if __name__ == "__main__":
    main()

程式碼解析

讓我們來分解這個程式的主要組件和功能:

1. 界面設計

程式使用tkinterttk建立了一個分區明確的用戶界面:

# 建立主框架
self.main_frame = ttk.Frame(root, padding="10")
self.main_frame.pack(fill=tk.BOTH, expand=True)

界面主要分為三個部分:

  • 控制面板:包含資料夾選擇、選項設定和操作按鈕
  • 進度指示區:顯示進度條和狀態消息
  • 結果顯示區:使用樹狀視圖展示重複檔案組

2. 檔案掃描與比對

檔案比對分為兩個階段,提高了效率:

# 階段 1: 按大小分組檔案
size_groups = {}
# ... 代碼實現 ...

# 階段 2: 計算MD5並尋找重複項
for size, file_paths in potential_duplicates.items():
    hash_groups = {}
    # ... 代碼實現 ...
  1. 第一階段:按檔案大小分組,因為大小不同的檔案必然不是重複的
  2. 第二階段:只對相同大小的檔案計算MD5雜湊值,節省時間和資源

3. 自動保留最舊檔案

這是本工具的核心功能,實現如下:

# 按創建日期排序檔案(最舊的優先)
sorted_paths = sorted(file_paths, key=lambda p: os.path.getctime(p))

# 顯示檔案並自動標記
for i, path in enumerate(sorted_paths):
    # ... 顯示檔案 ...
    
    # 自動標記除了最舊檔案以外的所有檔案
    if self.auto_mark_var.get() and i > 0:
        self.mark_item(file_id, True, path)

程式使用os.path.getctime()獲取檔案的創建時間,然後按時間升序排列,保留第一個(最舊的)檔案。

4. 多線程處理

為了避免掃描過程中界面凍結,程式使用了多線程:

# 開始掃描線程
threading.Thread(target=self.scan_for_duplicates, args=(folder_path,), daemon=True).start()

這確保了即使在掃描大量檔案時,用戶界面仍然保持響應。

使用指南

使用這個工具非常簡單,只需幾個步驟:

步驟 1:選擇目標資料夾

點擊「瀏覽…」按鈕,選擇要掃描的資料夾。

步驟 2:開始掃描

確認「自動標記重複檔案」選項已勾選(預設勾選),然後點擊「掃描重複檔案」按鈕。

步驟 3:檢查掃描結果

掃描完成後,重複檔案會按群組顯示在樹狀視圖中:

  • 每個群組代表一組內容相同的檔案
  • 紅色標記的檔案將被刪除(預設是保留最舊的檔案,其餘標記為刪除)

步驟 4:調整選擇(可選)

如果需要手動調整要刪除的檔案:

  1. 右鍵點擊檔案,從選單中選擇:
    • 「標記為要刪除」:將檔案標記為刪除
    • 「取消標記」:取消檔案的刪除標記
    • 「除了這個都標記」:保留選中檔案,標記同組的其他檔案

步驟 5:刪除重複檔案

確認標記無誤後,點擊「刪除標記的檔案」按鈕。系統會顯示確認對話框,確認後將永久刪除標記的檔案。

自定義與擴展

這個程式可以根據您的需求輕鬆擴展。以下是一些可能的自定義方向:

修改保留策略

如果您想按其他標準選擇要保留的檔案(例如保留最新的而非最舊的),只需修改排序函數:

# 保留最新檔案而非最舊檔案
sorted_paths = sorted(file_paths, key=lambda p: os.path.getctime(p), reverse=True)

添加更多檔案訊息

您可以在檔案列表中顯示更多信息,例如修改時間、檔案類型等:

# 在update_ui_with_results方法中修改
modification_time = os.path.getmtime(path)
modification_time_str = self.format_time(modification_time)
file_extension = os.path.splitext(path)[1]

# 然後在display_path中添加這些信息

添加匯出報告功能

您可以添加一個按鈕來將重複檔案的清單匯出為CSV或TXT格式:

def export_report(self):
    export_path = filedialog.asksaveasfilename(
        defaultextension=".csv",
        filetypes=[("CSV檔案", "*.csv"), ("文字檔案", "*.txt")]
    )
    if not export_path:
        return
        
    with open(export_path, 'w', encoding='utf-8') as f:
        # 寫入報告內容
        f.write("群組,檔案名稱,檔案大小,檔案路徑,創建時間,是否標記刪除\n")
        # 遍歷所有群組和檔案...

常見問題

Q1: 為什麼程式掃描大型檔案很慢?

A: 計算MD5雜湊值需要讀取整個檔案內容,對於大型檔案可能需要較長時間。程式已經優化了掃描流程,首先按檔案大小過濾,只對可能重複的檔案計算雜湊值。

Q2: 如何判斷檔案是否真的重複?

A: 程式使用MD5雜湊算法比對檔案內容,這提供了極高的準確率。兩個不同內容的檔案產生相同MD5值的機率極低(hash碰撞),因此可以確保被識別為重複的檔案真的內容相同。

Q3: 刪除後可以恢復嗎?

A: 不可以。程式直接使用os.remove()刪除檔案,這是永久性的操作,不會將檔案移至回收站。請在刪除前仔細檢查標記的檔案。

Q4: 程式在比對哪些類型的檔案?

A: 程式會比對所有非空檔案,不限制檔案類型。無論是文檔、圖片、音頻還是視頻檔案,只要內容相同就會被識別為重複。

Q5: 我可以保留特定位置的檔案嗎?

A: 預設情況下,程式會保留最舊的檔案。如果您想保留特定位置的檔案,可以在掃描完成後使用右鍵選單手動調整標記。

結語

這個Python重複檔案清理工具不僅能幫您節省寶貴的硬碟空間,還通過自動保留最舊檔案的功能,讓檔案整理工作變得更加智能和高效。無論您是整理照片、音樂、文檔還是其他類型的檔案,它都能幫您快速找出並安全地移除重複內容。

您可以根據自己的需要自由修改和擴展這個程式,添加更多功能或調整現有功能的行為。希望這個工具能夠幫助您更好地管理數位資產!

如有任何問題或建議,歡迎在評論區留言交流。


注意事項:在使用此工具刪除檔案前,強烈建議先備份重要資料。程式使用的刪除操作是永久性的,無法恢復。

發佈留言