好友
阅读权限10
听众
最后登录1970-1-1
|
推荐
nyk
发表于 2026-11-3 17:40
我在原来基础上做了一些调整:
为兼容各版本 Windows 做的改动
DPI 适配改为三级降级:Win8.1+ 用 shcore.SetProcessDpiAwareness,Win7/Vista 自动回退到 user32.SetProcessDPIAware,都失败也不影响运行。
去掉了 Python 3.6 才支持的 os.scandir 上下文管理器写法,最低支持到 Python 3.5。
st_file_attributes(判断 junction/符号链接用)改为安全读取,取不到时按普通目录处理。
tkinter 改为软导入:精简版 Python 没装 tkinter 时,--scan 命令行模式仍然可用。
ttk 主题按 vista → winnative → xpnative → clam → default 逐级降级,老系统不会因主题缺失报错。
回收站调用增加 Windows 平台判断;并处理了用户点“取消”的情况,回收站不可用时也会在永久删除前警告。
用 pythonw 启动时没有控制台,崩溃信息会写入 appdata_analyzer_error.log,同时用 MessageBoxW 弹窗提示,不会静默失败。
AppData 路径增加了 USERPROFILE / HOMEDRIVE+HOMEPATH 兜底。
启动脚本的修复 按顺序自动查找:pyw -3(Python 官方启动器)→ pythonw → python,每步都会先验证解释器真的可用(避免微软商店的 python 假别名),全都没有时给出安装提示,不再写死任何安装路径。
然后还打包了exe,方便在没有py环境的机器上无损运行。
# -*- coding: utf-8 -*-
"""
AppData 空间分析工具(Windows 通用版)
================================================================
扫描 AppData(Local / LocalLow / Roaming) 下各软件数据目录的占用空间,
自动识别缓存、临时文件等"可安全清理"目录,支持一键移入回收站(带进度显示)。
兼容性
------
* Windows 7 / 8 / 8.1 / 10 / 11(32 位、64 位均可)
* Python 3.5 及以上,仅使用标准库,无需安装第三方包
* 未安装 tkinter 时命令行模式仍可正常使用
* 高 DPI 适配在 Win8.1+ 用 shcore,Win7/Vista 自动退回 user32
用法
----
图形界面 : python appdata_analyzer.py (或双击 启动AppData分析.bat)
命令行 : python appdata_analyzer.py --scan [目录] [--csv 输出.csv]
界面自检 : python appdata_analyzer.py --gui-test (隐藏窗口自动关闭,用于验证)
"""
import os
import sys
import stat
import csv
import time
import queue
import threading
import argparse
IS_WINDOWS = (os.name == "nt")
if IS_WINDOWS:
import ctypes
from ctypes import wintypes
else: # 仅为让脚本可被导入/静态检查,实际功能仅支持 Windows
ctypes = None
wintypes = None
# tkinter 在部分精简版 Python(如嵌入式发行版)中可能缺失:
# 这里做软导入,缺失时命令行模式依旧可用。
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
HAS_TK = True
except Exception:
tk = None
ttk = None
filedialog = None
messagebox = None
HAS_TK = False
def _default_root():
"""定位当前用户的 AppData 目录(不依赖 HOME 环境变量是否正常)"""
home = os.path.expanduser("~")
if not home or home == "~":
home = os.environ.get("USERPROFILE")
if not home:
drive = os.environ.get("HOMEDRIVE", "C:")
tail = os.environ.get("HOMEPATH", os.sep + "Users" + os.sep + "Default")
home = drive + tail
return os.path.join(home, "AppData")
DEFAULT_ROOT = _default_root()
# 目录名命中以下任一(不区分大小写)即视为缓存/临时类,整棵子树可安全清理
SAFE_DIR_NAMES = {
"cache", "caches", "cachestorage", "cacheddata", "gpu cache", "code cache",
"service worker", "shader cache", "web cache", "dns cache", "temp", "tmp",
"temporary internet files", "inetcache", "cryptneturlcache", "thumbnails",
"thumbnail cache", "logs", "log", "crashdumps", "crash reports",
"report.wer", "wer", "updater temp",
}
# 文件名命中以下规则视为可安全清理的散落缓存文件
def is_safe_file(name):
n = name.lower()
return n.startswith("thumbcache_") or n.startswith("iconcache_") or n.endswith(".tmp")
def is_safe_dir(name):
return name.strip().lower() in SAFE_DIR_NAMES
def fmt_size(b):
"""字节数 -> 人类可读大小"""
try:
b = float(b)
except (TypeError, ValueError):
b = 0.0
if b < 1024:
return "%.0f B" % b
for u in ("KB", "MB", "GB", "TB", "PB"):
b /= 1024.0
if b < 1024:
return "%.1f %s" % (b, u)
return "%.1f PB" % b
def list_dir(path):
"""
跨 Python 版本读取目录列表。
os.scandir() 的上下文管理器支持是 Python 3.6 才有的,
Python 3.5 只能手动 close(),这里统一包一层。
"""
it = os.scandir(path)
try:
return list(it)
finally:
close = getattr(it, "close", None)
if close is not None:
try:
close()
except OSError:
pass
def is_reparse_point(st):
"""
判断是否为 junction / 符号链接(避免循环与重复统计)。
老版本/异常环境下可能没有 st_file_attributes,此时按普通目录处理。
"""
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
def enable_dpi_awareness():
"""让高分屏下界面不模糊;按系统版本逐级降级,全部失败也不影响运行。"""
if not IS_WINDOWS:
return
# Windows 8.1+:shcore 提供 SetProcessDpiAwareness(1=系统 DPI)
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
return
except Exception:
pass
# Windows Vista / 7:user32 的旧接口
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
class ScanStopped(Exception):
"""用户点击停止时抛出"""
def scan_tree(root, stop_event, progress_cb=None, throttle=300):
"""
递归统计一个目录。
返回 (total_bytes, safe_bytes, items, file_count, error_count)
items: [(path, size)] 命中的可清理缓存目录(未排序)
"""
total = 0
safe = 0
files = 0
errors = 0
items = []
counter = [0]
try:
entries = list_dir(root)
except OSError:
return 0, 0, [], 0, 1
for e in entries:
if stop_event.is_set():
raise ScanStopped()
try:
st = e.stat(follow_symlinks=False)
except OSError:
errors += 1
continue
if stat.S_ISDIR(st.st_mode):
# 跳过 junction / 符号链接,避免循环与重复统计(如 Local\Application Data)
if is_reparse_point(st):
continue
if is_safe_dir(e.name):
t, _s, _it, f, err = scan_tree(e.path, stop_event, progress_cb, throttle)
total += t
safe += t # 整棵子树都算可清理
files += f
errors += err
items.append((e.path, t))
continue
t, s, it, f, err = scan_tree(e.path, stop_event, progress_cb, throttle)
total += t
safe += s
files += f
errors += err
if it:
items.extend(it)
else:
try:
size = st.st_size
except OSError:
size = 0
total += size
files += 1
if is_safe_file(e.name):
safe += size
counter[0] += 1
if progress_cb and counter[0] % throttle == 0:
progress_cb(root)
return total, safe, items, files, errors
def scan_appdata(root, stop_event, progress_cb=None):
"""扫描 AppData 两级目录(Local/LocalLow/Roaming → 各软件目录),返回汇总结果 dict"""
t0 = time.time()
tops = []
try:
entries = list_dir(root)
for e in entries:
if stop_event.is_set():
raise ScanStopped()
try:
st = e.stat(follow_symlinks=False)
except OSError:
continue
if stat.S_ISDIR(st.st_mode) and not is_reparse_point(st):
tops.append(e)
except OSError as ex:
raise RuntimeError("无法访问目录:%s\n%s" % (root, ex))
total = 0
safe = 0
files = 0
errors = 0
roots = []
def add_entry(parent, name, path, t, s, items, f, err):
nonlocal total, safe, files, errors
total += t
safe += s
files += f
errors += err
items.sort(key=lambda x: x[1], reverse=True)
if t == 0 and s == 0:
advice = "空目录"
elif s == t and t > 0:
advice = "可删除(缓存)"
elif s > 0:
advice = "可清理缓存"
else:
advice = "不建议删除"
roots.append({"name": name, "parent": parent, "path": path,
"total": t, "safe": s, "advice": advice, "items": items})
for e in tops:
if stop_event.is_set():
raise ScanStopped()
if e.name in ("Local", "LocalLow", "Roaming"):
try:
subs = []
for s2 in list_dir(e.path):
if stop_event.is_set():
raise ScanStopped()
try:
st2 = s2.stat(follow_symlinks=False)
except OSError:
continue
if stat.S_ISDIR(st2.st_mode) and not is_reparse_point(st2):
subs.append(s2)
except OSError:
continue
for s2 in subs:
if stop_event.is_set():
raise ScanStopped()
t, s, items, f, err = scan_tree(s2.path, stop_event, progress_cb)
if is_safe_dir(s2.name) and t > 0:
# 软件目录本身即为缓存/临时目录(如 Local\Temp、Local\uv)→ 整目录可删
s = t
items = [(s2.path, t)]
add_entry(e.name, s2.name, s2.path, t, s, items, f, err)
else:
# 直接位于 AppData 下的非标准顶层目录(如 ModularData),按单个数据目录处理
t, s, items, f, err = scan_tree(e.path, stop_event, progress_cb)
add_entry("其他顶层目录", e.name, e.path, t, s, items, f, err)
return {"total": total, "safe": safe, "files": files, "errors": errors,
"roots": roots, "elapsed": time.time() - t0}
def move_to_recycle_bin(paths):
"""
将路径列表移入回收站(支持撤销),使用 SHFileOperationW。
该 API 从 Windows 95/2000 起一直可用,Win7~Win11 行为一致。
返回 (返回码, 是否被用户取消);返回码 0 且未取消表示全部成功。
"""
if not paths:
return 0, False
if not IS_WINDOWS:
raise OSError("移入回收站功能仅支持 Windows")
src = "\x00".join(str(p) for p in paths) + "\x00\x00"
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_ushort),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
op = SHFILEOPSTRUCTW()
op.hwnd = None
op.wFunc = 3 # FO_DELETE
op.pFrom = src
op.pTo = None
# FOF_ALLOWUNDO(移入回收站) | FOF_NOCONFIRMATION | FOF_NOERRORUI
# | FOF_WANTNUKEWARNING(回收站不可用时,永久删除前仍会警告)
# 不设置 FOF_SILENT:显示 Windows 原生进度对话框(含真实进度条与"取消"按钮)
op.fFlags = 0x40 | 0x10 | 0x400 | 0x4000
op.fAnyOperationsAborted = False
op.hNameMappings = None
op.lpszProgressTitle = None
code = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
return code, bool(op.fAnyOperationsAborted)
def write_csv(path, res, root):
"""导出汇总报告(utf-8-sig,Excel 可直接打开)"""
with open(path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["一级位置", "软件/目录", "占用(B)", "占用", "可清理(B)", "可清理", "建议", "路径"])
for r in sorted(res["roots"], key=lambda x: x["total"], reverse=True):
g = os.path.basename(os.path.dirname(r["path"]))
w.writerow([g, r["name"], r["total"], fmt_size(r["total"]),
r["safe"], fmt_size(r["safe"]), r["advice"], r["path"]])
w.writerow(["合计", "", res["total"], fmt_size(res["total"]),
res["safe"], fmt_size(res["safe"]), "", ""])
class AppDataAnalyzer:
def __init__(self, root_path, auto_start=False):
self.root_path = root_path
self.stop_event = threading.Event()
self.msg_q = queue.Queue()
self.result = None
self.row_info = {}
self._iid_seq = 0
self._recycle_running = False
self.build_ui()
if auto_start:
self.start_scan()
# ---------- 界面 ----------
def build_ui(self):
self.root = tk.Tk()
self.root.title("AppData 空间分析工具")
self.root.geometry("1120x660")
self.root.minsize(860, 480)
style = ttk.Style(self.root)
# 主题逐级降级:vista(Win7+) → winnative → xpnative → clam → 默认
for name in ("vista", "winnative", "xpnative", "clam", "default"):
try:
style.theme_use(name)
break
except tk.TclError:
continue
# 顶部工具栏
bar = ttk.Frame(self.root, padding=(8, 6))
bar.pack(side="top", fill="x")
ttk.Label(bar, text="分析目录").pack(side="left")
self.var_path = tk.StringVar(value=self.root_path)
ent = ttk.Entry(bar, textvariable=self.var_path, width=60)
ent.pack(side="left", padx=6)
ttk.Button(bar, text="浏览…", width=8, command=self.on_browse).pack(side="left")
self.btn_scan = ttk.Button(bar, text="开始分析", width=9, command=self.start_scan)
self.btn_scan.pack(side="left", padx=(12, 4))
self.btn_stop = ttk.Button(bar, text="停止", width=7, command=self.stop_scan, state="disabled")
self.btn_stop.pack(side="left", padx=4)
self.btn_recycle = ttk.Button(bar, text="移入回收站", width=11, command=self.on_recycle)
self.btn_recycle.pack(side="left", padx=4)
ttk.Button(bar, text="打开位置", width=9, command=self.on_open).pack(side="left", padx=4)
ttk.Button(bar, text="导出报告", width=9, command=self.export_csv).pack(side="left", padx=4)
# 结果树
frame = ttk.Frame(self.root)
frame.pack(side="top", fill="both", expand=True, padx=8, pady=(4, 0))
self.tree = ttk.Treeview(frame, columns=("size", "clean", "advice", "path"),
show="tree headings", selectmode="extended")
self.tree.heading("#0", text="软件 / 目录", anchor="w")
self.tree.heading("size", text="占用", anchor="e")
self.tree.heading("clean", text="可清理", anchor="e")
self.tree.heading("advice", text="建议", anchor="w")
self.tree.heading("path", text="路径", anchor="w")
self.tree.column("#0", width=260, minwidth=140, stretch=True)
self.tree.column("size", width=95, minwidth=70, anchor="e", stretch=False)
self.tree.column("clean", width=95, minwidth=70, anchor="e", stretch=False)
self.tree.column("advice", width=150, minwidth=100, anchor="w", stretch=False)
self.tree.column("path", width=520, minwidth=200, anchor="w", stretch=False)
self.tree.tag_configure("safe", foreground="#1a7f37")
self.tree.tag_configure("partial", foreground="#b45309")
self.tree.tag_configure("keep", foreground="#111111")
vsb = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
self.tree.bind("<Double-1>", lambda e: self.on_open())
self.tree.bind("<Button-3>", self.on_right_click)
# 右键菜单
self.menu = tk.Menu(self.root, tearoff=0)
self.menu.add_command(label="打开位置", command=self.on_open)
self.menu.add_command(label="复制路径", command=self.on_copy_path)
self.menu.add_separator()
self.menu.add_command(label="移入回收站", command=self.on_recycle)
# 底部状态栏 + 进度条
status = ttk.Frame(self.root, padding=(8, 4))
status.pack(side="bottom", fill="x")
self.lbl_status = ttk.Label(status, text="就绪。默认扫描 %s" % self.root_path)
self.lbl_status.pack(side="left")
self.progressbar = ttk.Progressbar(status, mode="indeterminate", length=180)
self.progressbar.pack(side="right", padx=8)
self.root.after(100, self._poll)
# ---------- 扫描线程 ----------
def start_scan(self):
path = self.var_path.get().strip() or DEFAULT_ROOT
if not os.path.isdir(path):
messagebox.showwarning("路径无效", "目录不存在:\n%s" % path)
return
self.root_path = path
self.var_path.set(path)
self.stop_event.clear()
self.tree.delete(*self.tree.get_children())
self.row_info.clear()
self.result = None
self.btn_scan.config(state="disabled")
self.btn_stop.config(state="normal")
self.lbl_status.config(text="正在扫描…(目录越大耗时越长,可随时停止)")
threading.Thread(target=self._worker, daemon=True).start()
def stop_scan(self):
self.stop_event.set()
self.btn_stop.config(state="disabled")
self.lbl_status.config(text="正在停止…")
def _worker(self):
try:
res = scan_appdata(self.root_path, self.stop_event,
progress_cb=lambda p: self.msg_q.put(("progress", p)))
res["aborted"] = False
except ScanStopped:
res = {"aborted": True}
except Exception as ex: # 兜底,避免线程静默死亡
res = {"aborted": True, "error": str(ex)}
self.msg_q.put(("done", res))
def _poll(self):
try:
while True:
kind, payload = self.msg_q.get_nowait()
if kind == "progress":
p = payload
if len(p) > 110:
p = "…" + p[-110:]
self.lbl_status.config(text="正在统计:" + p)
self.progressbar.start(8)
elif kind == "done":
self.progressbar.stop()
self.btn_scan.config(state="normal")
self.btn_stop.config(state="disabled")
self._on_done(payload)
elif kind == "recycle_done":
self._on_recycle_done(payload)
except queue.Empty:
pass
self.root.after(100, self._poll)
def _on_done(self, payload):
if payload.get("aborted"):
self.lbl_status.config(text="已停止扫描" + ("(" + payload["error"] + ")" if payload.get("error") else ""))
return
self.result = payload
self._fill_tree(payload)
t = payload["total"]
s = payload["safe"]
pct = 100.0 * s / t if t else 0.0
self.lbl_status.config(
text="总占用 %s | 可安全清理 %s(%.1f%%)| 文件数 %s | 耗时 %.1fs | 跳过 %d 处"
% (fmt_size(t), fmt_size(s), pct, format(payload["files"], ","),
payload["elapsed"], payload["errors"]))
def _add_row(self, parent, name, size, clean, advice, path, kind):
self._iid_seq += 1
iid = "r%d" % self._iid_seq
self.row_info[iid] = {"path": path, "advice": advice, "kind": kind, "size": size}
if kind == "cache":
tag = "safe"
elif advice == "可删除(缓存)":
tag = "safe"
elif advice == "可清理缓存":
tag = "partial"
else:
tag = "keep"
self.tree.insert(parent, "end", iid=iid, text=name,
values=(fmt_size(size), fmt_size(clean), advice, path), tags=(tag,))
return iid
def _fill_tree(self, res):
self.tree.delete(*self.tree.get_children())
self.row_info.clear()
groups = {}
for r in res["roots"]:
groups.setdefault(r["parent"], []).append(r)
# Local / LocalLow / Roaming 优先,其他顶层目录排最后
ordered = sorted(groups.keys(),
key=lambda g: 0 if g in ("Local", "LocalLow", "Roaming") else 1)
for g in ordered:
rows = groups[g]
gtotal = sum(x["total"] for x in rows)
gsafe = sum(x["safe"] for x in rows)
gpath = os.path.join(self.root_path, g) if g != "其他顶层目录" else self.root_path
gid = self._add_row("", g, gtotal, gsafe,
"%d 个软件目录" % len(rows), gpath, "group")
for r in sorted(rows, key=lambda x: x["total"], reverse=True):
aid = self._add_row(gid, r["name"], r["total"], r["safe"], r["advice"], r["path"], "app")
for p, s in r["items"][:30]:
self._add_row(aid, os.path.basename(p), s, s, "可删除(缓存)", p, "cache")
# ---------- 操作 ----------
def on_browse(self):
d = filedialog.askdirectory(title="选择要分析的目录", initialdir=self.root_path)
if d:
self.var_path.set(d)
def _selected_paths(self):
out = []
for iid in self.tree.selection():
info = self.row_info.get(iid)
if info and info["path"]:
out.append(info)
return out
def on_open(self):
if not hasattr(os, "startfile"):
messagebox.showwarning("无法打开", "当前系统不支持直接打开目录")
return
for info in self._selected_paths():
try:
os.startfile(info["path"])
except OSError as ex:
messagebox.showwarning("无法打开", str(ex))
def on_copy_path(self):
sel = self.tree.selection()
if not sel:
return
texts = [self.row_info[i]["path"] for i in sel if i in self.row_info]
if texts:
self.root.clipboard_clear()
self.root.clipboard_append("\n".join(texts))
def on_recycle(self):
if self._recycle_running:
return
infos = self._selected_paths()
if not infos:
messagebox.showinfo("提示", "请先在列表中选中要清理的目录(可多选)")
return
paths = []
total = 0
risky = False
for info in infos:
paths.append(info["path"])
total += info.get("size", 0)
if info["kind"] != "cache":
risky = True
msg = "将把选中的 %d 项(共 %s)移入回收站(可恢复)。\n\n" % (len(paths), fmt_size(total))
if risky:
msg += "注意:其中包含软件数据目录(非纯缓存),删除后可能导致相关软件异常、需要重新登录或重新配置!\n\n"
msg += "是否继续?"
if not messagebox.askyesno("移入回收站", msg):
return
# 后台线程执行,避免界面假死;状态栏进度条 + Windows 原生进度窗口(可查看进度/取消)
sel = list(self.tree.selection()) # 记录本次选中的行,成功后直接从列表删除
self._recycle_running = True
self.btn_recycle.config(state="disabled")
self.btn_scan.config(state="disabled")
self.btn_stop.config(state="disabled")
self.progressbar.start(10)
self.lbl_status.config(
text="正在移入回收站:%d 项,共 %s …(可在 Windows 进度窗口查看进度或取消)"
% (len(paths), fmt_size(total)))
threading.Thread(target=self._recycle_worker, args=(paths, sel), daemon=True).start()
def _recycle_worker(self, paths, sel):
"""后台执行移入回收站,完成后把结果放回消息队列"""
try:
ret, aborted = move_to_recycle_bin(paths)
except Exception as ex:
ret, aborted = ex, False
self.msg_q.put(("recycle_done", (ret, aborted, sel)))
def _delete_rows(self, sel):
"""从列表删除选中行;父行选中时子行随父级联删除,并同步清理 row_info"""
tops = []
for iid in sel:
pid = self.tree.parent(iid)
while pid and pid not in sel: # 存在选中祖先则跳过,交由祖先级联删除
pid = self.tree.parent(pid)
if not pid:
tops.append(iid)
for iid in tops:
try:
stack = [iid]
while stack:
cur = stack.pop()
self.row_info.pop(cur, None)
stack.extend(self.tree.get_children(cur))
self.tree.delete(iid)
except tk.TclError:
pass
def _on_recycle_done(self, payload):
ret, aborted, sel = payload
self._recycle_running = False
self.progressbar.stop()
self.btn_recycle.config(state="normal")
self.btn_scan.config(state="normal")
self.btn_stop.config(state="disabled")
if isinstance(ret, Exception):
self.lbl_status.config(text="移入回收站失败:%s" % ret)
messagebox.showerror("移入回收站失败", str(ret))
return
if aborted:
self.lbl_status.config(text="已取消清理;部分项目可能已被移入回收站,建议重新分析")
messagebox.showinfo("已取消",
"操作已取消。\n已删除的项目仍在回收站中,可随时还原;建议重新分析以刷新列表。")
return
if ret != 0:
self.lbl_status.config(
text="回收站操作返回码 %d,存在未完成项(文件被占用或权限不足),列表未改动" % ret)
messagebox.showwarning("有未完成项",
"回收站操作返回码 %d (0x%x)。\n可能是文件正被占用或权限不足,未能处理的部分仍在原位置,列表保留待核对。"
% (ret, ret))
return
self._delete_rows(sel)
self.lbl_status.config(text="已移入回收站,列表已同步更新(%d 项)" % len(sel))
messagebox.showinfo("完成", "已移入回收站,可随时从回收站还原。\n列表已同步更新。")
def export_csv(self):
if not self.result:
messagebox.showinfo("提示", "请先完成一次分析再导出")
return
path = filedialog.asksaveasfilename(
title="导出报告", defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
initialfile="AppData占用分析.csv")
if not path:
return
try:
write_csv(path, self.result, self.root_path)
except OSError as ex:
messagebox.showerror("导出失败", str(ex))
return
messagebox.showinfo("完成", "已导出:\n%s" % path)
def on_right_click(self, event):
iid = self.tree.identify_row(event.y)
if iid:
if iid not in self.tree.selection():
self.tree.selection_set(iid)
self.menu.tk_popup(event.x_root, event.y_root)
# ---------- 命令行模式 ----------
def setup_console():
"""Windows 控制台编码兼容(重定向/旧版 cmd 下不报错)"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def cli():
ap = argparse.ArgumentParser(description="AppData 空间分析(命令行)")
ap.add_argument("--scan", nargs="?", const=DEFAULT_ROOT, default=None,
help="扫描目录(默认 %s)" % DEFAULT_ROOT)
ap.add_argument("--csv", default=None, help="导出 CSV 报告路径")
args = ap.parse_args()
target = args.scan or DEFAULT_ROOT
if not os.path.isdir(target):
print("目录不存在:%s" % target)
return 1
stop = threading.Event()
last = [""]
def cb(p):
if p != last[0]:
last[0] = p
tail = p[-90:]
sys.stdout.write("\r扫描中:%s" % tail + " " * 12)
sys.stdout.flush()
setup_console()
print("开始扫描:%s" % target)
res = scan_appdata(target, stop, progress_cb=cb)
sys.stdout.write("\r" + " " * 100 + "\r")
t, s = res["total"], res["safe"]
pct = 100.0 * s / t if t else 0.0
print("总占用:%s 可安全清理:%s(%.1f%%) 文件数:%s 耗时:%.1fs 错误跳过:%d"
% (fmt_size(t), fmt_size(s), pct, format(res["files"], ","), res["elapsed"], res["errors"]))
print()
print("%-8s %-26s %12s %12s 建议" % ("位置", "软件/目录", "占用", "可清理"))
print("-" * 88)
for r in sorted(res["roots"], key=lambda x: x["total"], reverse=True):
g = os.path.basename(os.path.dirname(r["path"]))
name = r["name"] if len(r["name"]) <= 26 else r["name"][:25] + "…"
print("%-8s %-26s %12s %12s %s"
% (g, name, fmt_size(r["total"]), fmt_size(r["safe"]), r["advice"]))
print("-" * 88)
if args.csv:
write_csv(args.csv, res, target)
print("已导出报告:%s" % args.csv)
return 0
# ---------- 启动与异常兜底 ----------
def report_fatal(exc_text):
"""GUI 模式下(pythonw 无控制台)也把错误暴露给用户,并落盘日志。"""
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"appdata_analyzer_error.log")
try:
with open(log_path, "w", encoding="utf-8") as f:
f.write(exc_text)
except Exception:
pass
msg = "%s\n\n详细信息已写入:\n%s" % (exc_text.strip(), log_path)
shown = False
if HAS_TK:
try:
r = tk.Tk()
r.withdraw()
messagebox.showerror("AppData 分析工具 - 启动失败", msg)
r.destroy()
shown = True
except Exception:
pass
if not shown and IS_WINDOWS:
try:
ctypes.windll.user32.MessageBoxW(None, msg, "AppData 分析工具 - 启动失败", 0x10)
except Exception:
pass
if not shown:
print(msg)
def main():
if sys.version_info < (3, 5):
print("需要 Python 3.5 或更高版本,当前版本:%s" % sys.version.split()[0])
return 1
# 命令行/帮助:即使没有 tkinter 也能用
if ("--scan" in sys.argv or "--csv" in sys.argv
or "--help" in sys.argv or "-h" in sys.argv):
return cli()
if not HAS_TK:
print("未检测到 tkinter,无法启动图形界面。\n"
"可改用命令行模式:python appdata_analyzer.py --scan")
return 1
enable_dpi_awareness()
if "--gui-test" in sys.argv:
app = AppDataAnalyzer(DEFAULT_ROOT, auto_start=False)
app.root.withdraw()
app.root.after(1500, app.root.destroy)
app.root.mainloop()
print("GUI 自检通过")
return 0
try:
app = AppDataAnalyzer(DEFAULT_ROOT)
app.root.mainloop()
except Exception:
import traceback
report_fatal(traceback.format_exc())
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
启动脚本也做了一些优化:
@echo off
setlocal EnableExtensions
chcp 65001 >nul 2>nul
cd /d "%~dp0"
set "SCRIPT=appdata_analyzer.py"
if not exist "%SCRIPT%" (
echo [ERROR] "%SCRIPT%" not found in this folder.
pause
exit /b 1
)
rem 1) python.org launcher (py.exe / pyw.exe) - works with any installed 3.x
where pyw >nul 2>nul
if not errorlevel 1 (
pyw -3 -c "import sys" >nul 2>nul
if not errorlevel 1 (
start "" pyw -3 "%SCRIPT%"
exit /b 0
)
)
rem 2) pythonw.exe on PATH
where pythonw >nul 2>nul
if not errorlevel 1 (
pythonw -c "import sys" >nul 2>nul
if not errorlevel 1 (
start "" pythonw "%SCRIPT%"
exit /b 0
)
)
rem 3) python.exe on PATH (console stays open so errors are visible)
where python >nul 2>nul
if not errorlevel 1 (
python -c "import sys" >nul 2>nul
if not errorlevel 1 (
python "%SCRIPT%"
exit /b %errorlevel%
)
)
echo.
echo [ERROR] Python 3 not found on this computer.
echo Install Python 3 from https://www.python.org/downloads/windows/
echo Remember to check "Add Python to PATH" during installation.
echo.
pause
exit /b 1
文件都打包在7z里了。但是exe7.8M,超限了。不知道怎么发,我发我网盘共享,没问题把:
链接:https://pan.quark.cn/s/9c51387f8d61?pwd=aPCu
提取码:aPCu |
|