微信 / QQ 缓存安全清理工具
一个只扫描、不自动删除的 Windows 本地清理工具。它只在指定的微信、QQ 数据目录中寻找规则明确的头像缓存和缩略图缓存,并把每一个候选文件的完整路径、类别和大小展示给用户。删除操作必须由用户在界面中选中并二次确认。
安全边界
- 默认目录:
文档/WeChat Files、文档/Tencent Files,以及常见的微信 AppData 目录(存在时才扫描)。可自行添加目录。
- 不扫描聊天记录数据库、文档、视频、原图、语音或任意未知文件。
- 仅匹配以下规则:
- 头像缓存:路径中含
avatar、headimage 或 headimg,扩展名为 .dat。
- 缩略图缓存:路径中含
thumb 或 thumbnail,扩展名为 .dat、.jpg、.jpeg、.png、.webp、.bmp。
- 文件还须落在界面设定的大小范围内(默认 1 KiB–20 MiB)。
- 不递归跟随目录链接;无权读取的目录会跳过并记录在扫描提示中。
- 删除前会在程序目录的
删除清单 文件夹中生成 UTF-8 .txt 清单,包含时间、路径、大小、规则和删除结果。
- 清单是审计记录,不含文件内容,不能用于恢复文件。请先检查列表;程序不会代替用户判断文件是否可删。
运行
需要 Python 3.10+,只使用标准库:
python wechat_qq_cleaner.py
在 Windows 中也可以双击运行(若 .py 已关联 Python)。
使用方式
- 关闭微信和 QQ,避免文件正在写入或占用。
- 启动程序,确认或添加要扫描的根目录。
- 设置体积范围后点击 开始安全扫描。
- 在结果列表中按住
Ctrl / Shift 多选;点击 删除所选文件。
- 阅读确认窗口。程序会先写入删除清单,再逐个删除选定文件。
建议第一次只删除少量文件并在客户端中确认无异常。重要资料请自行备份.
源码
from __future__ import annotations
import os
import queue
import threading
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
APP_TITLE = "微信 / QQ 缓存安全清理"
MANIFEST_DIR_NAME = "删除清单"
@dataclass(frozen=True)
class CacheRule:
key: str
label: str
description: str
extensions: tuple[str, ...]
path_keywords: tuple[str, ...]
def matches(self, file_path: Path) -> bool:
path_text = str(file_path).lower()
return (
file_path.suffix.lower() in self.extensions
and any(keyword in path_text for keyword in self.path_keywords)
)
CACHE_RULES = (
CacheRule(
"avatar",
"头像缓存",
"仅限路径含 avatar / headimage / headimg 的 .dat 文件",
(".dat",),
("avatar", "headimage", "headimg"),
),
CacheRule(
"thumbnail",
"图片缩略图缓存",
"仅限路径含 thumb / thumbnail 的常见小图片或 .dat 文件",
(".dat", ".jpg", ".jpeg", ".png", ".webp", ".bmp"),
("thumb", "thumbnail"),
),
CacheRule(
"sticker",
"表情包缓存",
"仅限路径含 emoji / sticker / emoticon 的常见图片或 .dat 文件",
(".dat", ".gif", ".jpg", ".jpeg", ".png", ".webp"),
("emoji", "sticker", "emoticon"),
),
)
@dataclass
class ScanItem:
path: Path
size: int
category: str
rule_description: str
def format_size(size: int) -> str:
units = ("B", "KiB", "MiB", "GiB", "TiB")
value = float(size)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
value /= 1024
return f"{size} B"
def default_scan_roots() -> list[Path]:
home = Path.home()
candidates = (
home / "Documents" / "WeChat Files",
home / "文档" / "WeChat Files",
home / "Documents" / "Tencent Files",
home / "文档" / "Tencent Files",
home / "AppData" / "Roaming" / "Tencent" / "WeChat",
home / "AppData" / "Roaming" / "Tencent" / "QQ",
)
seen: set[str] = set()
roots: list[Path] = []
for candidate in candidates:
normalized = os.path.normcase(os.path.normpath(str(candidate)))
if candidate.is_dir() and normalized not in seen:
roots.append(candidate)
seen.add(normalized)
return roots
class CleanerApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title(APP_TITLE)
self.geometry("1220x760")
self.minsize(980, 620)
self.items: dict[str, ScanItem] = {}
self.events: queue.Queue[tuple[str, object]] = queue.Queue()
self.scanning = False
self.rule_vars = {rule.key: tk.BooleanVar(value=True) for rule in CACHE_RULES}
self.min_kib_var = tk.StringVar(value="1")
self.max_mib_var = tk.StringVar(value="20")
self.status_var = tk.StringVar(value="请选择要扫描的缓存类型,然后开始安全扫描。")
self.summary_var = tk.StringVar(value="当前没有扫描结果")
self.selection_var = tk.StringVar(value="已选 0 个文件,共 0 B")
self._build_ui()
self._add_default_roots()
self.after(100, self._process_events)
def _build_ui(self) -> None:
self.configure(bg="#f4faf7")
style = ttk.Style(self)
style.theme_use("clam")
style.configure("App.TFrame", background="#f4faf7")
style.configure("Card.TFrame", background="#ffffff")
style.configure("Title.TLabel", background="#f4faf7", foreground="#173b35", font=("Microsoft YaHei UI", 18, "bold"))
style.configure("Subtitle.TLabel", background="#f4faf7", foreground="#62807a", font=("Microsoft YaHei UI", 10))
style.configure("Section.TLabelframe", background="#ffffff", bordercolor="#dcebe5", relief="solid")
style.configure("Section.TLabelframe.Label", background="#ffffff", foreground="#286b5f", font=("Microsoft YaHei UI", 10, "bold"))
style.configure("TLabel", background="#ffffff", foreground="#28453f", font=("Microsoft YaHei UI", 9))
style.configure("Hint.TLabel", background="#ffffff", foreground="#728a84", font=("Microsoft YaHei UI", 8))
style.configure("Primary.TButton", background="#329a83", foreground="#ffffff", borderwidth=0, padding=(16, 8), font=("Microsoft YaHei UI", 9, "bold"))
style.map("Primary.TButton", background=[("active", "#237b68"), ("disabled", "#b8d9d0")])
style.configure("Soft.TButton", background="#e6f4ef", foreground="#237b68", borderwidth=0, padding=(12, 7))
style.map("Soft.TButton", background=[("active", "#d2ebe3")])
style.configure("Danger.TButton", background="#fff0ee", foreground="#b95044", borderwidth=0, padding=(12, 7))
style.map("Danger.TButton", background=[("active", "#fbded9")])
style.configure("Treeview", background="#ffffff", fieldbackground="#ffffff", foreground="#28453f", rowheight=31, font=("Microsoft YaHei UI", 9), borderwidth=0)
style.configure("Treeview.Heading", background="#e9f5f0", foreground="#286b5f", relief="flat", font=("Microsoft YaHei UI", 9, "bold"), padding=(8, 8))
style.map("Treeview", background=[("selected", "#cfece3")], foreground=[("selected", "#173b35")])
page = ttk.Frame(self, style="App.TFrame", padding=(22, 18, 22, 14))
page.pack(fill=tk.BOTH, expand=True)
header = ttk.Frame(page, style="App.TFrame")
header.pack(fill=tk.X, pady=(0, 14))
ttk.Label(header, text="微信 / QQ 缓存安全清理", style="Title.TLabel").pack(anchor=tk.W)
ttk.Label(
header,
text="只扫描已选择的明确缓存类型 · 文件由你逐项确认后才会删除",
style="Subtitle.TLabel",
).pack(anchor=tk.W, pady=(4, 0))
top = ttk.Frame(page, style="App.TFrame")
top.pack(fill=tk.X)
type_box = ttk.LabelFrame(top, text=" ① 选择要扫描的缓存类型 ", style="Section.TLabelframe", padding=14)
type_box.pack(fill=tk.X)
for index, rule in enumerate(CACHE_RULES):
rule_frame = ttk.Frame(type_box, style="Card.TFrame", padding=(3, 2))
rule_frame.grid(row=0, column=index, sticky=tk.NSEW, padx=(0, 24 if index < len(CACHE_RULES) - 1 else 0))
ttk.Checkbutton(rule_frame, text=rule.label, variable=self.rule_vars[rule.key]).pack(anchor=tk.W)
ttk.Label(rule_frame, text=rule.description, style="Hint.TLabel", wraplength=265).pack(anchor=tk.W, pady=(3, 0))
type_box.columnconfigure(index, weight=1)
limit_box = ttk.Frame(top, style="Card.TFrame", padding=(4, 12, 4, 0))
limit_box.pack(fill=tk.X)
ttk.Label(limit_box, text="文件体积范围:").pack(side=tk.LEFT)
ttk.Entry(limit_box, textvariable=self.min_kib_var, width=8).pack(side=tk.LEFT)
ttk.Label(limit_box, text=" KiB 至 ").pack(side=tk.LEFT)
ttk.Entry(limit_box, textvariable=self.max_mib_var, width=8).pack(side=tk.LEFT)
ttk.Label(limit_box, text=" MiB", style="Hint.TLabel").pack(side=tk.LEFT)
ttk.Label(limit_box, text="范围外的文件不会进入结果列表", style="Hint.TLabel").pack(side=tk.LEFT, padx=(12, 0))
roots_box = ttk.LabelFrame(page, text=" ② 扫描位置(仅限下列目录) ", style="Section.TLabelframe", padding=12)
roots_box.pack(fill=tk.X, pady=(12, 10))
self.roots_listbox = tk.Listbox(
roots_box, height=4, selectmode=tk.EXTENDED, relief=tk.FLAT, borderwidth=0,
background="#f8fcfa", foreground="#28453f", selectbackground="#cfece3",
selectforeground="#173b35", font=("Microsoft YaHei UI", 9), highlightthickness=0,
)
self.roots_listbox.pack(side=tk.LEFT, fill=tk.X, expand=True)
root_buttons = ttk.Frame(roots_box, style="Card.TFrame")
root_buttons.pack(side=tk.LEFT, fill=tk.Y, padx=(12, 0))
ttk.Button(root_buttons, text="添加目录", style="Soft.TButton", command=self._add_root).pack(fill=tk.X)
ttk.Button(root_buttons, text="移除选中", style="Soft.TButton", command=self._remove_selected_roots).pack(fill=tk.X, pady=(7, 0))
actions = ttk.Frame(page, style="App.TFrame", padding=(0, 2))
actions.pack(fill=tk.X)
self.scan_button = ttk.Button(actions, text="开始安全扫描", style="Primary.TButton", command=self._start_scan)
self.scan_button.pack(side=tk.LEFT)
self.delete_button = ttk.Button(actions, text="删除所选文件", style="Danger.TButton", command=self._delete_selected)
self.delete_button.pack(side=tk.LEFT, padx=(8, 0))
ttk.Button(actions, text="全选结果", style="Soft.TButton", command=self._select_all_results).pack(side=tk.LEFT, padx=(18, 0))
ttk.Button(actions, text="清空结果", style="Soft.TButton", command=self._clear_results).pack(side=tk.LEFT, padx=(8, 0))
ttk.Label(actions, textvariable=self.summary_var, style="Subtitle.TLabel").pack(side=tk.RIGHT, pady=(5, 0))
table_card = ttk.LabelFrame(page, text=" ③ 检查扫描结果并手动选择 ", style="Section.TLabelframe", padding=8)
table_card.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
table_box = ttk.Frame(table_card, style="Card.TFrame")
table_box.pack(fill=tk.BOTH, expand=True)
columns = ("category", "size", "path", "rule")
self.tree = ttk.Treeview(table_box, columns=columns, show="headings", selectmode="extended")
self.tree.heading("category", text="类型")
self.tree.heading("size", text="大小")
self.tree.heading("path", text="完整路径")
self.tree.heading("rule", text="匹配规则")
self.tree.column("category", width=120, stretch=False)
self.tree.column("size", width=105, stretch=False, anchor=tk.E)
self.tree.column("path", width=650, stretch=True)
self.tree.column("rule", width=300, stretch=True)
self.tree.bind("<<TreeviewSelect>>", self._update_selection_summary)
vertical = ttk.Scrollbar(table_box, orient=tk.VERTICAL, command=self.tree.yview)
horizontal = ttk.Scrollbar(table_box, orient=tk.HORIZONTAL, command=self.tree.xview)
self.tree.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set)
self.tree.grid(row=0, column=0, sticky=tk.NSEW)
vertical.grid(row=0, column=1, sticky=tk.NS)
horizontal.grid(row=1, column=0, sticky=tk.EW)
table_box.rowconfigure(0, weight=1)
table_box.columnconfigure(0, weight=1)
footer = ttk.Frame(page, style="App.TFrame", padding=(2, 9, 2, 0))
footer.pack(fill=tk.X)
ttk.Label(footer, textvariable=self.selection_var, style="Subtitle.TLabel").pack(side=tk.LEFT)
ttk.Label(footer, text="删除前会生成可追溯的 .txt 清单", style="Subtitle.TLabel").pack(side=tk.RIGHT)
status = ttk.Label(self, textvariable=self.status_var, relief=tk.FLAT, anchor=tk.W, padding=(18, 7), background="#e4f2ed", foreground="#286b5f")
status.pack(fill=tk.X, side=tk.BOTTOM)
def _add_default_roots(self) -> None:
for root in default_scan_roots():
self.roots_listbox.insert(tk.END, str(root))
if self.roots_listbox.size() == 0:
self.status_var.set("未发现默认微信/QQ目录,请使用“添加目录”选择 WeChat Files 或 Tencent Files。")
def _add_root(self) -> None:
selected = filedialog.askdirectory(title="选择 WeChat Files、Tencent Files 或其子目录")
if not selected:
return
normalized = os.path.normcase(os.path.normpath(selected))
existing = {
os.path.normcase(os.path.normpath(self.roots_listbox.get(index)))
for index in range(self.roots_listbox.size())
}
if normalized not in existing:
self.roots_listbox.insert(tk.END, selected)
def _remove_selected_roots(self) -> None:
for index in reversed(self.roots_listbox.curselection()):
self.roots_listbox.delete(index)
def _selected_rules(self) -> list[CacheRule]:
return [rule for rule in CACHE_RULES if self.rule_vars[rule.key].get()]
def _parse_limits(self) -> tuple[int, int] | None:
try:
min_size = int(float(self.min_kib_var.get()) * 1024)
max_size = int(float(self.max_mib_var.get()) * 1024 * 1024)
except ValueError:
messagebox.showerror(APP_TITLE, "文件体积范围必须是数字。")
return None
if min_size < 0 or max_size < min_size:
messagebox.showerror(APP_TITLE, "请设置有效的文件体积范围。")
return None
return min_size, max_size
def _start_scan(self) -> None:
if self.scanning:
return
rules = self._selected_rules()
if not rules:
messagebox.showwarning(APP_TITLE, "请至少选择一种缓存类型。")
return
roots = [Path(self.roots_listbox.get(i)) for i in range(self.roots_listbox.size())]
roots = [root for root in roots if root.is_dir()]
if not roots:
messagebox.showwarning(APP_TITLE, "请添加至少一个存在的扫描目录。")
return
limits = self._parse_limits()
if limits is None:
return
self._clear_results()
self.scanning = True
self.scan_button.configure(state=tk.DISABLED)
self.delete_button.configure(state=tk.DISABLED)
self.status_var.set("正在安全扫描:只读取文件名、路径与大小,不会删除任何文件……")
threading.Thread(target=self._scan_worker, args=(roots, rules, *limits), daemon=True).start()
def _scan_worker(self, roots: list[Path], rules: list[CacheRule], min_size: int, max_size: int) -> None:
scanned = matched = skipped = 0
seen_files: set[str] = set()
def on_walk_error(_: OSError) -> None:
nonlocal skipped
skipped += 1
for root in roots:
for current_dir, directories, files in os.walk(root, topdown=True, followlinks=False, onerror=on_walk_error):
# 跳过目录链接,避免绕出用户选择的范围或重复扫描。
directories[:] = [
directory for directory in directories
if not Path(current_dir, directory).is_symlink()
]
for name in files:
scanned += 1
file_path = Path(current_dir, name)
try:
if file_path.is_symlink():
continue
stat = file_path.stat()
except OSError:
skipped += 1
continue
if not min_size <= stat.st_size <= max_size:
continue
matching_rule = next((rule for rule in rules if rule.matches(file_path)), None)
if matching_rule is None:
continue
normalized = os.path.normcase(os.path.normpath(str(file_path)))
if normalized in seen_files:
continue
seen_files.add(normalized)
matched += 1
self.events.put(("item", ScanItem(file_path, stat.st_size, matching_rule.label, matching_rule.description)))
if matched % 50 == 0:
self.events.put(("progress", (scanned, matched)))
self.events.put(("done", (scanned, matched, skipped)))
def _process_events(self) -> None:
try:
while True:
event, payload = self.events.get_nowait()
if event == "item":
item = payload
assert isinstance(item, ScanItem)
item_id = self.tree.insert("", tk.END, values=(item.category, format_size(item.size), str(item.path), item.rule_description))
self.items[item_id] = item
elif event == "progress":
scanned, matched = payload
self.status_var.set(f"正在扫描:已检查 {scanned:,} 个文件,找到 {matched:,} 个候选缓存……")
elif event == "done":
scanned, matched, skipped = payload
self.scanning = False
self.scan_button.configure(state=tk.NORMAL)
self.delete_button.configure(state=tk.NORMAL)
self.summary_var.set(f"找到 {matched:,} 个候选文件,共 {format_size(self._total_size())}")
self._update_selection_summary()
extra = f";跳过 {skipped:,} 个无权读取或已失效项目" if skipped else ""
self.status_var.set(f"扫描完成:检查 {scanned:,} 个文件,匹配 {matched:,} 个候选缓存{extra}。请检查路径后再删除。")
except queue.Empty:
pass
self.after(100, self._process_events)
def _total_size(self, item_ids: tuple[str, ...] | None = None) -> int:
target_ids = item_ids if item_ids is not None else tuple(self.items)
return sum(self.items[item_id].size for item_id in target_ids if item_id in self.items)
def _update_selection_summary(self, _: object | None = None) -> None:
selected_ids = self.tree.selection()
self.selection_var.set(
f"已选 {len(selected_ids):,} 个文件,共 {format_size(self._total_size(selected_ids))}"
)
def _select_all_results(self) -> None:
children = self.tree.get_children()
self.tree.selection_set(children)
self._update_selection_summary()
if children:
self.status_var.set(f"已选中 {len(children):,} 个候选文件,共 {format_size(self._total_size(children))}。")
def _clear_results(self) -> None:
self.tree.delete(*self.tree.get_children())
self.items.clear()
self.summary_var.set("当前没有扫描结果")
self._update_selection_summary()
def _delete_selected(self) -> None:
selected_ids = self.tree.selection()
if not selected_ids:
messagebox.showinfo(APP_TITLE, "请在列表中选择要删除的文件。可使用 Ctrl 或 Shift 多选。")
return
selected = [self.items[item_id] for item_id in selected_ids if item_id in self.items]
if not selected:
return
total_size = sum(item.size for item in selected)
text = (
f"即将删除 {len(selected):,} 个已选文件,合计 {format_size(total_size)}。\n\n"
"程序会先生成删除清单(.txt),随后逐个删除文件。\n"
"删除清单仅记录路径和结果,不能恢复文件内容。\n\n"
"请确认已检查过列表中的完整路径。是否继续?"
)
if not messagebox.askyesno("最终确认删除", text, icon=messagebox.WARNING):
return
manifest_path = self._write_manifest(selected)
deleted = failed = 0
failures: list[str] = []
for item in selected:
try:
# 删除前再次验证:拒绝删除符号链接与目录。
if item.path.is_symlink() or not item.path.is_file():
raise OSError("文件已不存在、不是普通文件,或为链接")
item.path.unlink()
deleted += 1
result = "已删除"
except OSError as error:
failed += 1
result = f"失败:{error}"
failures.append(f"{item.path} — {error}")
self._append_manifest_result(manifest_path, item, result)
for item_id in selected_ids:
if item_id in self.items and not self.items[item_id].path.exists():
self.tree.delete(item_id)
del self.items[item_id]
self.summary_var.set(f"剩余 {len(self.items):,} 个候选文件,共 {format_size(self._total_size())}")
self._update_selection_summary()
self.status_var.set(f"删除结束:成功 {deleted:,} 个,失败 {failed:,} 个。删除清单:{manifest_path}")
detail = "\n".join(failures[:5])
suffix = f"\n\n部分失败原因:\n{detail}" if detail else ""
messagebox.showinfo(APP_TITLE, f"删除结束:成功 {deleted:,} 个,失败 {failed:,} 个。\n\n删除清单:\n{manifest_path}{suffix}")
def _manifest_folder(self) -> Path:
folder = Path(__file__).resolve().parent / MANIFEST_DIR_NAME
folder.mkdir(exist_ok=True)
return folder
def _write_manifest(self, items: list[ScanItem]) -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
manifest_path = self._manifest_folder() / f"删除清单_{timestamp}.txt"
lines = [
"微信 / QQ 缓存安全清理 — 删除前清单",
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"选中文件数量:{len(items)}",
f"选中文件总大小:{format_size(sum(item.size for item in items))}",
"",
"以下项目由用户在界面中手动选中。删除结果会追加在每一项下方。",
"此文件是审计清单,不保存缓存内容,不能用于恢复。",
"=" * 78,
]
for number, item in enumerate(items, 1):
lines.extend((
f"[{number}] 类型:{item.category}",
f"路径:{item.path}",
f"大小:{format_size(item.size)} ({item.size} 字节)",
f"匹配规则:{item.rule_description}",
"删除结果:等待执行",
"-" * 78,
))
manifest_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return manifest_path
@staticmethod
def _append_manifest_result(manifest_path: Path, item: ScanItem, result: str) -> None:
# 追加每项结果,确保删除途中失败/退出时仍保留可追溯记录。
with manifest_path.open("a", encoding="utf-8") as file:
file.write(f"执行结果 | {item.path} | {result}\n")
if __name__ == "__main__":
CleanerApp().mainloop()