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,949 @@
1
+ #!/usr/bin/env python3
2
+ """deepclean-mydisk —— 只读磁盘体检(v0.3)。
3
+
4
+ 硬约束:只读。不删除、不移动、不修改任何文件;删除建议只生成命令文本。
5
+ """
6
+ import argparse
7
+ import heapq
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from pathlib import Path
16
+
17
+ VERSION = "0.5.0"
18
+ SCRIPT_DIR = Path(__file__).resolve().parent
19
+
20
+
21
+ def _progress(pct, label):
22
+ """向宿主输出进度(stderr,仅供插件聚合展示,不污染报告)。"""
23
+ try:
24
+ print(f"PROGRESS {pct} {label}", file=sys.stderr, flush=True)
25
+ except Exception:
26
+ pass
27
+ SKILL_DIR = SCRIPT_DIR.parent
28
+ DEFAULT_RULES = {
29
+ "thresholds": {"displayMinMb": 10, "mediaConfirmMb": 50, "commandMinMb": 100, "bigFileMb": 300},
30
+ "mediaHints": ["video", "player", "music", "douyin"],
31
+ "meetingHints": ["meeting", "zoom", "teams"],
32
+ "userDataHints": ["wechat", "qq", "docs", "notes", "mail"],
33
+ "devHints": ["xcode", "codex", "docker"],
34
+ "extSuffixRe": r"(?i)\.(widget|extension|helper|share|service|intent|preview|provider|sync)$",
35
+ "systemCacheNames": ["CloudKit", "GeoServices"],
36
+ "knownNames": {},
37
+ "denyPaths": [],
38
+ }
39
+
40
+
41
+ def load_rules():
42
+ path = SKILL_DIR / "rules.json"
43
+ rules = json.loads(json.dumps(DEFAULT_RULES))
44
+ try:
45
+ if path.exists():
46
+ loaded = json.loads(path.read_text(encoding="utf-8"))
47
+ for k, v in loaded.items():
48
+ rules[k] = v
49
+ except Exception as e: # 规则坏了也要能跑
50
+ print(f"[warn] rules.json 读取失败,使用内置规则:{e}", file=sys.stderr)
51
+ th = rules.setdefault("thresholds", {})
52
+ for key, val in (("displayMinMb", 10), ("mediaConfirmMb", 50),
53
+ ("commandMinMb", 100), ("bigFileMb", 300)):
54
+ th.setdefault(key, val)
55
+ try:
56
+ rules["_extRe"] = re.compile(rules["extSuffixRe"])
57
+ except re.error:
58
+ rules["_extRe"] = re.compile(DEFAULT_RULES["extSuffixRe"])
59
+ return rules
60
+
61
+
62
+ def sh(cmd, timeout=120):
63
+ try:
64
+ return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ def du_map(pattern, timeout=180):
70
+ out = sh(f'du -sk {pattern} 2>/dev/null', timeout=timeout)
71
+ m = {}
72
+ for line in out.splitlines():
73
+ parts = line.split("\t", 1)
74
+ if len(parts) != 2:
75
+ continue
76
+ try:
77
+ m[parts[1]] = int(parts[0])
78
+ except ValueError:
79
+ continue
80
+ return m
81
+
82
+
83
+ def du_one(path, timeout=120):
84
+ out = sh(f'du -sk "{path}" 2>/dev/null', timeout=timeout)
85
+ try:
86
+ return int(out.split("\t", 1)[0])
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def human(kb):
92
+ if kb is None:
93
+ return "?"
94
+ n = float(kb) * 1024
95
+ for unit in ("B", "K", "M", "G", "T"):
96
+ if n < 1024 or unit == "T":
97
+ return f"{n:.0f}{unit}" if unit in ("B", "K") else f"{n:.1f}{unit}"
98
+ n /= 1024
99
+ return f"{n:.1f}T"
100
+
101
+
102
+ # 文件名/路径可能带换行或控制字符;先单行化再放进 Markdown 表格,
103
+ # 避免伪造额外表格行或截断渲染(只影响展示,不影响扫描/分档)。
104
+ _CELL_CTRL = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060\ufeff]")
105
+
106
+
107
+ def one_line_cell(value, max_len=200):
108
+ s = "" if value is None else str(value)
109
+ s = _CELL_CTRL.sub(" ", s)
110
+ s = re.sub(r"\s+", " ", s).strip()
111
+ if max_len and len(s) > max_len:
112
+ s = s[: max_len - 1] + "…"
113
+ return s
114
+
115
+
116
+ def plist_raw(path, key):
117
+ if not os.path.exists(path):
118
+ return None
119
+ out = sh(f'plutil -extract "{key}" raw -o - "{path}" 2>/dev/null', timeout=10).strip()
120
+ return out or None
121
+
122
+
123
+ def _app_info(app):
124
+ try:
125
+ bid = plist_raw(app / "Contents" / "Info.plist", "CFBundleIdentifier")
126
+ if not bid:
127
+ return None
128
+ name = (plist_raw(app / "Contents" / "Info.plist", "CFBundleDisplayName")
129
+ or plist_raw(app / "Contents" / "Info.plist", "CFBundleName") or app.stem)
130
+ return bid, {"path": str(app), "name": name}
131
+ except Exception:
132
+ return None
133
+
134
+
135
+ def installed_apps(home):
136
+ """返回 {bundleId: {"path":..., "name":...}}(plutil 并行,约 0.5s)"""
137
+ roots = [home / "Applications", Path("/Applications"), Path("/Applications/Utilities"),
138
+ Path("/System/Applications"), Path("/System/Applications/Utilities")]
139
+ roots += [Path(p) / "Applications" for p in sh('ls -d /Volumes/* 2>/dev/null', timeout=10).split()]
140
+ apps, seen = [], set()
141
+ for root in roots:
142
+ try:
143
+ if not root.is_dir():
144
+ continue
145
+ for app in root.glob("*.app"):
146
+ real = str(app.resolve())
147
+ if real in seen:
148
+ continue
149
+ seen.add(real)
150
+ apps.append(app)
151
+ except Exception:
152
+ continue
153
+ mapping = {}
154
+ with ThreadPoolExecutor(max_workers=8) as ex:
155
+ for item in ex.map(_app_info, apps):
156
+ if item:
157
+ mapping[item[0]] = item[1]
158
+ return mapping
159
+
160
+
161
+ def base_id(bid, rules):
162
+ cur = bid
163
+ for _ in range(3):
164
+ m = rules["_extRe"].search(cur)
165
+ if not m:
166
+ break
167
+ cur = cur[: m.start()]
168
+ return cur
169
+
170
+
171
+ def belongs_to_installed(bid, installed, component_of=None, base=None):
172
+ if bid in installed:
173
+ return installed[bid]
174
+ for aid, info in installed.items():
175
+ if bid.startswith(aid + "."):
176
+ return info
177
+ comp = component_of or {}
178
+ parent = comp.get(bid) or (comp.get(base) if base else None)
179
+ if parent and parent in installed:
180
+ return installed[parent]
181
+ return None
182
+
183
+
184
+ CACHE_DIR_NAMES = {"caches", "cache", "tmp", "temp", "code cache", "gpucache",
185
+ "cachestorage", "service worker", "crashpad"}
186
+ CACHE_SKIP_TOP = {"Documents", "Desktop", "Downloads", "Movies", "Music", "Pictures"}
187
+
188
+
189
+ def cache_subdirs(container_path, max_depth=4, deadline=None):
190
+ """容器 Data 下的可再生缓存目录(不跟随链接、限制深度与时间预算)。
191
+
192
+ 只按目录名命中 CACHE_DIR_NAMES,跳过个人目录顶层;命中后不再向下遍历。
193
+ """
194
+ base = os.path.join(container_path, "Data")
195
+ # 容器或 Data 本身是符号链接时不拆(否则会返回链接外的路径)。
196
+ if not os.path.isdir(base) or os.path.islink(base) or os.path.islink(container_path):
197
+ return []
198
+ hits = []
199
+ for root, dirs, _files in os.walk(base):
200
+ if deadline and time.time() > deadline:
201
+ break
202
+ rel = os.path.relpath(root, base)
203
+ if rel != "." and rel.split(os.sep)[0] in CACHE_SKIP_TOP:
204
+ dirs[:] = []
205
+ continue
206
+ depth = 0 if rel == "." else rel.count(os.sep) + 1
207
+ if depth > max_depth:
208
+ dirs[:] = []
209
+ continue
210
+ dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))]
211
+ if os.path.basename(root).lower() in CACHE_DIR_NAMES:
212
+ hits.append(root)
213
+ dirs[:] = []
214
+ return hits
215
+
216
+
217
+ def strict_descendant(path, parents):
218
+ """path 的 realpath 必须严格位于任一 parent 的 realpath 之下。"""
219
+ try:
220
+ real = os.path.realpath(path)
221
+ except OSError:
222
+ return False
223
+ for parent in parents or []:
224
+ try:
225
+ base = os.path.realpath(parent)
226
+ except OSError:
227
+ continue
228
+ if real != base and real.startswith(base + os.sep):
229
+ return True
230
+ return False
231
+
232
+
233
+ def apply_leftover_cache_split(groups, splits):
234
+ """把 confirm 残留组里的缓存子目录拆成独立 safe 组。
235
+
236
+ splits: {baseId: {"kb": int, "paths": [str]}},只含未安装、未运行 App 的缓存子目录。
237
+ 拆出的组带 cachePart=True,客户端据此显示「App · 缓存」;原组保留其余数据并保持 confirm。
238
+ """
239
+ for base, split in list(splits.items()):
240
+ group = groups.get(base)
241
+ if group is None or group.get("bucket") != "confirm":
242
+ continue
243
+ if "进程正在运行" in (group.get("why") or ""):
244
+ continue
245
+ cache_kb = int(split.get("kb") or 0)
246
+ if cache_kb < 1024: # 小于 1 MB 不值得单列
247
+ continue
248
+ raw_paths = list(split.get("paths") or [])
249
+ paths = [p for p in raw_paths if strict_descendant(p, group.get("paths"))]
250
+ # 拆分路径必须严格在容器之下;否则(自命中/符号链接逃逸)保留原 confirm 组不拆。
251
+ if not paths or len(paths) != len(raw_paths):
252
+ continue
253
+ split_id = f"{base}.cache"
254
+ groups[split_id] = {
255
+ "baseId": split_id,
256
+ "name": group.get("name") or group["baseId"],
257
+ "kb": cache_kb,
258
+ "count": len(split.get("paths") or []),
259
+ "bucket": "safe",
260
+ "why": "已卸载 App 的缓存子目录,可重建;文档/聊天等数据仍保留",
261
+ "paths": paths,
262
+ "bundleIds": [],
263
+ "cachePart": True,
264
+ }
265
+ group["kb"] = max(0, group["kb"] - cache_kb)
266
+ group["why"] = f"{group['why']};其中缓存子目录已单独列出(可清理)"
267
+ # 缓存即全部内容时,原 confirm 组没有剩余数据
268
+ for key in [k for k, g in groups.items() if int(g.get("kb") or 0) <= 0]:
269
+ del groups[key]
270
+ return groups
271
+
272
+
273
+ def classify(base, kb, rules):
274
+ """整容器一律「需确认」;safe 只来自 cache_subdirs() 拆出的缓存子目录。
275
+
276
+ 卸载残留里可能混着文档/录制/下载内容,按名字猜「缓存为主」不够可靠;
277
+ 宁可让用户多确认一次,也不要把整个容器默认勾进推荐清理。
278
+ """
279
+ low = base.lower()
280
+ if any(h in low for h in rules["mediaHints"]):
281
+ return "confirm", "媒体类 App 已卸载;其中缓存子目录会单独列出(可清理),其余可能含已下载内容"
282
+ if any(h in low for h in rules["meetingHints"]):
283
+ return "confirm", "会议软件已卸载;其中缓存子目录会单独列出(可清理),录制/文档等需确认"
284
+ if any(h in low for h in rules["userDataHints"]):
285
+ return "confirm", "可能含聊天记录/文档/笔记"
286
+ if any(h in low for h in rules["devHints"]):
287
+ return "confirm", "开发工具数据,请确认项目仍在使用后再清理"
288
+ return "confirm", "未知来源,需人工确认"
289
+
290
+
291
+ def fda_status(home):
292
+ probes = [home / "Library" / "Mail", home / "Library" / "Messages", home / "Library" / "Safari",
293
+ home / "Library" / "Group Containers",
294
+ home / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db"]
295
+ accessible = existing = denied = 0
296
+ for p in probes:
297
+ if not p.exists():
298
+ continue
299
+ existing += 1
300
+ try:
301
+ if p.is_dir():
302
+ os.listdir(p)
303
+ else:
304
+ with open(p, "rb") as f:
305
+ f.read(1)
306
+ accessible += 1
307
+ except PermissionError:
308
+ denied += 1
309
+ except Exception:
310
+ pass
311
+ if denied:
312
+ return "denied", f"{denied}/{existing} 个受保护路径被拒绝"
313
+ if accessible:
314
+ return "granted", f"{accessible}/{existing} 个受保护路径可读"
315
+ return "unknown", "未找到可探测的受保护路径"
316
+
317
+
318
+ PERMISSION_HELP = """\
319
+ ❌ 缺少「完全磁盘访问权限」(Full Disk Access),已停止扫描。
320
+
321
+ 没有该权限时,macOS 会把其他 App 的容器显示成几十 KB 的空壳,
322
+ 报告会严重漏报——这正是市面清理软件「扫不到」的原因。
323
+
324
+ 请这样开启:
325
+ 1. 打开「系统设置」→「隐私与安全性」→「完全磁盘访问权限」
326
+ 2. 点「+」添加 DeepSeek Harness(或打开已有开关)
327
+ 3. 完全退出并重新打开 DeepSeek Harness(TCC 变更通常需重启应用生效)
328
+ 4. 回到会话,重新运行本命令
329
+
330
+ 若只想先看部分结果,可加 --force(报告会标注结果不可信)。
331
+ """
332
+
333
+
334
+ def permission_line(status, detail):
335
+ label = {"granted": "已授权", "denied": "**未授权**", "unknown": "无法确认"}[status]
336
+ return f"权限检查:macOS 完全磁盘访问 = {label}({detail})"
337
+
338
+
339
+ def process_snapshot():
340
+ """一次抓取全部进程命令行,避免每个 bundle id 反复 pgrep(几十倍提速)。"""
341
+ return sh("pgrep -fl . 2>/dev/null", timeout=15).lower()
342
+
343
+
344
+ def dev_sizes(home, rules):
345
+ # (path, advice, cleanable) —— cleanable=False 的只展示、不进入清理允许列表
346
+ items = [
347
+ (home / ".npm" / "_cacache", "npm cache clean --force", True),
348
+ (home / "Library" / "pnpm" / "store", "pnpm store prune", True),
349
+ (home / ".cache" / "codex-runtimes", "Codex 缓存,需要时会重新下载", True),
350
+ (home / ".m2" / "repository", "Maven 仓库,构建时会重新下载", True),
351
+ (home / "Library" / "Caches" / "electron-builder", "打包时会重新下载", True),
352
+ (home / "Library" / "Caches" / "node-gyp", "需要时会重新下载", True),
353
+ (home / "Library" / "Caches" / "next-swc", "需要时会重新下载", True),
354
+ (home / "Library" / "Caches" / "CocoaPods", "CocoaPods 缓存,需要时会重新下载", True),
355
+ (home / "Library" / "Caches" / "org.swift.swiftpm", "SwiftPM 缓存", True),
356
+ (home / "Library" / "Developer" / "Xcode" / "DerivedData", "Xcode 构建缓存,安全可再生", True),
357
+ (home / "Library" / "Developer" / "Xcode" / "DeviceSupport", "iOS 设备支持文件,需要时会重新下载", True),
358
+ (home / "Library" / "Developer" / "CoreSimulator", "模拟器数据,确认不再使用后再清理", False),
359
+ (home / "Library" / "Developer" / "Xcode" / "Archives", "应用归档,提交或回滚时可能需要,需确认", False),
360
+ (home / "Library" / "Containers" / "com.docker.docker" / "Data" / "vms", "Docker 镜像和数据卷,删除会丢失容器数据,需确认", False),
361
+ ]
362
+
363
+ def one(pair):
364
+ path, advice, cleanable = pair
365
+ kb = du_one(path, timeout=90)
366
+ return {"path": str(path), "kb": kb, "advice": advice, "cleanable": cleanable} if kb else None
367
+
368
+ out = []
369
+ with ThreadPoolExecutor(max_workers=4) as ex:
370
+ for r in ex.map(one, items):
371
+ if r:
372
+ out.append(r)
373
+ out.sort(key=lambda x: -x["kb"])
374
+ return out
375
+
376
+
377
+ # 浏览器进程子串(pgrep -fl 小写全文匹配):宁可误标运行中(暂时不可清),不可漏标。
378
+ BROWSER_PROC_HINTS = {
379
+ "Chrome": "google chrome",
380
+ "Edge": "microsoft edge",
381
+ "Firefox": "firefox",
382
+ "Safari": "safari",
383
+ "Arc": "arc",
384
+ }
385
+
386
+
387
+ def browser_caches(home, procs=""):
388
+ """浏览器缓存(按浏览器/profile 细分;不碰书签、密码、历史)。"""
389
+ # 只统计真正的缓存目录;绝不进入 Application Support(含书签/密码/历史)
390
+ specs = [
391
+ ("Chrome", home / "Library" / "Caches" / "Google" / "Chrome"),
392
+ ("Edge", home / "Library" / "Caches" / "Microsoft Edge"),
393
+ ("Firefox", home / "Library" / "Caches" / "Firefox" / "Profiles"),
394
+ ("Safari", home / "Library" / "Caches" / "com.apple.Safari"),
395
+ ("Arc", home / "Library" / "Caches" / "Arc"),
396
+ ]
397
+ out = []
398
+ for name, path in specs:
399
+ kb = du_one(path, timeout=60)
400
+ if kb:
401
+ hint = BROWSER_PROC_HINTS.get(name, "")
402
+ out.append({"name": name, "path": str(path), "kb": kb,
403
+ "running": bool(procs) and bool(hint) and hint in procs})
404
+ return out
405
+
406
+
407
+ def special_clean(home):
408
+ """微信 / 企业微信 / QQ 专清:按类型聚合,只有缓存类 cleanable=True。"""
409
+ apps = [
410
+ ("微信", home / "Library" / "Containers" / "com.tencent.xinWeChat" / "Data"),
411
+ ("企业微信", home / "Library" / "Containers" / "com.tencent.WeWorkMac" / "Data"),
412
+ ("QQ", home / "Library" / "Containers" / "com.tencent.qq" / "Data"),
413
+ ]
414
+ keywords = ["cache", "caches", "cefcache", "image", "images", "video", "videos",
415
+ "voice", "voices", "audio", "file", "files", "emoji", "emojis",
416
+ "sticker", "stickers", "attach", "attachment", "attachments",
417
+ "download", "downloads"]
418
+ pattern = " -o ".join(f"-iname '{k}'" for k in keywords)
419
+ out = []
420
+ for app, base in apps:
421
+ if not base.is_dir():
422
+ continue
423
+ cmd = (f'find "{base}" -maxdepth 8 -type d \\( {pattern} \\) '
424
+ f'-not -path "*/WebKit/*" -not -path "*/.Trash/*" 2>/dev/null | head -80')
425
+ paths = [p for p in sh(cmd, timeout=120).splitlines() if p]
426
+ rows = []
427
+ for p in paths:
428
+ kb = du_one(Path(p), timeout=60)
429
+ if kb < 1024:
430
+ continue
431
+ name = os.path.basename(p).lower()
432
+ low = p.lower()
433
+ if "cache" in name:
434
+ # 企微个人 Profile 下的 Caches 是聊天媒体缓存,删除后可能无法恢复 → 只读
435
+ if "/profiles/" in low and "tencentmeeting" not in low:
436
+ cat, clean = "聊天缓存", False
437
+ else:
438
+ cat, clean = "缓存", True
439
+ elif "image" in name:
440
+ cat, clean = "图片", False
441
+ elif "video" in name:
442
+ cat, clean = "视频", False
443
+ elif "voice" in name or "audio" in name:
444
+ cat, clean = "语音", False
445
+ elif "emoji" in name or "sticker" in name:
446
+ cat, clean = "表情", False
447
+ elif "attach" in name or "download" in name:
448
+ cat, clean = "附件/下载", False
449
+ else:
450
+ cat, clean = "文件", False
451
+ rows.append({"path": p, "kb": kb, "category": cat, "cleanable": clean})
452
+ rows.sort(key=lambda r: -r["kb"])
453
+ kept = []
454
+ for r in rows:
455
+ if any(r["path"] == k["path"] or r["path"].startswith(k["path"] + os.sep) for k in kept):
456
+ continue
457
+ kept.append(r)
458
+ if not kept:
459
+ continue
460
+ cats = {}
461
+ for r in kept:
462
+ c = cats.setdefault(r["category"], {"name": r["category"], "kb": 0,
463
+ "paths": [], "cleanable": r["cleanable"]})
464
+ c["kb"] += r["kb"]
465
+ c["paths"].append(r["path"])
466
+ c["cleanable"] = c["cleanable"] and r["cleanable"]
467
+ out.append({"app": app, "base": str(base),
468
+ "totalKb": sum(c["kb"] for c in cats.values()),
469
+ "categories": sorted(cats.values(), key=lambda c: -c["kb"])})
470
+ return out
471
+
472
+
473
+ def big_files(home, min_mb, top=15, deadline=None):
474
+ """全盘大文件:流式读取 find -print0 并维护 top-N 堆;超预算即截断(与 Windows 端同一约定)。
475
+
476
+ 不能用 head 截断 find 输出:那会把「先遍历到的前 40 个」误当候选集,
477
+ 真正的最大文件可能根本没进堆(Windows 端已是全量遍历 + 排序取 top-N)。
478
+ """
479
+ home = Path(home)
480
+ argv = [
481
+ "find", str(home), "-xdev", "(",
482
+ "-path", str(home / "Library" / "Containers"),
483
+ "-o", "-path", str(home / "Library" / "CloudStorage"),
484
+ "-o", "-path", str(home / "Library" / "Mobile Documents"),
485
+ "-o", "-path", str(home / "Library" / "Mail"),
486
+ "-o", "-path", str(home / "Library" / "Messages"),
487
+ "-o", "-path", str(home / ".Trash"),
488
+ "-o", "-name", "node_modules", "-o", "-name", ".git",
489
+ # App 包目录内部的文件不能出现在「大文件」里:搬走单个文件会破坏照片图库/Final Cut 等。
490
+ "-o", "-name", "*.photoslibrary", "-o", "-name", "*.photolibrary",
491
+ "-o", "-name", "*.sparsebundle", "-o", "-name", "*.fcpbundle",
492
+ "-o", "-name", "*.logicx", "-o", "-name", "*.band",
493
+ ")", "-prune", "-o", "-type", "f", "-size", f"+{min_mb}M", "-print0",
494
+ ]
495
+ heap = [] # (bytes, path, mtime, atime),只保留最大的 top 个
496
+ truncated = False
497
+ try:
498
+ proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
499
+ except Exception:
500
+ return [], False
501
+ buf = b""
502
+ while True:
503
+ if deadline and time.time() > deadline:
504
+ truncated = True
505
+ proc.kill()
506
+ break
507
+ try:
508
+ chunk = proc.stdout.read1(65536) # read1:有数据即返回,不等缓冲区填满
509
+ except Exception:
510
+ break
511
+ if not chunk:
512
+ break
513
+ buf += chunk
514
+ *ready, buf = buf.split(b"\0")
515
+ for raw in ready:
516
+ try:
517
+ p = raw.decode("utf-8", errors="replace")
518
+ st = os.stat(p)
519
+ item = (st.st_size, p, int(st.st_mtime), int(st.st_atime))
520
+ if len(heap) < top:
521
+ heapq.heappush(heap, item)
522
+ elif item[0] > heap[0][0]:
523
+ heapq.heapreplace(heap, item)
524
+ except Exception:
525
+ continue
526
+ proc.wait()
527
+ rows = [{"path": p, "bytes": b, "mtime": m, "atime": a}
528
+ for b, p, m, a in sorted(heap, reverse=True)]
529
+ return rows, truncated
530
+
531
+
532
+ def big_category(path, home):
533
+ try:
534
+ parts = Path(path).relative_to(home).parts
535
+ except Exception:
536
+ parts = Path(path).parts
537
+ if "Application Support" in parts:
538
+ return "App 资源(删了会影响功能)"
539
+ if parts and parts[0] in ("Documents", "Desktop", "Downloads", "Movies", "Music", "Pictures"):
540
+ return "个人文件(可迁移/备份)"
541
+ if parts and parts[0] in (".codex", ".claude", ".cache", ".npm", ".vscode", ".dsh"):
542
+ return "工具数据(可能含会话/登录)"
543
+ if parts and parts[0] == "Library":
544
+ return "系统/App 数据(谨慎)"
545
+ return "其他"
546
+
547
+
548
+ def scan(args):
549
+ t0 = time.time()
550
+ home = Path(args.home).expanduser().resolve() if args.home else Path.home()
551
+ rules = load_rules()
552
+ r = {"version": VERSION, "platform": "macos", "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
553
+ "host": os.uname().nodename, "home": str(home)}
554
+
555
+ try:
556
+ st = os.statvfs("/System/Volumes/Data")
557
+ total = st.f_bavail * st.f_frsize + (st.f_blocks - st.f_bfree) * st.f_frsize
558
+ free = st.f_bavail * st.f_frsize
559
+ used = total - free
560
+ except Exception:
561
+ total = free = used = 0
562
+
563
+ _progress(2, "读取磁盘信息")
564
+ status, detail = fda_status(home)
565
+ r["fda"] = {"status": status, "detail": detail}
566
+ r["disk"] = {"total": total, "used": used, "free": free, "homeKb": None}
567
+
568
+ with ThreadPoolExecutor(max_workers=7) as ex:
569
+ f_cont = ex.submit(du_map, f'"{home}/Library/Containers"/*', 240)
570
+ f_cache = ex.submit(du_map, f'"{home}/Library/Caches"/*', 180)
571
+ f_dotcache = ex.submit(du_map, f'"{home}/.cache"/*', 120)
572
+ f_cloud = ex.submit(du_map, f'"{home}/Library/CloudStorage"/*', 60) if not args.no_cloud else None
573
+ f_dev = ex.submit(dev_sizes, home, rules)
574
+ f_apps = ex.submit(installed_apps, home)
575
+ f_home = ex.submit(du_one, home, 300) if args.home_total else None
576
+ f_big = ex.submit(big_files, home, args.min_mb, args.top, time.time() + 180) if not args.quick else None
577
+ _progress(8, "扫描 App 容器与缓存…")
578
+
579
+ cont_sizes = f_cont.result()
580
+ _progress(30, "扫描 App 容器")
581
+ cache_sizes = f_cache.result()
582
+ _progress(45, "扫描系统/用户缓存")
583
+ dotcache_sizes = f_dotcache.result()
584
+ _progress(55, "扫描 ~/.cache")
585
+ cloud_sizes = f_cloud.result() if f_cloud else {}
586
+ dev = f_dev.result()
587
+ _progress(65, "扫描开发缓存")
588
+ installed = f_apps.result()
589
+ _progress(75, "识别已安装 App")
590
+ if f_home:
591
+ r["disk"]["homeKb"] = f_home.result()
592
+ big, big_trunc = f_big.result() if f_big else ([], False)
593
+ _progress(88, "扫描大文件" if f_big else "汇总结果")
594
+
595
+ r["installedCount"] = len(installed)
596
+
597
+ # 容器
598
+ cont = []
599
+ for path, kb in cont_sizes.items():
600
+ bid = plist_raw(os.path.join(path, ".com.apple.containermanagerd.metadata.plist"),
601
+ "MCMMetadataIdentifier") or os.path.basename(path)
602
+ base = base_id(bid, rules)
603
+ owner = belongs_to_installed(bid, installed, rules.get("componentOf"), base)
604
+ if owner:
605
+ cont.append({"path": path, "bundleId": bid, "baseId": base, "kb": kb,
606
+ "bucket": "keep", "why": f"已安装:{owner['name']}",
607
+ "installed": True, "installedName": owner['name']})
608
+ continue
609
+ if bid.startswith("com.apple."):
610
+ cont.append({"path": path, "bundleId": bid, "baseId": base, "kb": kb,
611
+ "bucket": "keep", "why": "系统自带 App 数据"})
612
+ continue
613
+ bucket, why = classify(base, kb, rules)
614
+ cont.append({"path": path, "bundleId": bid, "baseId": base, "kb": kb,
615
+ "bucket": bucket, "why": why})
616
+ cont.sort(key=lambda x: -x["kb"])
617
+ r["containers"] = cont
618
+
619
+ # 在用 App 的可再生缓存(仅 Caches/tmp;聊天类容器交给 special 专清)
620
+ chat_prefixes = ("com.tencent.xinwechat", "com.tencent.weworkmac", "com.tencent.qq")
621
+
622
+ cache_candidates = []
623
+ for c in cont:
624
+ if not c.get("installed"):
625
+ continue
626
+ low = c["baseId"].lower()
627
+ if low.startswith("com.apple.") or any(low.startswith(p) for p in chat_prefixes):
628
+ continue
629
+ for p in cache_subdirs(c["path"]):
630
+ cache_candidates.append((c, p))
631
+ procs = process_snapshot()
632
+ app_caches = []
633
+ if cache_candidates:
634
+ _progress(80, "扫描应用缓存")
635
+
636
+ def _cache_size(pair):
637
+ c, p = pair
638
+ kb = du_one(p, timeout=60)
639
+ return (c, p, kb) if kb else None
640
+
641
+ with ThreadPoolExecutor(max_workers=8) as ex:
642
+ rows = [x for x in ex.map(_cache_size, cache_candidates) if x]
643
+ by_app = {}
644
+ for c, p, kb in rows:
645
+ g = by_app.setdefault(c["baseId"], {"baseId": c["baseId"],
646
+ "app": c.get("installedName") or c["baseId"],
647
+ "kb": 0, "paths": [], "running": False})
648
+ g["kb"] += kb
649
+ g["paths"].append(p)
650
+ for g in by_app.values():
651
+ g["running"] = g["app"].lower() in procs or g["baseId"].lower() in procs
652
+ g["cleanable"] = not g["running"]
653
+ g["count"] = len(g["paths"])
654
+ if g["kb"] >= 1024:
655
+ app_caches.append(g)
656
+ app_caches.sort(key=lambda x: -x["kb"])
657
+ r["appCaches"] = app_caches[:80]
658
+
659
+ # 卸载残留的细粒度拆分:把 confirm 容器里的缓存子目录单独标为 safe,
660
+ # 剩余部分仍为 confirm(可能含文档/聊天/下载内容)。聊天类容器交给 special 专清,不拆。
661
+ split_deadline = time.time() + 25
662
+ split_candidates = []
663
+ for c in cont:
664
+ if c.get("installed") or c["bucket"] != "confirm":
665
+ continue
666
+ low = c["baseId"].lower()
667
+ if low.startswith("com.apple.") or any(low.startswith(p) for p in chat_prefixes):
668
+ continue
669
+ if any(str(b).lower() in procs for b in (c["bundleId"], c["baseId"])):
670
+ continue
671
+ for p in cache_subdirs(c["path"], deadline=split_deadline):
672
+ split_candidates.append((c, p))
673
+ leftover_splits = {}
674
+ if split_candidates:
675
+ _progress(84, "扫描残留缓存子目录")
676
+
677
+ def _split_size(pair):
678
+ c, p = pair
679
+ kb = du_one(p, timeout=30)
680
+ return (c, p, kb) if kb and kb >= 1024 else None
681
+
682
+ with ThreadPoolExecutor(max_workers=8) as ex:
683
+ split_rows = [x for x in ex.map(_split_size, split_candidates) if x]
684
+ for c, p, kb in split_rows:
685
+ entry = leftover_splits.setdefault(c["baseId"], {"kb": 0, "paths": []})
686
+ entry["kb"] += kb
687
+ entry["paths"].append(p)
688
+
689
+ # 归并 + 运行中检查
690
+ groups = {}
691
+ for c in cont:
692
+ if c["bucket"] == "keep":
693
+ continue
694
+ g = groups.setdefault(c["baseId"], {"baseId": c["baseId"], "kb": 0, "count": 0,
695
+ "bucket": c["bucket"], "why": c["why"],
696
+ "paths": [], "bundleIds": []})
697
+ g["kb"] += c["kb"]
698
+ g["count"] += 1
699
+ g["paths"].append(c["path"])
700
+ g["bundleIds"].append(c["bundleId"])
701
+ for g in groups.values():
702
+ g["name"] = rules["knownNames"].get(g["baseId"], g["baseId"])
703
+ if any(b.lower() in procs for b in g["bundleIds"]):
704
+ g["bucket"] = "confirm"
705
+ g["why"] += ";检测到相关进程正在运行,暂不可清理"
706
+ apply_leftover_cache_split(groups, leftover_splits)
707
+ r["leftoverGroups"] = sorted(groups.values(), key=lambda x: -x["kb"])
708
+
709
+ # 缓存
710
+ caches = [{"path": p, "kb": k} for p, k in cache_sizes.items()]
711
+ caches += [{"path": p, "kb": k} for p, k in dotcache_sizes.items()]
712
+ for c in caches:
713
+ c["system"] = any(s in c["path"] for s in rules["systemCacheNames"])
714
+ r["caches"] = sorted(caches, key=lambda x: -x["kb"])
715
+ r["devCaches"] = dev
716
+ r["devPaths"] = sorted({d["path"] for d in dev})
717
+ r["browsers"] = browser_caches(home, procs)
718
+ _progress(90, "分析微信/企业微信/QQ 数据")
719
+ r["special"] = special_clean(home)
720
+
721
+ r["cloudStorage"] = [{"path": p, "kb": k} for p, k in cloud_sizes.items()]
722
+ _progress(95, "统计废纸篓")
723
+ r["trashKb"] = du_one(home / ".Trash", timeout=120)
724
+ r["bigFiles"] = [{"path": b["path"], "kb": b["bytes"] // 1024,
725
+ "mtime": b.get("mtime", 0), "atime": b.get("atime", 0),
726
+ "ext": os.path.splitext(b["path"])[1].lower(),
727
+ "category": big_category(b["path"], home)} for b in big]
728
+ r["truncated"] = big_trunc
729
+ r["bigFilesTop"] = args.top
730
+ _progress(100, "完成")
731
+ r["elapsed"] = round(time.time() - t0, 1)
732
+ r["estimate"] = estimate_of(r, args.min_leftover_mb)
733
+ return r
734
+
735
+
736
+ def estimate_of(r, min_leftover_mb):
737
+ """统一估算,供 Markdown 与 JSON 共用(已去重)。"""
738
+ safe = sum(g["kb"] for g in r["leftoverGroups"]
739
+ if g["bucket"] == "safe" and g["kb"] >= min_leftover_mb * 1024)
740
+ cache_map = {c["path"]: c["kb"] for c in r["caches"] if not c.get("system")}
741
+ dev_map = {d["path"]: d["kb"] for d in r["devCaches"]}
742
+ overlap = sum(min(cache_map[p], dev_map[p]) for p in set(cache_map) & set(dev_map))
743
+ cache = sum(cache_map.values()) - overlap
744
+ dev = sum(dev_map.values())
745
+ return {"safeKb": safe, "cacheKb": cache, "devKb": dev,
746
+ "overlapKb": overlap, "totalKb": safe + cache + dev}
747
+
748
+
749
+ def redacted(text, home, host):
750
+ """脱敏:兼容 POSIX / Windows 两种路径分隔符。"""
751
+ raw = str(home)
752
+ variants = {raw, raw.replace("/", "\\"), raw.replace("\\", "/")}
753
+ for v in variants:
754
+ if v:
755
+ text = text.replace(v, "~")
756
+ return text.replace(host, "***")
757
+
758
+
759
+ def render_md(r, args):
760
+ home = Path(r["home"])
761
+ host = r["host"]
762
+ rules = load_rules()
763
+ th = rules["thresholds"]
764
+ L = []
765
+ a = L.append
766
+ a("# Mac 磁盘体检报告")
767
+ a("")
768
+ a(f"- 工具版本:deepclean-mydisk v{r['version']}({r.get('platform', 'macos')})")
769
+ a(f"- 生成时间:{r['generatedAt']}")
770
+ a(f"- 机器:{host if not args.redact else '***'}")
771
+ status = r["fda"]["status"]
772
+ fda_txt = {"granted": "已授权", "denied": "**未授权(App 容器会显示为空壳)**",
773
+ "unknown": "无法确认(未找到可探测路径)"}[status]
774
+ a(f"- 完全磁盘访问(FDA):{fda_txt}({r['fda']['detail']})")
775
+ a(f"- 已安装 App:{r['installedCount']} 个")
776
+ tb, used, free = r["disk"]["total"], r["disk"]["used"], r["disk"]["free"]
777
+ a(f"- 数据卷:{tb/1e9:.0f} GB 中已用 {used/1e9:.0f} GB,可用 **{free/1e9:.0f} GB**")
778
+ if r["disk"]["homeKb"]:
779
+ a(f"- 个人文件夹:{human(r['disk']['homeKb'])}")
780
+ a(f"- 扫描耗时:{r['elapsed']}s" + ("(快速模式:未扫描大文件)" if args.quick else ""))
781
+ if r.get("truncated"):
782
+ a("- ⚠️ 大文件扫描超过时间预算,结果可能不全")
783
+ a("- 扫描范围:`~/Library/Containers`、`~/Library/Caches`、`~/.cache`、开发缓存、"
784
+ "`~/Library/CloudStorage`、`~/.Trash`、大文件;**不含** `/private/var`、`/Library`、其他用户。")
785
+ if status == "denied":
786
+ a("")
787
+ a("> ⚠️ 没有完全磁盘访问权限时,下面的容器大小不可信。"
788
+ "请先到「系统设置 → 隐私与安全性 → 完全磁盘访问权限」授权后重跑。")
789
+
790
+ def table(title, rows):
791
+ a(f"## {title}")
792
+ a("")
793
+ if not rows:
794
+ a("(无)")
795
+ a("")
796
+ return
797
+ a("| 大小 | 位置 | 说明 |")
798
+ a("|---|---|---|")
799
+ for it in rows:
800
+ path_cell = one_line_cell(it.get("path", ""))
801
+ note_cell = one_line_cell(it.get("note", ""))
802
+ a(f"| {human(it.get('kb'))} | `{path_cell}` | {note_cell} |")
803
+ a("")
804
+
805
+ gl = r["leftoverGroups"]
806
+ minb = args.min_leftover_mb * 1024
807
+ big_g = [g for g in gl if g["kb"] >= minb]
808
+ small = [g for g in gl if g["kb"] < minb]
809
+
810
+ def group_rows(bucket):
811
+ rows = []
812
+ for g in big_g:
813
+ if g["bucket"] != bucket:
814
+ continue
815
+ name = g.get("name") or g["baseId"]
816
+ rows.append({"path": f"{name}({g['baseId']})", "kb": g["kb"],
817
+ "note": f"{g['why']};{g['count']} 个目录;示例 `{g['paths'][0]}`"})
818
+ return rows
819
+
820
+ table("一、推荐清理(已卸载 App 的可再生缓存子目录)", group_rows("safe"))
821
+ table("二、需确认(可能含聊天记录/文档/笔记/下载内容)", group_rows("confirm"))
822
+ if small:
823
+ a(f"> 另有 **{len(small)}** 个小于 {args.min_leftover_mb} MB 的残留碎片,"
824
+ f"合计 {human(sum(g['kb'] for g in small))}。")
825
+ a("")
826
+
827
+ dev_paths = set(r.get("devPaths", []))
828
+ table("三、系统缓存(不建议清理)",
829
+ [{"path": c["path"], "kb": c["kb"], "note": "系统缓存,释放空间有限,不建议清理"}
830
+ for c in r["caches"] if c.get("system") and c["kb"] >= th["displayMinMb"] * 1024])
831
+ def cache_note(c):
832
+ note = "缓存,可重建"
833
+ if "ms-playwright" in c["path"]:
834
+ note += ";Playwright 浏览器,测试仍在使用请保留"
835
+ elif c["kb"] >= 500 * 1024:
836
+ note += ";体积较大,请确认对应应用仍在使用"
837
+ return note
838
+
839
+ table("四、用户缓存(可重建)",
840
+ [{"path": c["path"], "kb": c["kb"], "note": cache_note(c)}
841
+ for c in r["caches"]
842
+ if not c.get("system") and c["kb"] >= th["displayMinMb"] * 1024
843
+ and c["path"] not in dev_paths])
844
+ table("五、开发缓存(可再生,按说明清理)",
845
+ [{"path": d["path"], "kb": d["kb"], "note": d["advice"]} for d in r["devCaches"]])
846
+ table("六、云盘目录(不建议清理)",
847
+ [{"path": c["path"], "kb": c["kb"], "note": "云盘同步目录,**不要直接删文件**"}
848
+ for c in r["cloudStorage"]])
849
+ table(f"七、大文件(> {args.min_mb} MB,最大的 {args.top} 个)",
850
+ [{"path": b["path"], "kb": b["kb"], "note": b["category"]} for b in r["bigFiles"]])
851
+
852
+ a("## 八、不建议清理")
853
+ a("")
854
+ a("- `~/Library/Mail`、`~/Library/Messages`、`~/Library/Photos`、`~/Library/Keychains`")
855
+ a("- `~/Library/CloudStorage`(云盘:本地删除可能同步删除云端)")
856
+ a("- `~/Library/Group Containers`、`~/Library/Application Support`(除明确缓存子目录)")
857
+ a("- 任何「已安装 App」的容器(已自动归并,不会出现在清理项里)")
858
+ a("")
859
+
860
+ est = r.get("estimate") or estimate_of(r, args.min_leftover_mb)
861
+ safe_kb, cache_kb, dev_kb = est["safeKb"], est["cacheKb"], est["devKb"]
862
+ overlap_kb = est["overlapKb"]
863
+ a("## 九、预计可释放")
864
+ a("")
865
+ a(f"- 推荐清理:**{human(safe_kb)}**")
866
+ a(f"- 用户缓存(已去重):**{human(cache_kb)}**(删除后会重新下载/重建)")
867
+ a(f"- 开发缓存:**{human(dev_kb)}**(用到时重新下载)")
868
+ a(f"- 合计约:**{human(safe_kb + cache_kb + dev_kb)}**(不含需确认项与废纸篓)")
869
+ if overlap_kb:
870
+ a(f"- 已避免重复计算:{human(overlap_kb)}(同时出现在缓存与开发缓存中)")
871
+ if r["trashKb"]:
872
+ a(f"- 另有废纸篓 {human(r['trashKb'])},清空可额外释放。")
873
+ a("")
874
+
875
+ a("## 十、建议命令(只生成,不执行)")
876
+ a("")
877
+ a("```bash")
878
+ a("# 缓存类(安全)")
879
+ a("npm cache clean --force")
880
+ a("pnpm store prune")
881
+ a("")
882
+ a("# 卸载残留:仅「推荐清理」档,且只移到废纸篓(-n 不覆盖重名)")
883
+ emitted = 0
884
+ for g in big_g:
885
+ if g["bucket"] != "safe" or g["kb"] < th["commandMinMb"] * 1024:
886
+ continue
887
+ for p in g["paths"][:3]:
888
+ a(f'mv -n "{p}" ~/.Trash/')
889
+ emitted += 1
890
+ if emitted == 0:
891
+ a("# 本次推荐清理项均小于 100 MB,未生成 mv;如需清理请逐项确认")
892
+ a("```")
893
+ a("")
894
+ a("> 需确认项不生成命令;永久删除必须二次确认。")
895
+ md = "\n".join(L)
896
+ if args.redact:
897
+ md = redacted(md, r["home"], host)
898
+ return md
899
+
900
+
901
+ def main():
902
+ ap = argparse.ArgumentParser(description="只读 Mac 磁盘体检")
903
+ ap.add_argument("--md")
904
+ ap.add_argument("--json")
905
+ ap.add_argument("--home", help="覆盖扫描根(测试用)")
906
+ ap.add_argument("--top", type=int, default=15)
907
+ ap.add_argument("--min-mb", type=int, default=300, help="大文件阈值 MB")
908
+ ap.add_argument("--min-leftover-mb", type=int, default=10, help="残留展示阈值 MB")
909
+ ap.add_argument("--quick", action="store_true", help="跳过全盘大文件扫描")
910
+ ap.add_argument("--home-total", action="store_true", help="额外统计个人文件夹总大小(慢 ~30s)")
911
+ ap.add_argument("--no-cloud", action="store_true", help="跳过 CloudStorage 统计")
912
+ ap.add_argument("--redact", action="store_true", help="脱敏:HOME→~、主机名打码")
913
+ ap.add_argument("--check-permissions", action="store_true",
914
+ help="只检查完全磁盘访问权限后退出")
915
+ ap.add_argument("--force", action="store_true", help="权限不足时仍生成部分报告(不可信)")
916
+ ap.add_argument("--version", action="version", version=f"deepclean-mydisk {VERSION}")
917
+ args = ap.parse_args()
918
+
919
+ home = Path(args.home).expanduser().resolve() if args.home else Path.home()
920
+ status, detail = fda_status(home)
921
+ if args.check_permissions:
922
+ print(permission_line(status, detail))
923
+ if status == "denied":
924
+ print()
925
+ print(PERMISSION_HELP)
926
+ sys.exit(2)
927
+ sys.exit(0)
928
+ if status == "denied" and not args.force:
929
+ print(PERMISSION_HELP)
930
+ sys.exit(2)
931
+
932
+ r = scan(args)
933
+ md = render_md(r, args)
934
+ print(md)
935
+ if args.md:
936
+ Path(args.md).write_text(md, encoding="utf-8")
937
+ print(f"[written] {args.md}", file=sys.stderr)
938
+ if args.json:
939
+ data = json.dumps(r, ensure_ascii=False, indent=2)
940
+ if args.redact:
941
+ data = redacted(data, r["home"], r["host"])
942
+ Path(args.json).write_text(data, encoding="utf-8")
943
+ print(f"[written] {args.json}", file=sys.stderr)
944
+ if r["fda"]["status"] == "denied" and not args.force:
945
+ sys.exit(2)
946
+
947
+
948
+ if __name__ == "__main__":
949
+ main()