deepclean-mydisk 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/assets/icon-180.png +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/plugin/DETAIL.en.md +58 -0
- package/docs/plugin/DETAIL.zh.md +58 -0
- package/docs/plugin/shot-clean.jpg +0 -0
- package/docs/plugin/shot-home.jpg +0 -0
- package/docs/plugin/shot-move.jpg +0 -0
- package/docs/plugin/shot-space.jpg +0 -0
- package/lib/clean.js +537 -0
- package/lib/client.js +2625 -0
- package/lib/index.js +1944 -0
- package/lib/platform.js +29 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +67 -4
- package/resources/LICENSE-phosphor.txt +21 -0
- package/resources/images/disk.png +0 -0
- package/resources/rules.json +139 -0
- package/resources/scripts/deepclean.py +109 -0
- package/resources/scripts/photos-windows.ps1 +77 -0
- package/resources/scripts/photos.py +268 -0
- package/resources/scripts/platforms/windows.py +940 -0
- package/resources/scripts/scan.py +949 -0
- package/resources/scripts/tree.py +218 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Windows 后端:只读磁盘体检(deepclean-mydisk v0.4.0)。
|
|
3
|
+
|
|
4
|
+
硬约束:只读。脚本不删除、不移动、不修改任何用户文件;
|
|
5
|
+
删除建议只生成 PowerShell 命令(移到回收站/隔离区),由用户确认后执行。
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
VERSION = "0.5.0"
|
|
20
|
+
PLATFORM = "windows"
|
|
21
|
+
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
22
|
+
SCAN_BUDGET_SEC = 120
|
|
23
|
+
|
|
24
|
+
# 这些子目录属于系统/运行时/已安装程序,不当作「卸载残留」候选
|
|
25
|
+
SYSTEM_LOCAL = {
|
|
26
|
+
"microsoft", "packages", "programs", "temp", "connecteddevicesplatform",
|
|
27
|
+
"publishers", "comms", "d3dscache", "crashdumps", "google", "mozilla",
|
|
28
|
+
"npm-cache", "pnpm", "pip", "yarn", "electron", "electron-builder",
|
|
29
|
+
"deepclean-mydisk", "virtualstore", "application data",
|
|
30
|
+
}
|
|
31
|
+
SYSTEM_ROAMING = {
|
|
32
|
+
"microsoft", "windows", "adobe", "google", "mozilla", "tencent",
|
|
33
|
+
"apple computer", "npm", "npm-cache",
|
|
34
|
+
}
|
|
35
|
+
SYSTEM_PROGRAMDATA = {
|
|
36
|
+
"microsoft", "windows", "packages", "microsoft windows", "microsoft office",
|
|
37
|
+
"apple", "google", "mozilla", "tencent", "deepclean-mydisk",
|
|
38
|
+
"chocolatey", "chocolateyhttpcache", "runner", "kind", "scoop",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# 卸载残留的 bucket 判定(目录名小写子串),与 macOS classify() 同口径:
|
|
42
|
+
# 整容器一律 confirm(只读);safe 只来自 cache_subdirs() 拆出的缓存子目录,
|
|
43
|
+
# 不放宽「需确认不进允许集合」的硬约束。
|
|
44
|
+
LEFTOVER_MEDIA_HINTS = (
|
|
45
|
+
"tenvideo", "iqiyi", "qiyi", "youku", "bilibili", "qqlive", "mangotv",
|
|
46
|
+
"neteasecloudmusic", "kugou", "kuwo", "qqmusic", "spotify", "douyin", "tiktok",
|
|
47
|
+
)
|
|
48
|
+
LEFTOVER_MEETING_HINTS = (
|
|
49
|
+
"zoom", "teams", "webex", "voov", "wemeet", "tencentmeeting",
|
|
50
|
+
)
|
|
51
|
+
def classify_leftover(name, kb):
|
|
52
|
+
"""整容器「需确认」;缓存子目录由 apply_leftover_cache_split 单独列为可清理。"""
|
|
53
|
+
low = name.lower()
|
|
54
|
+
if any(h in low for h in LEFTOVER_MEDIA_HINTS):
|
|
55
|
+
return "confirm", "媒体类应用已卸载;其中缓存子目录会单独列出(可清理),其余可能含已下载内容"
|
|
56
|
+
if any(h in low for h in LEFTOVER_MEETING_HINTS):
|
|
57
|
+
return "confirm", "会议软件已卸载;其中缓存子目录会单独列出(可清理),录制/文档等需确认"
|
|
58
|
+
return "confirm", "未匹配到已安装程序;可能含用户数据"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# 聊天类容器不做缓存拆分(交给专门的聊天清理/只读展示)。
|
|
62
|
+
CHAT_DIR_HINTS = ("wechat", "weixin", "wework", "qq", "telegram", "discord", "slack",
|
|
63
|
+
"feishu", "lark", "dingtalk", "whatsapp", "line")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_chat_dir(name):
|
|
67
|
+
low = name.lower()
|
|
68
|
+
return any(hint in low for hint in CHAT_DIR_HINTS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def proc_matches_dir(name, procs):
|
|
72
|
+
"""目录名与 tasklist 进程名(去掉 .exe)做归一化包含匹配;取不到进程快照时按「未知」处理。"""
|
|
73
|
+
norm = _norm_name(name).replace(" ", "")
|
|
74
|
+
if not norm or not procs:
|
|
75
|
+
return False
|
|
76
|
+
for exe in procs:
|
|
77
|
+
stem = exe[:-4] if exe.endswith(".exe") else exe
|
|
78
|
+
stem = _norm_name(stem).replace(" ", "")
|
|
79
|
+
if stem and (stem in norm or norm in stem):
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def should_split_leftover(name, apps, procs):
|
|
85
|
+
"""只对「未安装、未运行、非聊天类」的 confirm 残留拆缓存子目录。
|
|
86
|
+
|
|
87
|
+
installed_apps() 取不到(空)时 fail-closed:宁可不拆,也不把在用应用数据默认勾选。
|
|
88
|
+
"""
|
|
89
|
+
if not apps:
|
|
90
|
+
return False
|
|
91
|
+
if is_chat_dir(name):
|
|
92
|
+
return False
|
|
93
|
+
if proc_matches_dir(name, procs):
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
CACHE_DIR_NAMES = {
|
|
99
|
+
"cache", "caches", "cache2", "gpucache", "code cache", "cachestorage",
|
|
100
|
+
"service worker", "crashpad", "tmp", "temp",
|
|
101
|
+
}
|
|
102
|
+
CACHE_SKIP_TOP = {"documents", "desktop", "downloads", "pictures", "videos", "music"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cache_subdirs(base_dir, max_depth=3, deadline=None):
|
|
106
|
+
"""已卸载残留目录里的可再生缓存子目录(不跟随重解析点、限制深度与时间预算)。"""
|
|
107
|
+
base = os.path.abspath(str(base_dir))
|
|
108
|
+
if not os.path.isdir(base) or is_reparse(base):
|
|
109
|
+
return []
|
|
110
|
+
hits = []
|
|
111
|
+
for root, dirs, _files in os.walk(base):
|
|
112
|
+
if deadline and time.time() > deadline:
|
|
113
|
+
break
|
|
114
|
+
rel = os.path.relpath(root, base)
|
|
115
|
+
if rel != "." and rel.split(os.sep)[0].lower() in CACHE_SKIP_TOP:
|
|
116
|
+
dirs[:] = []
|
|
117
|
+
continue
|
|
118
|
+
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
|
119
|
+
if depth > max_depth:
|
|
120
|
+
dirs[:] = []
|
|
121
|
+
continue
|
|
122
|
+
dirs[:] = [d for d in dirs if not is_reparse(os.path.join(root, d))]
|
|
123
|
+
# 目录本身不能算自己的缓存子目录(%LOCALAPPDATA%\Cache 这类名字会自命中)。
|
|
124
|
+
if os.path.abspath(root) != base and os.path.basename(root).lower() in CACHE_DIR_NAMES:
|
|
125
|
+
hits.append(root)
|
|
126
|
+
dirs[:] = []
|
|
127
|
+
return hits
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _strict_descendant(path, parents):
|
|
131
|
+
try:
|
|
132
|
+
real = os.path.normcase(os.path.realpath(str(path)))
|
|
133
|
+
except OSError:
|
|
134
|
+
return False
|
|
135
|
+
for parent in parents or []:
|
|
136
|
+
try:
|
|
137
|
+
base = os.path.normcase(os.path.realpath(str(parent)))
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
if real != base and real.startswith(base + os.sep):
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def apply_leftover_cache_split(groups, splits):
|
|
146
|
+
"""把 confirm 残留组里的缓存子目录拆成独立 safe 组(groups 原地修改)。
|
|
147
|
+
|
|
148
|
+
splits: {残留路径: {"kb": 千字节, "paths": [缓存子目录]}},只含未安装 App 的缓存子目录。
|
|
149
|
+
拆出的组带 cachePart=True,客户端据此显示「App · 缓存」;原组保留其余数据并保持 confirm。
|
|
150
|
+
"""
|
|
151
|
+
by_path = {}
|
|
152
|
+
for group in groups:
|
|
153
|
+
paths = group.get("paths") or []
|
|
154
|
+
if paths:
|
|
155
|
+
by_path[paths[0]] = group
|
|
156
|
+
for path, split in list(splits.items()):
|
|
157
|
+
group = by_path.get(path)
|
|
158
|
+
if group is None or group.get("bucket") != "confirm":
|
|
159
|
+
continue
|
|
160
|
+
cache_kb = int(split.get("kb") or 0)
|
|
161
|
+
if cache_kb < 1024:
|
|
162
|
+
continue
|
|
163
|
+
raw_paths = list(split.get("paths") or [])
|
|
164
|
+
paths = [p for p in raw_paths if _strict_descendant(p, group.get("paths"))]
|
|
165
|
+
if not paths or len(paths) != len(raw_paths):
|
|
166
|
+
continue
|
|
167
|
+
groups.append({
|
|
168
|
+
"baseId": f'{group["baseId"]}.cache',
|
|
169
|
+
"name": group["name"],
|
|
170
|
+
"cachePart": True,
|
|
171
|
+
"kb": cache_kb,
|
|
172
|
+
"count": len(split.get("paths") or []),
|
|
173
|
+
"bucket": "safe",
|
|
174
|
+
"why": "已卸载 App 的缓存子目录,可重建;文档/聊天等数据仍保留",
|
|
175
|
+
"paths": paths,
|
|
176
|
+
"bundleIds": [],
|
|
177
|
+
})
|
|
178
|
+
group["kb"] = max(0, group["kb"] - cache_kb)
|
|
179
|
+
group["why"] = f'{group["why"]};其中缓存子目录已单独列出(可清理)'
|
|
180
|
+
groups[:] = [g for g in groups if int(g.get("kb") or 0) > 0]
|
|
181
|
+
return groups
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _stdout_utf8():
|
|
185
|
+
try:
|
|
186
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
187
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def env_path(name):
|
|
193
|
+
v = os.environ.get(name)
|
|
194
|
+
return Path(v) if v else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def human(kb):
|
|
198
|
+
if kb is None:
|
|
199
|
+
return "?"
|
|
200
|
+
n = float(kb) * 1024
|
|
201
|
+
for unit in ("B", "K", "M", "G", "T"):
|
|
202
|
+
if n < 1024 or unit == "T":
|
|
203
|
+
return f"{n:.0f}{unit}" if unit in ("B", "K") else f"{n:.1f}{unit}"
|
|
204
|
+
n /= 1024
|
|
205
|
+
return f"{n:.1f}T"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# 文件名/路径可能带换行或控制字符;先单行化再放进 Markdown 表格,
|
|
209
|
+
# 避免伪造额外表格行或截断渲染(只影响展示,不影响扫描/分档)。
|
|
210
|
+
_CELL_CTRL = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060\ufeff]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def one_line_cell(value, max_len=200):
|
|
214
|
+
s = "" if value is None else str(value)
|
|
215
|
+
s = _CELL_CTRL.sub(" ", s)
|
|
216
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
217
|
+
if max_len and len(s) > max_len:
|
|
218
|
+
s = s[: max_len - 1] + "…"
|
|
219
|
+
return s
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_admin():
|
|
223
|
+
try:
|
|
224
|
+
import ctypes
|
|
225
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
226
|
+
except Exception:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_reparse(path):
|
|
231
|
+
try:
|
|
232
|
+
st = os.lstat(path)
|
|
233
|
+
except OSError:
|
|
234
|
+
return False
|
|
235
|
+
if getattr(st, "st_file_attributes", 0) & FILE_ATTRIBUTE_REPARSE_POINT:
|
|
236
|
+
return True
|
|
237
|
+
try:
|
|
238
|
+
return bool(getattr(os.path, "isjunction", lambda p: False)(path))
|
|
239
|
+
except Exception:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def size_of(path, deadline=None):
|
|
244
|
+
"""递归统计逻辑字节数;跳过 junction/符号链接,带时间预算。"""
|
|
245
|
+
total = 0
|
|
246
|
+
stack = [str(path)]
|
|
247
|
+
truncated = False
|
|
248
|
+
while stack:
|
|
249
|
+
if deadline and time.time() > deadline:
|
|
250
|
+
truncated = True
|
|
251
|
+
break
|
|
252
|
+
cur = stack.pop()
|
|
253
|
+
try:
|
|
254
|
+
entries = os.scandir(cur)
|
|
255
|
+
except OSError:
|
|
256
|
+
continue
|
|
257
|
+
with entries:
|
|
258
|
+
for e in entries:
|
|
259
|
+
try:
|
|
260
|
+
if is_reparse(e.path):
|
|
261
|
+
continue
|
|
262
|
+
if e.is_dir(follow_symlinks=False):
|
|
263
|
+
stack.append(e.path)
|
|
264
|
+
elif e.is_file(follow_symlinks=False):
|
|
265
|
+
total += e.stat(follow_symlinks=False).st_size
|
|
266
|
+
except OSError:
|
|
267
|
+
continue
|
|
268
|
+
return total, truncated
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _reg_value(key, name):
|
|
272
|
+
try:
|
|
273
|
+
import winreg
|
|
274
|
+
return winreg.QueryValueEx(key, name)[0]
|
|
275
|
+
except Exception:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def installed_apps():
|
|
280
|
+
"""从注册表 Uninstall 键读取已安装程序(HKLM/HKCU,含 WOW6432Node)。"""
|
|
281
|
+
try:
|
|
282
|
+
import winreg
|
|
283
|
+
except Exception:
|
|
284
|
+
return {}
|
|
285
|
+
roots = [
|
|
286
|
+
(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Uninstall"),
|
|
287
|
+
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
|
|
288
|
+
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
|
|
289
|
+
]
|
|
290
|
+
apps = {}
|
|
291
|
+
for hive, sub in roots:
|
|
292
|
+
try:
|
|
293
|
+
root = winreg.OpenKey(hive, sub)
|
|
294
|
+
except OSError:
|
|
295
|
+
continue
|
|
296
|
+
i = 0
|
|
297
|
+
while True:
|
|
298
|
+
try:
|
|
299
|
+
name = winreg.EnumKey(root, i)
|
|
300
|
+
except OSError:
|
|
301
|
+
break
|
|
302
|
+
i += 1
|
|
303
|
+
try:
|
|
304
|
+
key = winreg.OpenKey(root, name)
|
|
305
|
+
except OSError:
|
|
306
|
+
continue
|
|
307
|
+
display = _reg_value(key, "DisplayName")
|
|
308
|
+
if display:
|
|
309
|
+
apps[display] = {
|
|
310
|
+
"display": display,
|
|
311
|
+
"installLocation": _reg_value(key, "InstallLocation"),
|
|
312
|
+
"publisher": _reg_value(key, "Publisher"),
|
|
313
|
+
}
|
|
314
|
+
try:
|
|
315
|
+
winreg.CloseKey(key)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
try:
|
|
319
|
+
winreg.CloseKey(root)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
return apps
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
326
|
+
_PAREN_RE = re.compile(r"[\((\[【].*?[\))\]】]")
|
|
327
|
+
_VERSION_RE = re.compile(r"\bv?\d+(?:\.\d+)+")
|
|
328
|
+
_YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
|
|
329
|
+
_NON_WORD_RE = re.compile(r"[^0-9a-z\u4e00-\u9fff]+")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _norm_name(text):
|
|
333
|
+
"""归一化应用/目录名:去括号说明、版本号、年份、空白与标点。
|
|
334
|
+
|
|
335
|
+
「爱奇艺 12.0 (x64)」「KuGou 2023」这类 DisplayName 与目录名能落到同一形态上比较。
|
|
336
|
+
"""
|
|
337
|
+
if not text:
|
|
338
|
+
return ""
|
|
339
|
+
s = str(text).lower()
|
|
340
|
+
s = _PAREN_RE.sub(" ", s)
|
|
341
|
+
s = _VERSION_RE.sub(" ", s)
|
|
342
|
+
s = _YEAR_RE.sub(" ", s)
|
|
343
|
+
s = _NON_WORD_RE.sub(" ", s)
|
|
344
|
+
return " ".join(s.split())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _tokens(text):
|
|
348
|
+
"""可比较的 token:CJK 片段保留 2 字(中文应用名常为 2–4 字),拉丁片段至少 3 字。"""
|
|
349
|
+
norm = _norm_name(text)
|
|
350
|
+
if not norm:
|
|
351
|
+
return set()
|
|
352
|
+
parts = [p for p in norm.split()
|
|
353
|
+
if len(p) >= 2 and (_CJK_RE.search(p) or len(p) >= 3)]
|
|
354
|
+
out = set(parts)
|
|
355
|
+
if parts:
|
|
356
|
+
out.add("".join(parts))
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def installed_tokens(apps):
|
|
361
|
+
tokens = set()
|
|
362
|
+
for info in apps.values():
|
|
363
|
+
tokens |= _tokens(info.get("display"))
|
|
364
|
+
loc = info.get("installLocation")
|
|
365
|
+
if loc:
|
|
366
|
+
base = Path(str(loc)).name
|
|
367
|
+
tokens |= _tokens(base)
|
|
368
|
+
norm = _norm_name(base)
|
|
369
|
+
if norm:
|
|
370
|
+
tokens.add(norm)
|
|
371
|
+
return tokens
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def dir_name_matches_installed(name, tokens):
|
|
375
|
+
"""目录名是否像某个已安装程序(宁多判已安装,不可把在用数据当残留)。
|
|
376
|
+
|
|
377
|
+
依次尝试:归一化全名相等 → token 交集 → DisplayName/目录名互相包含。
|
|
378
|
+
"""
|
|
379
|
+
norm = _norm_name(name)
|
|
380
|
+
if not norm:
|
|
381
|
+
return False
|
|
382
|
+
if norm in tokens:
|
|
383
|
+
return True
|
|
384
|
+
if _tokens(name) & tokens:
|
|
385
|
+
return True
|
|
386
|
+
short_ok = _CJK_RE.search(norm) and len(norm) >= 2 or len(norm) >= 3
|
|
387
|
+
if not short_ok:
|
|
388
|
+
return False
|
|
389
|
+
for token in tokens:
|
|
390
|
+
if not token:
|
|
391
|
+
continue
|
|
392
|
+
if len(token) < 2:
|
|
393
|
+
continue
|
|
394
|
+
if token in norm or norm in token:
|
|
395
|
+
return True
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _child_dirs(path):
|
|
400
|
+
out = []
|
|
401
|
+
try:
|
|
402
|
+
with os.scandir(path) as it:
|
|
403
|
+
for e in it:
|
|
404
|
+
try:
|
|
405
|
+
if e.is_dir(follow_symlinks=False) and not is_reparse(e.path):
|
|
406
|
+
out.append(Path(e.path))
|
|
407
|
+
except OSError:
|
|
408
|
+
continue
|
|
409
|
+
except OSError:
|
|
410
|
+
pass
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def cache_specs():
|
|
415
|
+
local = env_path("LOCALAPPDATA")
|
|
416
|
+
temp = env_path("TEMP") or env_path("TMP")
|
|
417
|
+
roaming = env_path("APPDATA")
|
|
418
|
+
specs = []
|
|
419
|
+
def add(p, label, safe=True, system=False, admin=False, cleanable=True, advice="缓存,可重建"):
|
|
420
|
+
if p and Path(p).exists():
|
|
421
|
+
specs.append({"path": str(Path(p)), "label": label, "safe": safe,
|
|
422
|
+
"system": system, "admin": admin, "cleanable": cleanable,
|
|
423
|
+
"advice": advice})
|
|
424
|
+
if local:
|
|
425
|
+
# Temp 被运行中的程序持续占用,整体移动要么失败、要么破坏程序 → 仅供查看。
|
|
426
|
+
add(local / "Temp", "用户临时文件", cleanable=False,
|
|
427
|
+
advice="程序运行中持续使用;建议用「磁盘清理」处理")
|
|
428
|
+
add(local / "CrashDumps", "崩溃转储")
|
|
429
|
+
add(local / "Microsoft" / "Windows" / "WER", "Windows 错误报告")
|
|
430
|
+
add(local / "npm-cache", "npm 缓存")
|
|
431
|
+
add(local / "pnpm" / "store", "pnpm store")
|
|
432
|
+
add(local / "pip" / "Cache", "pip 缓存")
|
|
433
|
+
add(local / "Yarn" / "Cache", "Yarn 缓存")
|
|
434
|
+
if temp:
|
|
435
|
+
add(temp, "临时文件", cleanable=False,
|
|
436
|
+
advice="程序运行中持续使用;建议用「磁盘清理」处理")
|
|
437
|
+
# 需要管理员权限,且整体移动系统 Temp 目录本身就有风险 → 不进入清理允许集合。
|
|
438
|
+
add(Path("C:/Windows/Temp"), "系统临时文件", admin=True, cleanable=False,
|
|
439
|
+
advice="需要管理员权限;建议用「磁盘清理」处理")
|
|
440
|
+
add(Path("C:/Windows/SoftwareDistribution/Download"), "Windows 更新缓存",
|
|
441
|
+
safe=False, system=True, admin=True)
|
|
442
|
+
return specs
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def parse_tasklist(text):
|
|
446
|
+
"""解析 tasklist /FO CSV /NH 输出为 exe 名小写集合。"""
|
|
447
|
+
names = set()
|
|
448
|
+
for line in text.splitlines():
|
|
449
|
+
line = line.strip()
|
|
450
|
+
if not line.startswith('"'):
|
|
451
|
+
continue
|
|
452
|
+
end = line.find('"', 1)
|
|
453
|
+
if end > 1:
|
|
454
|
+
names.add(line[1:end].lower())
|
|
455
|
+
return names
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def running_process_names():
|
|
459
|
+
"""进程快照;失败返回空集合(按未运行处理,不阻断扫描)。"""
|
|
460
|
+
try:
|
|
461
|
+
out = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True,
|
|
462
|
+
text=True, timeout=30, errors="replace").stdout
|
|
463
|
+
except Exception:
|
|
464
|
+
return set()
|
|
465
|
+
return parse_tasklist(out)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
BROWSER_EXE_HINTS = (("Chrome", "chrome.exe"), ("Edge", "msedge.exe"), ("Firefox", "firefox.exe"))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def browser_exe(name):
|
|
472
|
+
for prefix, exe in BROWSER_EXE_HINTS:
|
|
473
|
+
if name.startswith(prefix):
|
|
474
|
+
return exe
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def browser_specs():
|
|
479
|
+
"""浏览器缓存(按浏览器/profile 细分;只统计 Cache 目录,不碰书签/密码/历史)。
|
|
480
|
+
|
|
481
|
+
与 macOS 报告同一契约:{name, path},由 scan() 补 kb/running。
|
|
482
|
+
"""
|
|
483
|
+
local = env_path("LOCALAPPDATA")
|
|
484
|
+
roaming = env_path("APPDATA")
|
|
485
|
+
specs = []
|
|
486
|
+
|
|
487
|
+
def add(name, p):
|
|
488
|
+
if p and Path(p).exists():
|
|
489
|
+
specs.append({"name": name, "path": str(Path(p))})
|
|
490
|
+
|
|
491
|
+
def chromium(label, user_data):
|
|
492
|
+
for prof in _child_dirs(user_data):
|
|
493
|
+
if prof.name != "Default" and not prof.name.lower().startswith("profile"):
|
|
494
|
+
continue
|
|
495
|
+
suffix = "" if prof.name == "Default" else f"({prof.name})"
|
|
496
|
+
add(f"{label}{suffix}", prof / "Cache")
|
|
497
|
+
|
|
498
|
+
if local:
|
|
499
|
+
chromium("Chrome", local / "Google" / "Chrome" / "User Data")
|
|
500
|
+
chromium("Edge", local / "Microsoft" / "Edge" / "User Data")
|
|
501
|
+
if roaming:
|
|
502
|
+
for prof in _child_dirs(roaming / "Mozilla" / "Firefox" / "Profiles"):
|
|
503
|
+
add("Firefox", prof / "cache2")
|
|
504
|
+
return specs
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def dev_specs():
|
|
508
|
+
home = Path(os.environ.get("USERPROFILE", "C:/Users/Default"))
|
|
509
|
+
local = env_path("LOCALAPPDATA")
|
|
510
|
+
out = []
|
|
511
|
+
def add(p, advice):
|
|
512
|
+
if p and Path(p).exists():
|
|
513
|
+
out.append({"path": str(Path(p)), "advice": advice})
|
|
514
|
+
add(home / ".gradle" / "caches", "Gradle 缓存,构建时会重新下载")
|
|
515
|
+
add(home / ".m2" / "repository", "Maven 仓库,构建时会重新下载")
|
|
516
|
+
add(home / ".nuget" / "packages", "NuGet 包,还原时会重新下载")
|
|
517
|
+
add(home / ".cargo" / "registry", "Cargo 缓存")
|
|
518
|
+
add(home / ".conda" / "pkgs", "Conda 包缓存")
|
|
519
|
+
add(local / "npm-cache" if local else None, "npm cache clean --force")
|
|
520
|
+
add(local / "pnpm" / "store" if local else None, "pnpm store prune")
|
|
521
|
+
add(local / "pip" / "Cache" if local else None, "pip cache purge")
|
|
522
|
+
return out
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def cloud_roots():
|
|
526
|
+
roots = set()
|
|
527
|
+
for name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
|
|
528
|
+
p = env_path(name)
|
|
529
|
+
if p and Path(p).exists():
|
|
530
|
+
roots.add(str(Path(p).resolve()))
|
|
531
|
+
home = env_path("USERPROFILE")
|
|
532
|
+
if home:
|
|
533
|
+
for d in _child_dirs(home):
|
|
534
|
+
if d.name.lower().startswith("onedrive") or d.name.lower() in ("dropbox", "google drive"):
|
|
535
|
+
roots.add(str(d.resolve()))
|
|
536
|
+
return sorted(roots)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def big_files(home, min_mb, cloud, top=15, deadline=None):
|
|
540
|
+
"""全盘大文件:先排序后取 top-N;超过时间预算即截断(与 size_of 同一约定)。"""
|
|
541
|
+
skip_dirs = {"appdata", "node_modules", ".git", "$recycle.bin", "system volume information"}
|
|
542
|
+
cloud_resolved = [str(Path(c)) for c in cloud]
|
|
543
|
+
rows = []
|
|
544
|
+
truncated = False
|
|
545
|
+
for root, dirs, files in os.walk(home):
|
|
546
|
+
if deadline and time.time() > deadline:
|
|
547
|
+
truncated = True
|
|
548
|
+
break
|
|
549
|
+
dirs[:] = [d for d in dirs if d.lower() not in skip_dirs
|
|
550
|
+
and not is_reparse(os.path.join(root, d))
|
|
551
|
+
and not any(os.path.join(root, d).startswith(c) for c in cloud_resolved)]
|
|
552
|
+
for f in files:
|
|
553
|
+
try:
|
|
554
|
+
p = os.path.join(root, f)
|
|
555
|
+
if is_reparse(p):
|
|
556
|
+
continue
|
|
557
|
+
st = os.stat(p)
|
|
558
|
+
if st.st_size >= min_mb * 1024 * 1024:
|
|
559
|
+
rows.append({"path": p, "bytes": st.st_size, "mtime": int(st.st_mtime)})
|
|
560
|
+
except OSError:
|
|
561
|
+
continue
|
|
562
|
+
rows.sort(key=lambda x: -x["bytes"])
|
|
563
|
+
return rows[:top], truncated
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def scan(args):
|
|
567
|
+
_stdout_utf8()
|
|
568
|
+
t0 = time.time()
|
|
569
|
+
home = Path(args.home).expanduser().resolve() if args.home else Path(os.environ.get("USERPROFILE", "C:/Users/Default"))
|
|
570
|
+
local = env_path("LOCALAPPDATA") or home / "AppData" / "Local"
|
|
571
|
+
roaming = env_path("APPDATA") or home / "AppData" / "Roaming"
|
|
572
|
+
programdata = env_path("PROGRAMDATA") or Path("C:/ProgramData")
|
|
573
|
+
deadline = t0 + SCAN_BUDGET_SEC
|
|
574
|
+
admin = is_admin()
|
|
575
|
+
system_drive = os.environ.get("SystemDrive", "C:")
|
|
576
|
+
disk = shutil.disk_usage(system_drive + "/")
|
|
577
|
+
r = {
|
|
578
|
+
"version": VERSION, "platform": PLATFORM,
|
|
579
|
+
"generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
580
|
+
"host": os.environ.get("COMPUTERNAME", "windows"),
|
|
581
|
+
"home": str(home),
|
|
582
|
+
"disk": {"total": disk.total, "used": disk.used, "free": disk.free, "drive": system_drive},
|
|
583
|
+
"access": {"admin": admin, "detail": "管理员" if admin else "普通用户"},
|
|
584
|
+
"installedCount": 0,
|
|
585
|
+
"caches": [], "leftoverGroups": [], "devCaches": [], "cloudStorage": [],
|
|
586
|
+
"browsers": [], "appCaches": [],
|
|
587
|
+
"bigFiles": [], "trashKb": None, "bigFilesTop": args.top,
|
|
588
|
+
"scope": f"{local}、{roaming}、{programdata}、Temp、浏览器缓存、开发缓存、云盘、"
|
|
589
|
+
f"{home}(不含 AppData 的大文件)",
|
|
590
|
+
"truncated": False,
|
|
591
|
+
}
|
|
592
|
+
apps = installed_apps()
|
|
593
|
+
r["installedCount"] = len(apps)
|
|
594
|
+
tokens = installed_tokens(apps)
|
|
595
|
+
|
|
596
|
+
seen_real = set()
|
|
597
|
+
|
|
598
|
+
def fresh(path):
|
|
599
|
+
key = os.path.normcase(os.path.realpath(str(path)))
|
|
600
|
+
if key in seen_real:
|
|
601
|
+
return False
|
|
602
|
+
seen_real.add(key)
|
|
603
|
+
return True
|
|
604
|
+
|
|
605
|
+
cloud = cloud_roots()
|
|
606
|
+
r["cloudStorage"] = [{"path": c, "kb": size_of(c, min(deadline, time.time() + 20))[0] // 1024} for c in cloud]
|
|
607
|
+
|
|
608
|
+
specs = cache_specs()
|
|
609
|
+
seen = set()
|
|
610
|
+
for spec in specs:
|
|
611
|
+
if not fresh(spec["path"]):
|
|
612
|
+
continue
|
|
613
|
+
seen.add(spec["path"])
|
|
614
|
+
kb, trunc = size_of(spec["path"], min(deadline, time.time() + 20))
|
|
615
|
+
r["truncated"] |= trunc
|
|
616
|
+
r["caches"].append({**spec, "kb": kb // 1024})
|
|
617
|
+
|
|
618
|
+
dev_paths = set()
|
|
619
|
+
for spec in dev_specs():
|
|
620
|
+
if not fresh(spec["path"]):
|
|
621
|
+
continue
|
|
622
|
+
seen.add(spec["path"])
|
|
623
|
+
dev_paths.add(spec["path"])
|
|
624
|
+
kb, trunc = size_of(spec["path"], min(deadline, time.time() + 20))
|
|
625
|
+
r["truncated"] |= trunc
|
|
626
|
+
r["devCaches"].append({**spec, "kb": kb // 1024})
|
|
627
|
+
|
|
628
|
+
proc_names = running_process_names()
|
|
629
|
+
for spec in browser_specs():
|
|
630
|
+
if not fresh(spec["path"]):
|
|
631
|
+
continue
|
|
632
|
+
seen.add(spec["path"])
|
|
633
|
+
kb, trunc = size_of(spec["path"], min(deadline, time.time() + 20))
|
|
634
|
+
r["truncated"] |= trunc
|
|
635
|
+
r["browsers"].append({**spec, "kb": kb // 1024,
|
|
636
|
+
"running": bool(proc_names) and browser_exe(spec["name"]) in proc_names})
|
|
637
|
+
r["browsers"].sort(key=lambda x: -x["kb"])
|
|
638
|
+
|
|
639
|
+
# 已卸载 App 数据候选(LOCAL/ROAMING/PROGRAMDATA 顶层目录)。
|
|
640
|
+
# 与 macOS 报告同一契约:单路径 leftoverGroups,bucket 由 classify_leftover 判定。
|
|
641
|
+
def candidate_roots():
|
|
642
|
+
yield local, SYSTEM_LOCAL
|
|
643
|
+
yield roaming, SYSTEM_ROAMING
|
|
644
|
+
yield programdata, SYSTEM_PROGRAMDATA
|
|
645
|
+
|
|
646
|
+
leftover_splits = {}
|
|
647
|
+
for root, skip in candidate_roots():
|
|
648
|
+
if not root or not Path(root).is_dir():
|
|
649
|
+
continue
|
|
650
|
+
for d in _child_dirs(root):
|
|
651
|
+
name = d.name
|
|
652
|
+
low = name.lower()
|
|
653
|
+
if low in skip or low.startswith("deepclean"):
|
|
654
|
+
continue
|
|
655
|
+
if any(str(d).startswith(c) for c in cloud):
|
|
656
|
+
continue
|
|
657
|
+
if dir_name_matches_installed(name, tokens):
|
|
658
|
+
continue
|
|
659
|
+
kb, trunc = size_of(d, min(deadline, time.time() + 15))
|
|
660
|
+
r["truncated"] |= trunc
|
|
661
|
+
if kb < args.min_leftover_mb * 1024 * 1024:
|
|
662
|
+
continue
|
|
663
|
+
if not fresh(d):
|
|
664
|
+
continue
|
|
665
|
+
bucket, why = classify_leftover(name, kb // 1024)
|
|
666
|
+
r["leftoverGroups"].append({"baseId": name, "name": name, "kb": kb // 1024,
|
|
667
|
+
"count": 1, "bucket": bucket, "why": why,
|
|
668
|
+
"paths": [str(d)], "bundleIds": []})
|
|
669
|
+
# confirm 残留里的可再生缓存子目录单独成 safe 组,其余数据保持只读;
|
|
670
|
+
# 未安装判定不可靠(installed_apps 为空)、聊天类、运行中的一律不拆。
|
|
671
|
+
if bucket == "confirm" and should_split_leftover(name, apps, proc_names):
|
|
672
|
+
split_paths = cache_subdirs(d, deadline=min(deadline, time.time() + 3))
|
|
673
|
+
kept, split_kb = [], 0
|
|
674
|
+
for sp in split_paths:
|
|
675
|
+
skb, strunc = size_of(sp, min(deadline, time.time() + 8))
|
|
676
|
+
r["truncated"] |= strunc
|
|
677
|
+
if skb >= 1024 * 1024:
|
|
678
|
+
kept.append(sp)
|
|
679
|
+
split_kb += skb // 1024
|
|
680
|
+
if kept and split_kb >= 1024:
|
|
681
|
+
leftover_splits[str(d)] = {"kb": split_kb, "paths": kept}
|
|
682
|
+
|
|
683
|
+
# UWP 包数据(单独列为需确认)
|
|
684
|
+
packages = local / "Packages"
|
|
685
|
+
if packages.is_dir():
|
|
686
|
+
for d in _child_dirs(packages):
|
|
687
|
+
kb, trunc = size_of(d, min(deadline, time.time() + 15))
|
|
688
|
+
r["truncated"] |= trunc
|
|
689
|
+
if kb >= args.min_leftover_mb * 1024 * 1024 and fresh(d):
|
|
690
|
+
r["leftoverGroups"].append({"baseId": d.name, "name": f"UWP: {d.name}", "kb": kb // 1024,
|
|
691
|
+
"count": 1, "bucket": "confirm",
|
|
692
|
+
"why": "商店应用数据;删除后应用将被重置且数据丢失",
|
|
693
|
+
"paths": [str(d)], "bundleIds": []})
|
|
694
|
+
|
|
695
|
+
apply_leftover_cache_split(r["leftoverGroups"], leftover_splits)
|
|
696
|
+
r["leftoverGroups"].sort(key=lambda x: -x["kb"])
|
|
697
|
+
if not args.quick:
|
|
698
|
+
big, big_trunc = big_files(home, args.min_mb, cloud, top=args.top, deadline=deadline)
|
|
699
|
+
r["truncated"] |= big_trunc
|
|
700
|
+
r["bigFiles"] = [{"path": b["path"], "kb": b["bytes"] // 1024, "mtime": b["mtime"]}
|
|
701
|
+
for b in big]
|
|
702
|
+
r["devPaths"] = sorted(dev_paths)
|
|
703
|
+
r["elapsed"] = round(time.time() - t0, 1)
|
|
704
|
+
# 与 macOS estimate_of 同契约:safe/confirm 按 leftoverGroups 分档,
|
|
705
|
+
# caches 与 devCaches 重叠(npm/pnpm/pip 两侧都列)按路径取 min 去重。
|
|
706
|
+
cache_map = {os.path.normcase(c["path"]): c["kb"] for c in r["caches"]
|
|
707
|
+
if c["safe"] and not c["system"] and c.get("cleanable", True)}
|
|
708
|
+
dev_map = {os.path.normcase(d["path"]): d["kb"] for d in r["devCaches"]}
|
|
709
|
+
overlap_kb = sum(min(cache_map[p], dev_map[p]) for p in set(cache_map) & set(dev_map))
|
|
710
|
+
cache_kb = sum(cache_map.values()) - overlap_kb
|
|
711
|
+
dev_kb = sum(dev_map.values())
|
|
712
|
+
safe_kb = sum(g["kb"] for g in r["leftoverGroups"] if g["bucket"] == "safe")
|
|
713
|
+
confirm_kb = sum(g["kb"] for g in r["leftoverGroups"] if g["bucket"] != "safe")
|
|
714
|
+
r["estimate"] = {"safeKb": safe_kb, "confirmKb": confirm_kb,
|
|
715
|
+
"cacheKb": cache_kb, "devKb": dev_kb, "overlapKb": overlap_kb,
|
|
716
|
+
"totalKb": safe_kb + cache_kb + dev_kb}
|
|
717
|
+
return r
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def render(r, args):
|
|
721
|
+
L = []
|
|
722
|
+
a = L.append
|
|
723
|
+
a("# 磁盘体检报告(Windows)")
|
|
724
|
+
a("")
|
|
725
|
+
a(f"- 工具版本:deepclean-mydisk v{r['version']}({r['platform']})")
|
|
726
|
+
a(f"- 生成时间:{r['generatedAt']}")
|
|
727
|
+
a(f"- 机器:{r['host'] if not args.redact else '***'}")
|
|
728
|
+
a(f"- 权限:{r['access']['detail']}(清理 ProgramData / Windows 目录需要管理员)")
|
|
729
|
+
a(f"- 已安装程序(注册表):{r['installedCount']} 个")
|
|
730
|
+
a(f"- 系统盘 {r['disk'].get('drive', 'C:')} 共 {r['disk']['total']/1e9:.0f} GB,已用 {r['disk']['used']/1e9:.0f} GB,"
|
|
731
|
+
f"可用 **{r['disk']['free']/1e9:.0f} GB**")
|
|
732
|
+
a(f"- 扫描耗时:{r['elapsed']}s" + ("(快速模式:未扫描大文件)" if args.quick else ""))
|
|
733
|
+
a(f"- 扫描范围:{r['scope']}")
|
|
734
|
+
if r["truncated"]:
|
|
735
|
+
a("- ⚠️ 部分目录超过时间预算,大小可能偏小(仍可安全使用)")
|
|
736
|
+
a("")
|
|
737
|
+
|
|
738
|
+
def table(title, rows):
|
|
739
|
+
a(f"## {title}")
|
|
740
|
+
a("")
|
|
741
|
+
if not rows:
|
|
742
|
+
a("(无)")
|
|
743
|
+
a("")
|
|
744
|
+
return
|
|
745
|
+
a("| 大小 | 位置 | 说明 |")
|
|
746
|
+
a("|---|---|---|")
|
|
747
|
+
for it in rows:
|
|
748
|
+
path_cell = one_line_cell(it.get("path", ""))
|
|
749
|
+
note_cell = one_line_cell(it.get("note", ""))
|
|
750
|
+
a(f"| {human(it.get('kb'))} | `{path_cell}` | {note_cell} |")
|
|
751
|
+
a("")
|
|
752
|
+
|
|
753
|
+
table("一、推荐清理(临时文件/缓存)",
|
|
754
|
+
[{"path": c["path"], "kb": c["kb"], "note": f"{c['label']};{c['advice']}"}
|
|
755
|
+
for c in r["caches"] if c["safe"] and not c["system"] and c.get("cleanable", True)]
|
|
756
|
+
+ [{"path": b["path"], "kb": b["kb"], "note": f"{b['name']} 浏览器缓存,可重建(不碰书签/密码)"}
|
|
757
|
+
for b in r.get("browsers", []) if not b.get("running")])
|
|
758
|
+
table("二、已卸载残留(推荐清理)",
|
|
759
|
+
[{"path": g["paths"][0], "kb": g["kb"], "note": f"{g['name']};{g['why']}"}
|
|
760
|
+
for g in r["leftoverGroups"] if g["bucket"] == "safe"])
|
|
761
|
+
table("三、需确认(应用数据 / 可能的卸载残留 / UWP)",
|
|
762
|
+
[{"path": g["paths"][0], "kb": g["kb"], "note": f"{g['name']};{g['why']}"}
|
|
763
|
+
for g in r["leftoverGroups"] if g["bucket"] != "safe"])
|
|
764
|
+
table("四、开发缓存(可再生)",
|
|
765
|
+
[{"path": d["path"], "kb": d["kb"], "note": d["advice"]} for d in r["devCaches"]])
|
|
766
|
+
table("五、云盘目录(不建议清理)",
|
|
767
|
+
[{"path": c["path"], "kb": c["kb"], "note": "云盘同步目录;删除占位文件会删除云端文件"}
|
|
768
|
+
for c in r["cloudStorage"]])
|
|
769
|
+
table(f"六、大文件(> {args.min_mb} MB,个人文件请自行判断)",
|
|
770
|
+
[{"path": b["path"], "kb": b["kb"], "note": "个人文件,请自行判断"} for b in r["bigFiles"]])
|
|
771
|
+
table("七、系统缓存与暂不可清理项(不建议清理)",
|
|
772
|
+
[{"path": c["path"], "kb": c["kb"], "note": f"{c['label']};{c['advice']}"}
|
|
773
|
+
for c in r["caches"] if c["system"] or not c.get("cleanable", True)]
|
|
774
|
+
+ [{"path": b["path"], "kb": b["kb"], "note": f"{b['name']} 正在运行,退出后重新扫描再清理"}
|
|
775
|
+
for b in r.get("browsers", []) if b.get("running")])
|
|
776
|
+
|
|
777
|
+
a("## 八、不建议清理")
|
|
778
|
+
a("")
|
|
779
|
+
a("- `C:\\Windows\\WinSxS`、`C:\\Windows\\System32`、驱动仓库")
|
|
780
|
+
a("- `pagefile.sys` / `hiberfil.sys` / `System Volume Information`")
|
|
781
|
+
a("- `C:\\Windows.old`(请用「磁盘清理」处理)")
|
|
782
|
+
a("- `C:\\Program Files`、`C:\\Program Files (x86)`、`C:\\ProgramData`(除明确缓存子目录)")
|
|
783
|
+
a("- 注册表、Defender 隔离区、回收站内部结构")
|
|
784
|
+
a("- OneDrive 等云盘的占位文件(删除会同步删除云端)")
|
|
785
|
+
a("- `%LOCALAPPDATA%\\Packages`(商店应用数据)")
|
|
786
|
+
a("- 浏览器配置目录(书签/密码/登录态)")
|
|
787
|
+
a("")
|
|
788
|
+
|
|
789
|
+
est = r.get("estimate") or {}
|
|
790
|
+
safe_kb = est.get("safeKb", 0)
|
|
791
|
+
cache_kb = est.get("cacheKb", 0)
|
|
792
|
+
dev_kb = est.get("devKb", 0)
|
|
793
|
+
a("## 九、预计可释放")
|
|
794
|
+
a("")
|
|
795
|
+
a(f"- 推荐清理(缓存/临时文件,已去重):**{human(cache_kb)}**")
|
|
796
|
+
if safe_kb:
|
|
797
|
+
a(f"- 已卸载残留(推荐清理):**{human(safe_kb)}**")
|
|
798
|
+
a(f"- 开发缓存:**{human(dev_kb)}**")
|
|
799
|
+
a(f"- 合计约:**{human(safe_kb + cache_kb + dev_kb)}**(不含需确认项)")
|
|
800
|
+
if est.get("overlapKb"):
|
|
801
|
+
a(f"- 已避免重复计算:{human(est['overlapKb'])}(同时出现在缓存与开发缓存中)")
|
|
802
|
+
a("")
|
|
803
|
+
|
|
804
|
+
a("## 十、建议命令(PowerShell,只生成,不执行)")
|
|
805
|
+
a("")
|
|
806
|
+
a("```powershell")
|
|
807
|
+
a('# 缓存/临时:移到隔离区,确认后再删除(比直接删安全)')
|
|
808
|
+
a('$q = Join-Path $env:LOCALAPPDATA "deepclean-mydisk\\trash"')
|
|
809
|
+
a('New-Item -ItemType Directory -Force $q | Out-Null')
|
|
810
|
+
emitted = 0
|
|
811
|
+
for c in r["caches"]:
|
|
812
|
+
if not c["safe"] or c["system"] or not c.get("cleanable", True) or c["kb"] < 100 * 1024:
|
|
813
|
+
continue
|
|
814
|
+
a(f"Move-Item -LiteralPath '{c['path']}' -Destination $q")
|
|
815
|
+
emitted += 1
|
|
816
|
+
for g in r["leftoverGroups"]:
|
|
817
|
+
if g["bucket"] != "safe" or g["kb"] < 100 * 1024:
|
|
818
|
+
continue
|
|
819
|
+
a(f"Move-Item -LiteralPath '{g['paths'][0]}' -Destination $q")
|
|
820
|
+
emitted += 1
|
|
821
|
+
if emitted == 0:
|
|
822
|
+
a("# 本次没有 ≥100 MB 的推荐清理项,无需移动")
|
|
823
|
+
a("```")
|
|
824
|
+
a("")
|
|
825
|
+
a("> 需确认项(App 数据/UWP)不生成命令;删除前请先退出对应程序。")
|
|
826
|
+
md = "\n".join(L)
|
|
827
|
+
if args.redact:
|
|
828
|
+
home = r["home"]
|
|
829
|
+
md = md.replace(home, "%USERPROFILE%").replace(r["host"], "***")
|
|
830
|
+
return md
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _move_quote(path):
|
|
834
|
+
return "'" + str(path).replace("'", "''") + "'"
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def self_test():
|
|
838
|
+
fails, skips = [], []
|
|
839
|
+
|
|
840
|
+
def check(name, cond, detail=""):
|
|
841
|
+
if cond:
|
|
842
|
+
print(f"PASS {name}")
|
|
843
|
+
else:
|
|
844
|
+
fails.append(name)
|
|
845
|
+
print(f"FAIL {name} {detail}")
|
|
846
|
+
|
|
847
|
+
tmp = Path(tempfile.mkdtemp(prefix="deepclean 中文 空格 "))
|
|
848
|
+
try:
|
|
849
|
+
f = tmp / "a b 中文.txt"
|
|
850
|
+
f.write_bytes(b"x" * 4096)
|
|
851
|
+
kb, trunc = size_of(tmp)
|
|
852
|
+
check("size_of 统计(4KB)", kb == 4096 and not trunc, f"got {kb}")
|
|
853
|
+
|
|
854
|
+
# junction 跳过
|
|
855
|
+
link = tmp / "link"
|
|
856
|
+
try:
|
|
857
|
+
subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(tmp / "a b 中文.txt")],
|
|
858
|
+
capture_output=True, check=False)
|
|
859
|
+
check("junction 识别", is_reparse(link) or not link.exists())
|
|
860
|
+
except Exception:
|
|
861
|
+
skips.append("junction 创建")
|
|
862
|
+
|
|
863
|
+
# 注册表
|
|
864
|
+
apps = installed_apps()
|
|
865
|
+
check("注册表已安装程序可读(>0)", len(apps) > 0, f"got {len(apps)}")
|
|
866
|
+
|
|
867
|
+
# 命令转义(避免 f-string 表达式里出现反斜杠,兼容 Python 3.9)
|
|
868
|
+
tricky = "C:\\A B\\it's"
|
|
869
|
+
expected = "-LiteralPath 'C:\\A B\\it''s'"
|
|
870
|
+
cmd = "Move-Item -LiteralPath " + _move_quote(tricky)
|
|
871
|
+
check("命令转义", expected in cmd, cmd)
|
|
872
|
+
|
|
873
|
+
# 报告渲染
|
|
874
|
+
r = {"version": VERSION, "platform": PLATFORM, "generatedAt": "x", "host": "h",
|
|
875
|
+
"home": str(tmp), "access": {"admin": False, "detail": "普通用户"},
|
|
876
|
+
"installedCount": len(apps), "disk": {"total": 1, "used": 1, "free": 1},
|
|
877
|
+
"caches": [], "leftoverGroups": [], "devCaches": [], "cloudStorage": [],
|
|
878
|
+
"browsers": [], "appCaches": [],
|
|
879
|
+
"bigFiles": [], "truncated": False, "elapsed": 0.1, "scope": "x"}
|
|
880
|
+
ns = argparse.Namespace(redact=False, quick=True, min_mb=300, min_leftover_mb=10)
|
|
881
|
+
md = render(r, ns)
|
|
882
|
+
check("报告含关键小节", "预计可释放" in md and "不建议清理" in md)
|
|
883
|
+
md.encode("utf-8")
|
|
884
|
+
|
|
885
|
+
# 隔离区往返
|
|
886
|
+
q = tmp / "quarantine"
|
|
887
|
+
q.mkdir()
|
|
888
|
+
target = tmp / "move-me 中文"
|
|
889
|
+
target.mkdir()
|
|
890
|
+
(target / "f").write_text("x", encoding="utf-8")
|
|
891
|
+
shutil.move(str(target), str(q / target.name))
|
|
892
|
+
check("隔离区移动", (q / target.name).exists() and not target.exists())
|
|
893
|
+
shutil.move(str(q / target.name), str(target))
|
|
894
|
+
check("隔离区还原", target.exists())
|
|
895
|
+
finally:
|
|
896
|
+
# 仅清理自检用的临时目录,不触碰任何用户文件
|
|
897
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
898
|
+
|
|
899
|
+
if skips:
|
|
900
|
+
print("SKIP:", ", ".join(skips))
|
|
901
|
+
print(f"\n自检结果:{'通过' if not fails else '失败 ' + ', '.join(fails)}")
|
|
902
|
+
return 0 if not fails else 1
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def main():
|
|
906
|
+
_stdout_utf8()
|
|
907
|
+
ap = argparse.ArgumentParser(description="Windows 只读磁盘体检")
|
|
908
|
+
ap.add_argument("--md")
|
|
909
|
+
ap.add_argument("--json")
|
|
910
|
+
ap.add_argument("--home")
|
|
911
|
+
ap.add_argument("--top", type=int, default=15)
|
|
912
|
+
ap.add_argument("--min-mb", type=int, default=300)
|
|
913
|
+
ap.add_argument("--min-leftover-mb", type=int, default=10)
|
|
914
|
+
ap.add_argument("--quick", action="store_true")
|
|
915
|
+
ap.add_argument("--redact", action="store_true")
|
|
916
|
+
ap.add_argument("--check-permissions", action="store_true")
|
|
917
|
+
ap.add_argument("--self-test", action="store_true")
|
|
918
|
+
ap.add_argument("--version", action="version", version=f"deepclean-mydisk {VERSION} (windows)")
|
|
919
|
+
args = ap.parse_args()
|
|
920
|
+
if args.check_permissions:
|
|
921
|
+
role = "管理员" if is_admin() else "普通用户"
|
|
922
|
+
print(f"权限检查:Windows 无「完全磁盘访问」概念;当前 = {role}"
|
|
923
|
+
"(清理 ProgramData/Windows 目录需要管理员)")
|
|
924
|
+
sys.exit(0)
|
|
925
|
+
if args.self_test:
|
|
926
|
+
sys.exit(self_test())
|
|
927
|
+
r = scan(args)
|
|
928
|
+
md = render(r, args)
|
|
929
|
+
print(md)
|
|
930
|
+
if args.md:
|
|
931
|
+
Path(args.md).write_text(md, encoding="utf-8")
|
|
932
|
+
print(f"[written] {args.md}", file=sys.stderr)
|
|
933
|
+
if args.json:
|
|
934
|
+
data = json.dumps(r, ensure_ascii=False, indent=2)
|
|
935
|
+
Path(args.json).write_text(data, encoding="utf-8")
|
|
936
|
+
print(f"[written] {args.json}", file=sys.stderr)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
if __name__ == "__main__":
|
|
940
|
+
main()
|