吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 735|回复: 31
上一主题 下一主题
收起左侧

[Python 原创] 清理C盘的AppData

[复制链接]
跳转到指定楼层
楼主
ldkj2022 发表于 2026-11-3 12:10 回帖奖励
AppData 空间分析工具
================================================================
扫描 AppData(Local / LocalLow / Roaming) 下各软件数据目录的占用空间,
自动识别缓存、临时文件等"可安全清理"目录,支持一键移入回收站(带进度显示)。

用法
----
图形界面 :  python appdata_analyzer.py                  (启动后不自动扫描,点击"开始分析"才开始)
命令行   :  python appdata_analyzer.py --scan [目录] [--csv 输出.csv]
界面自检 :  python appdata_analyzer.py --gui-test       (隐藏窗口自动关闭,用于验证)

依赖     : 仅 Python 标准库(tkinter / os / ctypes),无需安装第三方包。

upload.jpg (80.71 KB, 下载次数: 1)

upload.jpg

appdata_analyzer.rar

8.49 KB, 下载次数: 124, 下载积分: 吾爱币 -1 CB

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
OKOK258 + 1 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
苏紫方璇 发表于 2026-11-3 13:35
请在帖子中插入部分关键代码
本版块仅限分享编程技术和源码相关内容,发布帖子必须带上关键代码和具体功能介绍
推荐
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

appdata_analyzer.7z

10.33 KB, 下载次数: 27, 下载积分: 吾爱币 -1 CB

3#
scbo 发表于 2026-11-3 13:55
4#
jlf629 发表于 2026-11-3 14:16
不错,还有源代码
5#
superlnts 发表于 2026-11-3 14:20
双击bat文件什么也没有啊,没有软件界面
6#
xu52393356 发表于 2026-11-3 14:30
这个类似于在电脑右键属性里的清理
7#
skyshiyi 发表于 2026-11-3 14:40
win1126300.9711出现这个

2026-11-03_143712.png (30.73 KB, 下载次数: 0)

2026-11-03_143712.png
8#
zmy96 发表于 2026-11-3 14:59
楼主辛苦,谢谢分享。
9#
鑫辰 发表于 2026-11-3 15:10
谢谢分享
10#
wsc1458485922 发表于 2026-11-3 15:25
不错,谢谢分享。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-4 15:03

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表