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.
@@ -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()