在這個數位時代,我們的硬碟總是被各種檔案塞滿,其中有許多是重複的檔案占用了寶貴的空間。本教學將帶您製作一個強大的Python工具,不僅能找出所有重複的檔案,還能自動識別並保留最舊的那一份,讓您的檔案整理工作變得輕鬆高效。
工具介紹與功能概述
這個工具使用Python的tkinter庫創建了一個直觀的圖形界面,能夠:
- 掃描指定資料夾及其子資料夾中的所有檔案
- 使用MD5雜湊值精確比對檔案內容以發現重複檔案
- 自動按創建日期排序,標記除了最舊檔案外的所有重複檔案
- 提供靈活的手動調整選項
- 安全地刪除選定的重複檔案
與市面上其他類似工具相比,這個程式的特色在於它能自動識別並保留最舊的檔案,這對於那些想保留原始檔案的使用者來說非常實用。
環境準備
本工具使用Python標準庫中的模組,因此不需要安裝額外的套件。您只需要:
- 安裝Python 3.6或更高版本(建議使用最新的Python 3.11)
- 確保您的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. 界面設計
程式使用tkinter和ttk建立了一個分區明確的用戶界面:
# 建立主框架 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 = {}
# ... 代碼實現 ...
- 第一階段:按檔案大小分組,因為大小不同的檔案必然不是重複的
- 第二階段:只對相同大小的檔案計算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:調整選擇(可選)
如果需要手動調整要刪除的檔案:
- 右鍵點擊檔案,從選單中選擇:
- 「標記為要刪除」:將檔案標記為刪除
- 「取消標記」:取消檔案的刪除標記
- 「除了這個都標記」:保留選中檔案,標記同組的其他檔案
步驟 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重複檔案清理工具不僅能幫您節省寶貴的硬碟空間,還通過自動保留最舊檔案的功能,讓檔案整理工作變得更加智能和高效。無論您是整理照片、音樂、文檔還是其他類型的檔案,它都能幫您快速找出並安全地移除重複內容。
您可以根據自己的需要自由修改和擴展這個程式,添加更多功能或調整現有功能的行為。希望這個工具能夠幫助您更好地管理數位資產!
如有任何問題或建議,歡迎在評論區留言交流。
注意事項:在使用此工具刪除檔案前,強烈建議先備份重要資料。程式使用的刪除操作是永久性的,無法恢復。