
Storage Analyzer
- 1.2k installs
- 19.1k repo stars
- Updated August 2, 2026
- kkkkhazix/khazix-skills
storage-analyzer is an agent skill that performs read-only macOS and Windows disk scans and returns a categorized interactive HTML report with safe cleanup recommendations.
About
storage-analyzer is a cross-platform disk analysis skill for macOS and Windows that scans storage usage read-only and ranks items into three cleanup tiers: auto-safe, manual review, and caution. The workflow runs df, du, diskutil, stat, and ls style inspection only—no rm, mv, or destructive commands during analysis—then generates a collapsible interactive HTML report with copy-ready cleanup commands. A local server can open the report for one-click delete actions moved to trash when the user explicitly chooses cleanup. storage-analyzer triggers on phrases like disk full, storage analysis, or cache cleanup in English and Chinese, but excludes RAM and process memory questions. Developers reach for storage-analyzer when a workstation runs out of space and they need categorized, actionable findings instead of manual folder hunting.
- Automatically detects macOS or Windows and performs a read-only full-disk scan
- Classifies every item into three severity tiers: 🟢 auto-cleanable, 🟡 manual review, 🔴 dangerous to touch
- Generates an interactive, foldable HTML report with one-click-copy terminal commands
- Optional local web server lets you safely delete files to trash directly from the browser
- All deletion commands are displayed only — never executed by the agent
Storage Analyzer by the numbers
- 1,244 all-time installs (skills.sh)
- +66 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #424 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kkkkhazix/khazix-skills --skill storage-analyzerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 19.1k |
| Last updated | August 2, 2026 |
| Repository | kkkkhazix/khazix-skills ↗ |
What is filling up disk space on my Mac?
Instantly understand what is consuming disk space on their development machine and receive categorized, safe cleanup recommendations.
Who is it for?
Developers on macOS or Windows whose disks are full and who want a read-only scan with ranked cleanup recommendations.
Skip if: Developers troubleshooting RAM or process memory usage, or environments where automated disk deletion is prohibited.
When should I use this skill?
The user says disk full, storage analysis, cleanup space, C drive full, or similar storage-not-RAM complaints on macOS or Windows.
What you get
Interactive HTML storage report, three-tier cleanup classifications, and copy-ready deletion or cache-clear commands.
- Interactive HTML storage report
- Tiered cleanup recommendations
By the numbers
- Uses a three-tier cleanup classification: auto-safe, manual review, and caution
Files
Storage Analyzer
对 macOS 做一次只读存储分析,产出交互式 HTML 报告。流程:扫描 → 分析分级 → 生成网页 → 打开。
铁律
- 全程只读。 只能跑扫描/统计/列目录/读元信息(df、du、diskutil、stat、ls)。绝对禁止 rm、mv、rmdir、清空回收站、改权限等任何写操作。
- 删除命令只展示,不执行。 报告里给出的清理命令是供用户自己在终端确认后运行的。即使用户在对话里说"帮我删",也要先停下确认(命中全局红线:删除文件必须先问),不要直接代跑。
- 估算标注清楚。 涉及"可释放空间"一律说明是估算值。
- 路径、命令保留原文不翻译。
执行流程
Step 1 扫描(只读)
python3 scripts/scan.py > /tmp/storage_scan.jsonscan.py 自动识别系统(sys.platform):
- macOS:扫 home、library、caches、containers、group_containers、app_support、applications、downloads、dev_caches,用
du算大小。 - Windows:扫 user_profile、appdata_local、appdata_roaming、temp、downloads、program_files(_x86)、dev_caches,用
os.scandir算大小;system.disks含所有盘符。
输出 JSON:system(系统/磁盘信息,含 disk_name 主盘名 + disks 全部盘)+ groups(各组子目录大小,已降序、过滤 50MB 以下)。扫描较慢,耐心等。读不到的目录标 denied,需在报告里列出并提示遗漏体量。
Step 2 分析与分级
先看 system.os 判断系统,读对应的数据布局参考:macOS 读 references/macos.md,Windows 读 references/windows.md(讲该系统东西存哪、怎么辨认、归哪一级)。然后读 /tmp/storage_scan.json 做这几件事:
1. 挑 Top 5 占用大户,判定类型(系统资产/应用本体/应用数据/应用缓存/开发缓存/用户文件/媒体内容/下载内容/虚拟机镜像/回收站/其他)。 2. 识别"神秘大目录":UUID 命名的 Container、不明的隐藏目录,要追查它属于哪个 App、装的是什么(例如某 97GB 的 UUID Container 实为 Bilibili 离线视频缓存)。必要时 ls/du 深入一层看清楚,但仍只读。 3. 三级分类 = 清理决策清单,不是全盘点。 只把"存在'要不要动它'这个决策"的项放进三灯;日常在用的正常应用、操作系统本身、海量零碎小文件没有清理决策,不进三灯,它们落在磁盘条的蓝色"系统及其他"里。判定标准:
- 🟢 可自动清理:纯缓存、临时文件、安装包残留、明确可再生且不影响功能、不丢用户数据(pip/uv/npm/Xcode DerivedData 等开发缓存、浏览器缓存)。
- 🟡 需人工判断:含用户数据或有判断成本(离线视频、文档、项目代码 node_modules、聊天记录、设计稿)。给内容画像 + 至少 3 句处置路径(应用内清理 / 系统工具 / 文件管理器手动审查,三选最合适)+ 风险提示。所有橙灯项在服务模式下自动有「在访达/资源管理器打开」按钮(跳过去自己审查删);如果该项有一个核实过、删了不破坏 App 的安全子路径(如 B站离线视频的
.Downloads目录、旧备份目录),给它trash_paths→ 网页出现「移到废纸篓」按钮(橙灯只准移废纸篓、可逆,绝不给"直接删除")。App 托管又无安全子路径的(Chrome/微信)只给打开按钮、不给 trash_paths。按钮下方会自动写明注意事项(打开只查看不删、移废纸篓可逆需清空才释放等);如果某项在文件管理器里是 App 内部格式、不方便手动挑选,给它一个open_note字段做客观说明(会显示在注意事项里)。口吻要中性、像产品说明:直接描述"这里是什么结构、为什么不好手动删、想精细操作该去哪",不要写成"我发现/提醒注意/看着像没视频"这种暴露开发者踩坑视角的话。 - 🔴 谨慎清理(有决策但不建议手删):你可能想动、但建议别手删的具体项——重复安装的应用、想卸载的大应用、运行中应用的核心数据等。给"为什么不建议手删" +
indirect_release写具体卸载步骤(自带卸载器 / 启动台长按 / 右键移废纸篓 / AppCleaner 清残留 / App Store 可重装等,要可照做不是空话)。应用项给app_paths(真实.app绝对路径数组)→ 网页出现「在文件管理器打开(去卸载)」按钮,定位到 App 让用户自己正规卸载。红灯不给删除/卸载按钮(应用在系统目录、可能要管理员密码、可能有自带卸载器和残留,后台代删不稳妥)。纯系统文件、APFS 快照不要单独列红卡(没有清理决策),归蓝色即可;系统层面的释放技巧(重启释 swap、Time Machine 快照策略、可清除空间自动回收)写进summary.long_term长期建议。
每个 🟢 项要给:预估释放空间、清理前需关闭的进程、可一键复制的清理命令(用移到废纸篓或 App 自身清理入口的安全方式,谨慎用 rm;如用 rm 必须是明确的缓存子目录)。
大小字段写干净:size / size_estimate 用"约 14 GB""合计约 8.6 GB"即可——"约"已表示估算,不要再加"(估算)",重复且不专业(模板也会自动去掉这种冗余括号)。可再生属性已由分级标题和按钮说明覆盖,别塞进大小字段。
Step 3 生成交互报告
把分析结果写成 analysis JSON(schema 见 scripts/build_report.py 顶部注释)。
🟢 项必须带 `trash_paths`(具体可删的绝对路径数组,区别于人类可读的 path 展示字段)——这是网页删除按钮的前提,漏了按钮就不出现。
默认用一键删除模式(`server.py`)打开报告,因为这个 skill 的核心价值就是网页上能直接清理:
python3 scripts/server.py /tmp/storage_analysis.json # 自动开浏览器,Ctrl+C 停server.py 起在 127.0.0.1 + 随机端口 + 随机 token。🟢 项给「移到废纸篓」(可逆) +「直接删除」(立即释放、不可逆);🟡 项给「在访达打开」+(有安全子路径时)「移到废纸篓」。安全模型——三套白名单,权限从严到宽:rm 只允许绿灯 trash_paths;trash 允许绿灯+橙灯 trash_paths(橙灯永远不能 rm);open(在文件管理器打开,非破坏性)允许上述全部 + 橙灯真实 path。所有请求 realpath 校验 + 必须在 $HOME 内 + token + Host 校验,每次点击浏览器先 confirm。osascript/SHFileOperationW 入废纸篓,macOS 首次弹访达自动化授权点允许即可。
仅当用户明确只想要一份可分享/留存的只读文件时,才用静态模式(无删除按钮,因为 file:// 打开的页面碰不到文件系统):
python3 scripts/build_report.py /tmp/storage_analysis.json ~/Desktop/storage-report.html && open ~/Desktop/storage-report.html排障:网页上没有删除/移废纸篓按钮 = 要么开的是静态报告(改用 server.py),要么 🟢 项漏了 trash_paths(补上重启服务)。
报告阅读流(固定顺序):磁盘总览卡片(容量 + 进度条 + 三色容量 pills + 系统信息,纯数据)→ 占用排行 Top5 → 执行建议 → 🟢🟡🔴 三级可折叠卡片(命令一键复制)→ 长期优化建议。即"现状 → 诊断 → 处方 → 操作 → 预防"。
注意 summary.overview 要写成一句话洞察(直接说最大占用是什么、能释放多少),不要重复总/已用/可用数字——那些已在卡片大数字里显示。overview 渲染在"执行建议"小节开头作引子(普通文字),紧接着是 summary.priority 优先级清单。
磁盘进度条把"已用"拆成分段:绿(可自动清)+橙(需手动)+红(已识别的不建议动项)+蓝(系统及其他,自动取 已用−绿−橙−红 的余量),余下为可用(灰底)。summary.tier_stats 的 green / yellow / red 三个值都要以可解析的 GB 数字开头(如 "约 27.8 GB"),脚本从中取数算分段;蓝色段和"系统及其他"pill 由模板自动算余量。
pills 只渲染解析出的纯数字(如"约 5.5 GB"),不显示数据里的附注,所以 tier_stats 三个值写干净的数字即可,别加"仅已识别项/系统未计"这类道歉式说明——系统文件本来就归在蓝色段,红色只放你能量化的 🔴 项(重复应用、可卸载大应用等),量不准的系统文件/快照自然落到蓝色。
Step 4 对话里给摘要
报告生成后,在对话里用一段话给结论先行的摘要:总可释放估算、最该先清的 2-3 项、风险最高的一项。细节让用户看网页。
依赖与运行前提
- 全部脚本是 Python 3 标准库,零第三方依赖(不用 pip install)。
- macOS 自带 python3、
du、diskutil、osascript,开箱即用。 - Windows 默认没装 Python——需先装 Python 3,且命令多为
python或py -3(不是python3)。本 skill 命令示例写的是python3,在 Windows 上自动改用python/py -3。 - 本 skill 是 agent 驱动:扫描出数据后由 agent(Claude)做分级分析,不是双击即用的独立 App。
平台状态
- macOS:完整实现并实测(扫描 / 报告 / 一键删除全验证过)。
- Windows:代码已写(
scan.py的scan_windows、server.py的_trash_windows走SHFileOperationW),但未在真实 Windows 上实测。首次在 Windows 跑要核对:目标目录路径、os.scandir大小、回收站删除是否正常。多盘符已支持(主盘分段条 + 其他盘列表)。
长期优化建议素材(写进报告 summary.long_term)
- 定期清理:
brew cleanup、Xcode DerivedData、浏览器缓存 - 可视化工具:DaisyDisk、GrandPerspective、OmniDiskSweeper
- 大文件归档到外置盘 / iCloud / NAS;macOS「系统设置 > 通用 > 储存空间」的优化选项
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>存储分析报告</title>
<style>
:root{
--bg:#f6f7f9; --card:#ffffff; --ink:#1d2129; --sub:#86909c;
--line:#e5e6eb; --green:#00b42a; --yellow:#ff7d00; --red:#f53f3f;
--green-bg:#e8ffea; --yellow-bg:#fff7e8; --red-bg:#ffece8;
--accent:#165dff; --accent-bg:#e8f3ff;
--radius:14px; --shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"SF Pro SC","PingFang SC",system-ui,sans-serif;
background:var(--bg);color:var(--ink);line-height:1.6;padding:32px 20px 80px;
-webkit-font-smoothing:antialiased}
.wrap{max-width:1040px;margin:0 auto}
header{margin-bottom:28px}
h1{font-size:26px;font-weight:700;letter-spacing:-.5px}
.meta{color:var(--sub);font-size:13px;margin-top:6px}
/* disk overview */
.overview{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);
padding:24px;margin-bottom:24px}
.bar{height:16px;border-radius:8px;background:var(--line);overflow:hidden;display:flex;margin:16px 0 12px}
.bar i{display:block;height:100%}
.bar .used{background:linear-gradient(90deg,#165dff,#4080ff)}
.bar i.seg-green{background:var(--green)}
.bar i.seg-yellow{background:var(--yellow)}
.bar i.seg-red{background:var(--red)}
.bar i.seg-blue{background:var(--accent)}
.bar i+i{border-left:1.5px solid #fff}
.lead{font-size:14px;line-height:1.7;margin-bottom:12px}
.stats{display:flex;flex-wrap:wrap;gap:24px}
.stat .k{font-size:12px;color:var(--sub)}
.stat .v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
.sysgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
gap:10px 24px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line)}
.sysgrid div{font-size:13px}
.sysgrid span{color:var(--sub)}
.pills{margin:14px 0 0}
.bar-label{font-size:12px;color:var(--sub);margin-bottom:4px}
.other-disks{margin-top:16px;padding-top:14px;border-top:1px solid var(--line)}
.other-disks .label{font-size:12px;color:var(--sub);font-weight:600;margin-bottom:8px}
.odisk{display:flex;align-items:center;gap:12px;margin:6px 0;font-size:13px}
.odisk-name{width:46px;font-weight:600;font-variant-numeric:tabular-nums}
.obar{flex:1;height:10px;border-radius:6px;background:var(--line);overflow:hidden}
.obar i{display:block;height:100%;background:linear-gradient(90deg,#165dff,#4080ff)}
.odisk-num{color:var(--sub);font-variant-numeric:tabular-nums;white-space:nowrap}
/* sections */
.sec{margin-bottom:28px}
.sec h2{font-size:18px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.sec h2 .count{font-size:13px;color:var(--sub);font-weight:500}
.sec-actions{margin-left:auto;display:flex;align-items:center;gap:8px}
.sec-actions .del-btn{padding:5px 12px;font-size:12px}
.sec-status{font-size:12px}
/* top5 table */
table{width:100%;border-collapse:collapse;background:var(--card);
border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)}
th,td{text-align:left;padding:12px 14px;font-size:13px;border-bottom:1px solid var(--line)}
th{background:#fafbfc;color:var(--sub);font-weight:600;font-size:12px;white-space:nowrap}
td.type{white-space:nowrap}
.table-scroll{overflow-x:auto;border-radius:var(--radius);box-shadow:var(--shadow)}
.table-scroll table{min-width:720px;box-shadow:none}
tr:last-child td{border-bottom:none}
td.size{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
td.path{font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--sub);word-break:break-all}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%}
.dot.green{background:var(--green)} .dot.yellow{background:var(--yellow)} .dot.red{background:var(--red)} .dot.blue{background:var(--accent)}
/* item cards */
.item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);
margin-bottom:12px;border-left:4px solid var(--line);overflow:hidden}
.item.green{border-left-color:var(--green)}
.item.yellow{border-left-color:var(--yellow)}
.item.red{border-left-color:var(--red)}
.item-head{display:flex;align-items:center;gap:12px;padding:16px 18px;cursor:pointer;user-select:none}
.item-head:hover{background:#fafbfc}
.item-name{font-weight:600;font-size:15px;flex:1}
.item-badge{font-size:12px;font-weight:600;color:var(--green);background:var(--green-bg);
padding:2px 10px;border-radius:20px;white-space:nowrap}
.item-badge:empty{display:none}
.item.cleaned .item-name{text-decoration:line-through;color:var(--sub);font-weight:500}
.item.cleaned .item-size{color:var(--sub)}
.item.cleaned{opacity:.85}
.item-size{font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}
.chev{color:var(--sub);transition:transform .2s;font-size:12px}
.item.open .chev{transform:rotate(90deg)}
.item-body{display:none;padding:0 18px 18px;font-size:14px}
.item.open .item-body{display:block}
.item-path{font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--sub);
word-break:break-all;margin-bottom:12px;padding:8px 10px;background:#fafbfc;border-radius:8px}
.field{margin:10px 0}
.field .label{font-size:12px;color:var(--sub);font-weight:600;margin-bottom:3px}
.tag{display:inline-block;background:var(--accent-bg);color:var(--accent);
font-size:12px;padding:2px 8px;border-radius:6px;margin:2px 4px 2px 0}
.risk{background:var(--red-bg);color:var(--red);padding:8px 12px;border-radius:8px;
font-size:13px;margin-top:10px}
/* command block */
.cmd{position:relative;background:#1d2129;color:#e5e6eb;border-radius:10px;
padding:14px 14px;margin:8px 0;font-family:"SF Mono",ui-monospace,monospace;
font-size:12.5px;line-height:1.7;overflow-x:auto;white-space:pre}
.cmd .copy{position:absolute;top:8px;right:8px;background:#2d3138;color:#c9cdd4;
border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;
font-family:inherit}
.cmd .copy:hover{background:#3d424a;color:#fff}
.cmd .copy.done{background:var(--green);color:#fff}
.cmd-label{font-size:11px;color:var(--sub);margin:10px 0 2px}
/* one-click delete actions */
.del-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px}
.del-btn{border:none;border-radius:8px;padding:7px 16px;font-size:13px;font-weight:600;cursor:pointer}
.del-btn.trash{background:var(--accent-bg);color:var(--accent)}
.del-btn.trash:hover{background:#d4e4ff}
.del-btn.danger{background:var(--red-bg);color:var(--red)}
.del-btn.danger:hover{background:#ffd4cc}
.del-btn.open{background:#f2f3f5;color:var(--ink)}
.del-btn.open:hover{background:#e5e6eb}
.del-btn:disabled{opacity:.45;cursor:default}
.del-status{font-size:13px}
.del-status.ok{color:var(--green);font-weight:600}
.del-status.err{color:var(--red)}
.del-note{font-size:12px;color:var(--sub);line-height:1.6;margin-top:8px}
.del-note b{color:var(--ink)}
/* summary */
.summary{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px}
.summary h3{font-size:15px;margin:18px 0 8px} .summary h3:first-child{margin-top:0}
.summary ul{padding-left:20px} .summary li{margin:4px 0;font-size:14px}
.pill{display:inline-flex;align-items:center;gap:6px;background:#fafbfc;border:1px solid var(--line);
border-radius:20px;padding:6px 14px;font-size:13px;margin:4px 8px 4px 0;font-weight:600}
.pill b{font-variant-numeric:tabular-nums}
.note{font-size:12px;color:var(--sub);margin-top:8px}
.denied{background:var(--yellow-bg);border:1px solid #ffd591;border-radius:10px;
padding:12px 14px;font-size:13px;margin-top:12px}
footer{text-align:center;color:var(--sub);font-size:12px;margin-top:40px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>存储分析报告</h1>
<div class="meta" id="meta"></div>
</header>
<div id="app"></div>
<footer>storage-analyzer · 只读分析 · 删除命令请自行在终端确认后执行</footer>
</div>
<script>
const DATA = __REPORT_DATA__;
const DELETE = __DELETE_CONFIG__; // null=静态只读报告;{token,endpoint}=server.py 提供一键删除
const FM = (DATA.system && /win/i.test(DATA.system.os||'')) ? '资源管理器' : '访达'; // 文件管理器名,按系统
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
// "约" 已表示估算,去掉冗余的"(估算…)"括号让大小显示更干净专业
const cleanSize = s => String(s==null?'':s).replace(/[((]\s*估算[^))]*[))]/g, '').trim();
function copyBtn(text){
const id='c'+Math.random().toString(36).slice(2);
// store raw text on element via data attribute (base64 to survive quotes)
return `<button class="copy" data-cmd="${btoa(unescape(encodeURIComponent(text)))}">复制</button>`;
}
function cmdBlock(c){
const label = c.label ? `<div class="cmd-label">${esc(c.label)}</div>` : '';
return `${label}<div class="cmd">${copyBtn(c.cmd)}${esc(c.cmd)}</div>`;
}
function renderSystem(s, sm){
document.getElementById('meta').textContent =
`生成于 ${DATA.generated_at} · 扫描耗时 ${DATA.scan_seconds||'?'}s`;
// 已用部分分段:绿(可自动清)+橙(需手动)+红(已识别的不建议动项)+蓝(其他/系统等未归类的已用),余下灰底为可用
const total = parseGB(s.disk_total) || 1;
const used = parseGB(s.disk_used);
const ts = sm && sm.tier_stats;
const g = ts ? parseGB(ts.green) : 0;
const y = ts ? parseGB(ts.yellow) : 0;
const r = ts ? parseGB(ts.red) : 0;
const other = Math.max(0, used - g - y - r); // 既非红黄绿、也未单独列出的已用空间(系统+零碎文件)
let bar;
if(g > 0 || y > 0 || r > 0){
const seg=(v,cls,label)=> v>0?`<i class="${cls}" style="width:${(v/total*100).toFixed(2)}%" title="${label}:约 ${v.toFixed(1)} GB"></i>`:'';
bar = seg(g,'seg-green','可自动清')+seg(y,'seg-yellow','需手动')+seg(r,'seg-red','谨慎清理')+seg(other,'seg-blue','系统及其他');
} else {
bar = `<i class="used" style="width:${(used/total*100).toFixed(2)}%"></i>`;
}
const fmtGB = v => v>0?`约 ${v.toFixed(1)} GB`:'-';
const otherPill = other>0?`<span class="pill"><span class="dot blue"></span>系统及其他 <b>${fmtGB(other)}</b></span>`:'';
let pills='';
if(sm && sm.tier_stats){
pills=`<div class="pills">
<span class="pill"><span class="dot green"></span>可自动清 <b>${fmtGB(g)}</b></span>
<span class="pill"><span class="dot yellow"></span>需手动 <b>${fmtGB(y)}</b></span>
<span class="pill"><span class="dot red"></span>谨慎清理 <b>${fmtGB(r)}</b></span>
${otherPill}
</div>`;
}
// 多盘符(Windows):主盘用上面的分段条,其余盘紧凑列出
const allDisks = s.disks || [];
const others = allDisks.filter(d => d.name !== s.disk_name);
const barLabel = allDisks.length > 1
? `<div class="bar-label">系统盘 ${esc(s.disk_name||'')} · 已用部分按清理分级着色</div>` : '';
const otherDisksBlock = others.length ? `<div class="other-disks">
<div class="label">其他磁盘</div>
${others.map(d=>{
const u=parseGB(d.used), t=parseGB(d.total)||1;
const pct=Math.min(100, u/t*100).toFixed(1);
return `<div class="odisk"><span class="odisk-name">${esc(d.name)}</span>
<div class="obar"><i style="width:${pct}%"></i></div>
<span class="odisk-num">${esc(d.used)} / ${esc(d.total)}</span></div>`;
}).join('')}</div>` : '';
return `<div class="overview">
<div class="stats">
<div class="stat"><div class="k">总容量</div><div class="v">${esc(s.disk_total)}</div></div>
<div class="stat"><div class="k">已用</div><div class="v">${esc(s.disk_used)}</div></div>
<div class="stat"><div class="k">可用</div><div class="v">${esc(s.disk_free)}</div></div>
${s.purgeable?`<div class="stat"><div class="k">可清除(系统)</div><div class="v">${esc(s.purgeable)}</div></div>`:''}
</div>
${barLabel}
<div class="bar">${bar}</div>
${pills}
${otherDisksBlock}
<div class="sysgrid">
<div><span>系统 </span>${esc(s.os)} (${esc(s.build)})</div>
<div><span>架构 </span>${esc(s.arch)}</div>
<div><span>文件系统 </span>${esc(s.filesystem)}</div>
<div><span>用户 </span>${esc(s.user)}</div>
<div><span>主目录 </span>${esc(s.home)}</div>
</div>
</div>`;
}
// 从 "约 27.8 GB(…)" / "300.3 GB" 这类字符串里取数值,统一换算成 GB
function parseGB(str){
if(!str) return 0;
const m=String(str).match(/([\d.]+)\s*(TB|GB|MB)?/i);
if(!m) return 0;
let v=parseFloat(m[1]); const u=(m[2]||'GB').toUpperCase();
if(u==='TB') v*=1024; else if(u==='MB') v/=1024;
return v;
}
function renderTop5(rows){
if(!rows||!rows.length) return '';
const body = rows.map(r=>`<tr>
<td>${esc(r.rank)}</td>
<td><span class="dot ${esc(r.tier)}"></span></td>
<td class="size">${esc(cleanSize(r.size))}</td>
<td class="type">${esc(r.type)}</td>
<td><b>${esc(r.name)}</b></td>
<td class="path">${esc(r.path)}</td>
<td>${esc(r.note)}</td>
</tr>`).join('');
return `<div class="sec"><h2>占用排行 Top 5</h2>
<div class="table-scroll"><table><thead><tr><th>#</th><th>级</th><th>大小</th><th>类型</th><th>项目</th><th>路径</th><th>说明</th></tr></thead>
<tbody>${body}</tbody></table></div></div>`;
}
function renderGreen(items){
if(!items||!items.length) return '';
const cards = items.map(it=>{
const procs=(it.kill_processes&&it.kill_processes.length)
? `<div class="field"><div class="label">清理前需关闭</div>${it.kill_processes.map(p=>`<span class="tag">${esc(p)}</span>`).join('')}</div>`
: `<div class="field"><div class="label">清理前需关闭</div><span class="note">无需关闭任何进程</span></div>`;
const cmds=(it.commands||[]).map(cmdBlock).join('');
return card('green', it.name, it.size_estimate, it.path,
procs + `<div class="field"><div class="label">清理命令(自行确认后执行)</div>${cmds}</div>` + delBlock(it));
}).join('');
return section('🟢 可自动清理(纯缓存/临时文件,可再生)', items.length, cards, greenBatchActions(items));
}
// 分组级批量按钮:绿灯每项都是无条件安全的可再生缓存,给「全部移废纸篓/全部删除」一次清完。
// 橙灯/红灯需逐项判断,不给批量。仅 server.py 模式且至少一项有 trash_paths 时渲染。
function greenBatchActions(items){
if(!DELETE) return '';
if(!items.some(it => it.trash_paths && it.trash_paths.length)) return '';
return `<span class="sec-actions">
<button class="del-btn trash" onclick="doBatch(this,'trash')">全部移废纸篓</button>
<button class="del-btn danger" onclick="doBatch(this,'rm')">全部删除</button>
<span class="del-status sec-status"></span>
</span>`;
}
// 批量处理:收集本组所有未清理项的 trash_paths,确认一次,一次性提交。
function doBatch(btn, mode){
const secActions = btn.closest('.sec-actions');
const sec = btn.closest('.sec');
const status = secActions.querySelector('.del-status');
const wraps = [...sec.querySelectorAll('.item.green .del-actions')]
.filter(w => !w.closest('.item').classList.contains('cleaned'));
if(!wraps.length){ status.textContent='没有可清理的项了'; status.className='del-status sec-status'; return; }
const allPaths=[]; let gb=0;
wraps.forEach(w=>{
JSON.parse(decodeURIComponent(escape(atob(w.dataset.paths)))).forEach(p=>allPaths.push(p));
const sz=w.closest('.item').querySelector('.item-size'); if(sz) gb+=parseGB(sz.textContent);
});
const verb = mode==='rm' ? '直接删除(不可恢复)' : '移到废纸篓(可逆,清空后释放)';
const sizeStr = gb>0 ? `,约 ${gb.toFixed(1)} GB` : '';
if(!confirm(`确认将这组 ${wraps.length} 项缓存全部${verb}?\n共 ${allPaths.length} 个路径${sizeStr}。`)) return;
postAction(secActions, allPaths, mode).then(res=>{
if(res.ok){
status.textContent = mode==='rm' ? `✓ 已删除 ${wraps.length} 项` : `✓ 已移 ${wraps.length} 项到废纸篓`;
status.className='del-status sec-status ok';
wraps.forEach(w=>{
const item=w.closest('.item'); item.classList.add('cleaned');
const badge=item.querySelector('.item-head .item-badge');
if(badge) badge.textContent = mode==='rm' ? '已删除' : '已移废纸篓';
const ist=w.querySelector('.del-status');
if(ist){ ist.textContent = mode==='rm'?'✓ 已删除':'✓ 已移到废纸篓'; ist.className='del-status ok'; }
w.querySelectorAll('.del-btn.trash,.del-btn.danger').forEach(b=>b.remove());
});
secActions.querySelectorAll('.del-btn').forEach(b=>b.remove());
} else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status sec-status err'; }
});
}
// 一键删除按钮:仅当 server.py 提供了 DELETE 配置、且该项有具体 trash_paths 时渲染
function delBlock(it){
if(!DELETE || !it.trash_paths || !it.trash_paths.length) return '';
const payload = btoa(unescape(encodeURIComponent(JSON.stringify(it.trash_paths))));
return `<div class="field del-actions" data-paths="${payload}">
<div class="label">在网页上直接处理(${it.trash_paths.length} 个路径)</div>
<div class="del-row">
<button class="del-btn trash" onclick="doDelete(this,'trash')">移到废纸篓</button>
<button class="del-btn danger" onclick="doDelete(this,'rm')">直接删除</button>
<span class="del-status"></span>
</div>
<div class="del-note"><b>移到废纸篓</b>:可逆,删错能从${FM}废纸篓捞回,但要清空废纸篓才真正释放空间。<b>直接删除</b>:立即释放、不可恢复。绿灯都是可再生缓存,删了会自动重建。</div>
</div>`;
}
function postAction(wrap, paths, mode){
const status = wrap.querySelector('.del-status');
const btns = wrap.querySelectorAll('.del-btn');
status.textContent = '处理中…'; status.className='del-status';
btns.forEach(b=>b.disabled=true);
return fetch(DELETE.endpoint, {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:DELETE.token, paths, mode})})
.then(r=>r.json()).then(res=>{ btns.forEach(b=>b.disabled=false); return res; })
.catch(e=>{ btns.forEach(b=>b.disabled=false); return {ok:false, error:'连接失败:'+e}; });
}
function doDelete(btn, mode){
const wrap = btn.closest('.del-actions');
const paths = JSON.parse(decodeURIComponent(escape(atob(wrap.dataset.paths))));
const verb = mode==='rm' ? '直接删除(不可恢复)' : '移到废纸篓(访达可恢复)';
if(!confirm(`确认${verb}以下 ${paths.length} 个路径?\n\n`+paths.join('\n'))) return;
postAction(wrap, paths, mode).then(res=>{
const status = wrap.querySelector('.del-status');
if(res.ok){
status.textContent = mode==='rm' ? '✓ 已删除,空间已释放' : '✓ 已移到废纸篓(访达可恢复,清空后释放空间)';
status.className='del-status ok';
wrap.querySelectorAll('.del-btn.trash,.del-btn.danger').forEach(b=>b.remove());
// 折叠状态下也能看到:标题行打上徽标 + 整行变灰
const item = wrap.closest('.item');
if(item){
item.classList.add('cleaned');
const badge = item.querySelector('.item-head .item-badge');
if(badge) badge.textContent = mode==='rm' ? '已删除' : '已移废纸篓';
}
} else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status err'; }
});
}
function doOpen(btn){
const wrap = btn.closest('.del-actions');
const paths = JSON.parse(decodeURIComponent(escape(atob(wrap.dataset.open))));
postAction(wrap, paths, 'open').then(res=>{
const status = wrap.querySelector('.del-status');
if(res.ok){ status.textContent='✓ 已在'+FM+'打开,自行审查删除'; status.className='del-status ok'; }
else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status err'; }
});
}
const b64 = o => btoa(unescape(encodeURIComponent(JSON.stringify(o))));
function renderYellow(items){
if(!items||!items.length) return '';
const cards = items.map(it=>card('yellow', it.name, it.size, it.path,
`<div class="field"><div class="label">内容画像</div>${esc(it.content_profile)}</div>
<div class="field"><div class="label">为什么需要人工判断</div>${esc(it.why_manual)}</div>
<div class="field"><div class="label">处置路径</div>${esc(it.disposal)}</div>
<div class="risk">⚠️ ${esc(it.risk)}</div>` + yellowActions(it))).join('');
return section('🟡 需你参与的手动清理(含用户数据)', items.length, cards);
}
// 橙灯操作:打开文件夹(去自己审查删)+ 仅当有核实过的安全子路径时给「移到废纸篓」(只可逆,不直接删)
function yellowActions(it){
if(!DELETE) return '';
const hasTrash = it.trash_paths && it.trash_paths.length;
const trashBtn = hasTrash
? `<button class="del-btn trash" onclick="doDelete(this,'trash')">移到废纸篓(仅安全部分)</button>` : '';
const trashNote = hasTrash
? `<b>移到废纸篓(仅安全部分)</b>:只移这一项核实过可安全清理的子目录,可逆、需清空废纸篓才释放空间。` : '';
const openNote = it.open_note ? esc(it.open_note) : '';
return `<div class="field del-actions" data-paths="${hasTrash?b64(it.trash_paths):''}" data-open="${b64([it.path])}">
<div class="label">在网页上处理</div>
<div class="del-row">
<button class="del-btn open" onclick="doOpen(this)">在${FM}里打开</button>
${trashBtn}
<span class="del-status"></span>
</div>
<div class="del-note"><b>在${FM}里打开</b>:只打开文件夹供你自己查看,不删任何东西。注意 App 托管的数据(B站/微信/Chrome 等)在${FM}里是程序内部格式、文件名看不懂,想清优先去对应 App 内删。${trashNote}${openNote?'<br>'+openNote:''}</div>
</div>`;
}
function renderRed(items){
if(!items||!items.length) return '';
const cards = items.map(it=>card('red', it.name, it.size||'', it.path,
`<div class="field"><div class="label">为什么不建议手删</div>${esc(it.why_keep)}</div>
<div class="field"><div class="label">卸载 / 释放建议</div>${esc(it.indirect_release)}</div>
${it.auto_reclaim?`<div class="field"><div class="label">是否自动回收</div>${esc(it.auto_reclaim)}</div>`:''}` + redActions(it))).join('');
return section('🔴 谨慎清理(建议走正规卸载,别手动拖删)', items.length, cards);
}
// 红灯操作:只给「在文件管理器里打开并选中该 App」,让用户自己正规卸载(不后台代删,App 需谨慎)
function redActions(it){
if(!DELETE || !it.app_paths || !it.app_paths.length) return '';
return `<div class="field del-actions" data-open="${b64(it.app_paths)}">
<div class="label">在网页上处理</div>
<div class="del-row">
<button class="del-btn open" onclick="doOpen(this)">在${FM}里打开(去卸载)</button>
<span class="del-status"></span>
</div>
<div class="del-note"><b>在${FM}里打开</b>:定位到该应用,你可以右键「移到废纸篓」卸载,或用它自带的卸载器。应用本体放在系统目录、可能需要管理员密码,所以由你在${FM}里手动操作更稳妥,本页不代删。</div>
</div>`;
}
function card(tier,name,size,path,inner){
return `<div class="item ${tier}">
<div class="item-head" onclick="this.parentNode.classList.toggle('open')">
<span class="dot ${tier}"></span>
<span class="item-name">${esc(name)}</span>
<span class="item-badge"></span>
${size?`<span class="item-size">${esc(cleanSize(size))}</span>`:''}
<span class="chev">▶</span>
</div>
<div class="item-body">
<div class="item-path">${esc(path)}</div>
${inner}
</div></div>`;
}
function section(title,count,inner,actions){
return `<div class="sec"><h2>${esc(title)} <span class="count">${count} 项</span>${actions||''}</h2>${inner}</div>`;
}
const sumList=a=>(a&&a.length)?`<ul>${a.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'';
// 执行建议:放在 Top5 之后、三级明细之前(先看处方,再看每项怎么做)
// 开头用 overview 一句洞察作引子,下面是优先级清单
function renderPriority(sm){
if(!sm || (!sm.priority && !sm.overview)) return '';
const intro = sm.overview?`<p class="lead">${esc(sm.overview)}</p>`:'';
const body = sm.priority?(Array.isArray(sm.priority)?sumList(sm.priority):`<p>${esc(sm.priority)}</p>`):'';
return `<div class="sec"><h2>执行建议</h2><div class="summary">${intro}${body}</div></div>`;
}
// 长期优化建议:留在报告最后
function renderLongTerm(sm){
if(!sm || !sm.long_term || !sm.long_term.length) return '';
return `<div class="sec"><h2>长期优化建议</h2><div class="summary">${sumList(sm.long_term)}</div></div>`;
}
function renderDenied(){
if(!DATA.denied||!DATA.denied.length) return '';
return `<div class="denied"><b>以下目录权限不足未能读取,可能遗漏体量:</b><br>${DATA.denied.map(esc).join('<br>')}</div>`;
}
// mount
const app=document.getElementById('app');
app.innerHTML = renderSystem(DATA.system, DATA.summary)
+ renderTop5(DATA.top5)
+ renderPriority(DATA.summary)
+ renderGreen(DATA.green)
+ renderYellow(DATA.yellow)
+ renderRed(DATA.red)
+ renderDenied()
+ renderLongTerm(DATA.summary);
// copy handlers (delegated)
document.addEventListener('click', e=>{
const b=e.target.closest('.copy'); if(!b) return;
e.stopPropagation();
const text=decodeURIComponent(escape(atob(b.dataset.cmd)));
navigator.clipboard.writeText(text).then(()=>{
b.textContent='已复制'; b.classList.add('done');
setTimeout(()=>{b.textContent='复制';b.classList.remove('done');},1500);
});
});
</script>
</body>
</html>
macOS 数据布局与分级参考
分析 macOS 扫描结果时读这份。讲"东西存在哪、怎么辨认、归哪一级"。
关键目录
| 目录 | 装什么 | 典型分级 |
|---|---|---|
~/Library/Caches/* | 应用/工具缓存(浏览器、Homebrew、pip、playwright) | 🟢 可自动清 |
~/.cache/*、~/.npm、~/.cargo、~/.gradle、~/.m2 | 开发缓存 | 🟢 |
~/Library/Developer/Xcode/DerivedData、CoreSimulator | Xcode 构建/模拟器 | 🟢 |
~/Library/Containers/<UUID 或 bundleid> | 沙盒应用数据(聊天记录、离线视频、设置) | 🟡 多为用户数据 |
~/Library/Application Support/* | 应用数据(Chrome Profile、Claude VM、飞书) | 🟡 |
~/Downloads 里的 .dmg/.pkg | 安装包残留 | 🟢 |
/Applications/*.app | 应用本体 | 🔴 仅当重复/想卸时上灯,否则归蓝色 |
| 系统文件、APFS 本地快照 | 系统 | 不上灯,归蓝色"系统及其他" |
辨认"神秘 UUID 容器"
~/Library/Containers/ 下 UUID 命名的大目录,要查清属于哪个 App:
ls进Data/Documents/、Data/Library/,找带 bundle id 的子目录(如com.bilibili.bbad→ 哔哩哔哩)- 大头常藏在隐藏目录(如
.Downloads/里的.bilitask离线视频) - 仍只读,别动文件
间接释放(写进 long_term,不上红灯)
- 系统"可清除空间"磁盘紧张时自动回收
- 重启释放部分 swap / 临时快照
brew cleanup --prune=all、清 Xcode DerivedData- 调整 Time Machine 本地快照保留策略
删除机制
server.py 在 macOS 用 osascript 调访达入废纸篓;首次弹自动化授权,点允许。
Windows 数据布局与分级参考
分析 Windows 扫描结果时读这份。讲"东西存在哪、怎么辨认、归哪一级"。 注意:Windows 代码路径在 macOS 上无法验证,分析时对路径存在性保持谨慎。
多盘符
Windows 通常多个盘(C:、D:…)。磁盘总览会列出所有盘,但分析和清理聚焦系统盘 C:——缓存、AppData、临时文件几乎都在 C:。其他盘(D: 等)一般是用户自存的资料/游戏,归 🟡 让用户自己判断,不要自动给删除按钮。
关键目录
| 目录(环境变量) | 装什么 | 典型分级 |
|---|---|---|
%LOCALAPPDATA%(C:\Users\<u>\AppData\Local) | 浏览器缓存、应用数据、Temp,最大头 | 缓存 🟢 / 应用数据 🟡 |
%LOCALAPPDATA%\Temp、%TEMP% | 临时文件 | 🟢 |
%APPDATA%(Roaming) | 应用配置/数据 | 🟡 |
浏览器缓存 %LOCALAPPDATA%\Google\Chrome\User Data\*\Cache、Edge 同构 | 浏览器缓存 | 🟢 |
浏览器 User Data\<Profile>(非 Cache 部分) | 书签/登录态 | 🟡 |
%USERPROFILE%\.cache、.npm、.gradle、.m2、.nuget\packages、%LOCALAPPDATA%\pip\Cache、Yarn | 开发缓存 | 🟢 |
C:\Program Files、Program Files (x86) | 应用本体 | 🔴 仅重复/想卸时上灯,否则归蓝色 |
%USERPROFILE%\Downloads 的安装包 | exe/msi 残留 | 🟢 |
C:\$Recycle.Bin | 回收站 | 🟡 提示用户清空 |
系统占用(不上灯,归蓝色"系统及其他",间接释放写 long_term)
C:\Windows\WinSxS:组件存储,绝不能手删,用DISM /Online /Cleanup-Image /StartComponentCleanupC:\Windows\SoftwareDistribution\Download:Windows Update 缓存,用磁盘清理处理hiberfil.sys(休眠)、pagefile.sys(虚拟内存):系统管理,别手动删- 间接释放:设置 > 系统 > 存储 > 存储感知;
cleanmgr(磁盘清理);扩展磁盘清理选 Windows 更新清理
删除机制
server.py 在 Windows 用 ctypes 调 SHFileOperationW(FOF_ALLOWUNDO) 送进回收站;纯标准库。🟢 项的 trash_paths 应在用户配置文件(%USERPROFILE%)目录内,便于白名单与 HOME 越界校验通过。
#!/usr/bin/env python3
"""Inject an analysis JSON into the HTML template -> a standalone report.
Usage:
build_report.py <analysis.json> [output.html]
The analysis JSON is produced by Claude after interpreting scan.py output.
Schema (all sections optional except system):
{
"generated_at": "2026-05-28 12:00:00",
"scan_seconds": 42.1,
"system": {os, build, arch, user, home, filesystem,
disk_total, disk_used, disk_free, purgeable},
"top5": [{rank, tier(green|yellow|red), size, type, name, path, note}],
"green": [{name, path, size_estimate, kill_processes:[], trash_paths:[...], commands:[{label,cmd}]}],
"yellow": [{name, path, size, content_profile, why_manual, disposal, risk, trash_paths:[...]?, open_note?}],
"red": [{name, path, size, why_keep, indirect_release, auto_reclaim, app_paths:[...]?}],
"denied": ["/path/one", ...],
"summary": {overview, tier_stats:{green,yellow,red}, priority:[...], long_term:[...]}
}
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(HERE, "..", "assets", "report_template.html")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
src = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser(
"~/Desktop/storage-report.html")
with open(src, "r", encoding="utf-8") as f:
data = json.load(f)
with open(TEMPLATE, "r", encoding="utf-8") as f:
tpl = f.read()
blob = json.dumps(data, ensure_ascii=False)
# 静态报告不带删除能力(DELETE=null),删除按钮只在 server.py 服务时出现
html = tpl.replace("__REPORT_DATA__", blob).replace("__DELETE_CONFIG__", "null")
with open(out, "w", encoding="utf-8") as f:
f.write(html)
print(f"报告已生成: {out}")
print(f"打开: open '{out}'")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Read-only storage scanner (macOS + Windows).
Collects disk usage, system info, and per-directory size breakdowns for the
hot spots that typically eat disk, and emits one JSON blob to stdout for Claude
to interpret and classify. Auto-detects the OS and scans the right locations.
STRICTLY READ-ONLY: only sizes/lists/reads metadata. Never creates, moves, or
deletes anything.
Output shape (same on both OSes):
{
"generated_at", "scan_seconds",
"system": {os, build, arch, user, home, filesystem,
disk_total, disk_used, disk_free, purgeable,
disks: [{name, total, used, free}]}, # all volumes/drives
"groups": { "<group>": [{name, path, size_kb, size_h}], ... }
}
"""
import json
import os
import shutil
import sys
import time
HOME = os.path.expanduser("~")
def human(kb):
"""KB number -> human string like '12.3 GB'."""
n = float(kb) * 1024
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.1f} {unit}" if unit not in ("B", "KB") else f"{int(n)} {unit}"
n /= 1024
# ======================================================================
# macOS
# ======================================================================
import re
import subprocess
def run(cmd, timeout=180):
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
except Exception:
return ""
def du_children(path, min_kb=51200, limit=40):
"""Size every immediate child of `path` via du, sorted desc. macOS."""
if not os.path.isdir(path):
return []
results = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return [{"name": "(permission denied)", "path": path,
"size_kb": 0, "size_h": "?", "denied": True}]
for name in entries:
if name in (".", ".."):
continue
child = os.path.join(path, name)
if os.path.islink(child):
continue
out = run(["du", "-sk", child], timeout=120)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < min_kb:
continue
results.append({"name": name, "path": child, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results[:limit]
MAC_TARGETS = [
("home", HOME, 102400),
("library", os.path.join(HOME, "Library"), 51200),
("caches", os.path.join(HOME, "Library/Caches"), 51200),
("containers", os.path.join(HOME, "Library/Containers"), 51200),
("group_containers", os.path.join(HOME, "Library/Group Containers"), 51200),
("app_support", os.path.join(HOME, "Library/Application Support"), 51200),
("applications", "/Applications", 102400),
("downloads", os.path.join(HOME, "Downloads"), 51200),
("dev_caches", None, 51200),
]
MAC_DEV_CACHE_PATHS = [
"~/Library/Caches/pip", "~/Library/Caches/uv", "~/.cache", "~/.cargo",
"~/.npm", "~/.pnpm-store", "~/.gradle", "~/.m2",
"~/Library/Developer/Xcode/DerivedData", "~/Library/Developer/CoreSimulator",
"~/Library/Developer/Xcode/iOS DeviceSupport", "~/Library/pnpm",
"~/go/pkg", "~/.docker",
]
def dev_caches_macos():
results = []
for p in MAC_DEV_CACHE_PATHS:
path = os.path.expanduser(p)
if not os.path.isdir(path):
continue
out = run(["du", "-sk", path], timeout=180)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < 51200:
continue
results.append({"name": os.path.basename(path.rstrip("/")) or path,
"path": path, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results
def system_info_macos():
info = {}
info["os"] = "macOS " + run(["sw_vers", "-productVersion"]).strip()
info["build"] = run(["sw_vers", "-buildVersion"]).strip()
arch = run(["uname", "-m"]).strip()
brand = run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
info["arch"] = (f"Apple Silicon (arm64){' / ' + brand if brand else ''}"
if arch == "arm64" else f"{arch}{' / ' + brand if brand else ''}")
info["user"] = os.environ.get("USER", "") or run(["whoami"]).strip()
info["home"] = HOME
total, used, free = "?", "?", "?"
try:
t, u, f = shutil.disk_usage("/")
total, used, free = human(t // 1024), human(u // 1024), human(f // 1024)
except Exception:
pass
info["disk_total"], info["disk_used"], info["disk_free"] = total, used, free
dinfo = run(["diskutil", "info", "/"])
fs = re.search(r"File System Personality:\s*(.+)", dinfo)
info["filesystem"] = fs.group(1).strip() if fs else "APFS"
pm = re.search(r"Purgeable Space:\s*([\d.,]+ \w+)", dinfo)
info["purgeable"] = pm.group(1).strip() if pm else ""
info["disk_name"] = "Macintosh HD"
info["disks"] = [{"name": "Macintosh HD", "total": total, "used": used, "free": free}]
return info
def scan_macos():
system = system_info_macos()
groups = {}
for key, path, floor in MAC_TARGETS:
groups[key] = dev_caches_macos() if key == "dev_caches" else du_children(path, min_kb=floor)
return system, groups
# ======================================================================
# Windows (UNTESTED on this build — stdlib only: os, shutil, ctypes)
# ======================================================================
def dir_size_bytes(path):
"""Recursive size in bytes via os.scandir. Skips symlinks and unreadable."""
total = 0
try:
with os.scandir(path) as it:
for e in it:
try:
if e.is_symlink():
continue
if e.is_file(follow_symlinks=False):
total += e.stat(follow_symlinks=False).st_size
elif e.is_dir(follow_symlinks=False):
total += dir_size_bytes(e.path)
except (PermissionError, OSError):
continue
except (PermissionError, OSError):
pass
return total
def scandir_children(path, min_kb=51200, limit=40):
"""Size every immediate child of `path` via os.scandir. Windows."""
if not path or not os.path.isdir(path):
return []
results = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return [{"name": "(permission denied)", "path": path,
"size_kb": 0, "size_h": "?", "denied": True}]
for name in entries:
child = os.path.join(path, name)
if os.path.islink(child):
continue
try:
kb = (os.path.getsize(child) if os.path.isfile(child)
else dir_size_bytes(child)) // 1024
except (PermissionError, OSError):
continue
if kb < min_kb:
continue
results.append({"name": name, "path": child, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results[:limit]
def list_drives_windows():
drives = []
import string
for letter in string.ascii_uppercase:
root = f"{letter}:\\"
if os.path.exists(root):
try:
t, u, f = shutil.disk_usage(root)
drives.append({"name": root, "total": human(t // 1024),
"used": human(u // 1024), "free": human(f // 1024)})
except Exception:
continue
return drives
def system_info_windows():
import platform
info = {}
info["os"] = platform.system() + " " + platform.release()
info["build"] = platform.version()
info["arch"] = os.environ.get("PROCESSOR_ARCHITECTURE", platform.machine())
info["user"] = os.environ.get("USERNAME", "")
info["home"] = os.environ.get("USERPROFILE", HOME)
sysdrive = os.environ.get("SystemDrive", "C:") + "\\"
total, used, free = "?", "?", "?"
try:
t, u, f = shutil.disk_usage(sysdrive)
total, used, free = human(t // 1024), human(u // 1024), human(f // 1024)
except Exception:
pass
info["disk_total"], info["disk_used"], info["disk_free"] = total, used, free
info["filesystem"] = "NTFS"
info["purgeable"] = ""
info["disk_name"] = sysdrive
info["disks"] = list_drives_windows()
return info
def scan_windows():
profile = os.environ.get("USERPROFILE", HOME)
local = os.environ.get("LOCALAPPDATA", os.path.join(profile, "AppData", "Local"))
roaming = os.environ.get("APPDATA", os.path.join(profile, "AppData", "Roaming"))
targets = [
("user_profile", profile, 102400),
("appdata_local", local, 51200),
("appdata_roaming", roaming, 51200),
("temp", os.environ.get("TEMP", os.path.join(local, "Temp")), 51200),
("downloads", os.path.join(profile, "Downloads"), 51200),
("program_files", os.environ.get("ProgramFiles", r"C:\Program Files"), 102400),
("program_files_x86", os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), 102400),
]
groups = {}
for key, path, floor in targets:
groups[key] = scandir_children(path, min_kb=floor)
dev_paths = [
os.path.join(profile, ".cache"), os.path.join(profile, ".npm"),
os.path.join(profile, ".gradle"), os.path.join(profile, ".m2"),
os.path.join(profile, ".nuget", "packages"), os.path.join(profile, ".cargo"),
os.path.join(local, "pip", "Cache"), os.path.join(local, "Yarn"),
os.path.join(local, "uv"), os.path.join(local, "ms-playwright"),
os.path.join(local, "go-build"),
]
dev = []
for path in dev_paths:
if not os.path.isdir(path):
continue
try:
kb = dir_size_bytes(path) // 1024
except (PermissionError, OSError):
continue
if kb < 51200:
continue
dev.append({"name": os.path.basename(path.rstrip("\\/")) or path,
"path": path, "size_kb": kb, "size_h": human(kb)})
dev.sort(key=lambda r: r["size_kb"], reverse=True)
groups["dev_caches"] = dev
return system_info_windows(), groups
# ======================================================================
def main():
started = time.time()
if sys.platform == "darwin":
system, groups = scan_macos()
elif sys.platform.startswith("win"):
system, groups = scan_windows()
else:
print(json.dumps({"error": "unsupported_platform", "platform": sys.platform,
"message": "scan.py supports macOS and Windows only."},
ensure_ascii=False))
return
data = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"system": system,
"groups": groups,
"scan_seconds": round(time.time() - started, 1),
}
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Serve the storage report with a guarded one-click delete API (macOS + Windows).
Starts on 127.0.0.1 + a random port + a random per-session token, serves the
interactive report, and exposes POST /action to move green-tier paths to Trash
or delete them outright. Stop with Ctrl+C.
Usage:
server.py <analysis.json>
SAFETY MODEL — read before changing:
- Allowlist: only paths listed in this report's green items `trash_paths` are
accepted. Every request path is realpath-resolved and must be in the allowlist
AND under $HOME. Anything else is rejected. This is the core guard — the
endpoint cannot be used to delete arbitrary files.
- Bound to 127.0.0.1 only; every POST requires the session token; Host header
must be 127.0.0.1 (blocks DNS-rebinding from a malicious page).
- Two modes: "trash" (Finder -> Trash, reversible) and "rm" (immediate,
irreversible). The browser confirms each action before sending.
"""
import json
import os
import secrets
import shutil
import subprocess
import sys
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(HERE, "..", "assets", "report_template.html")
HOME = os.path.realpath(os.path.expanduser("~"))
TOKEN = secrets.token_urlsafe(24)
DATA = {}
TPL = ""
RM_ALLOW = set()
TRASH_ALLOW = set()
OPEN_ALLOW = set()
def expand(p):
return os.path.realpath(os.path.expanduser(p))
def load(src):
with open(src, encoding="utf-8") as f:
data = json.load(f)
with open(TEMPLATE, encoding="utf-8") as f:
tpl = f.read()
# 三套白名单,权限从严到宽:
# rm = 仅绿灯 trash_paths(可直接删的纯缓存)
# trash = 绿灯 + 橙灯 trash_paths(橙灯只准移废纸篓,不准直接删)
# open = trash 全集 + 橙灯 path + 红灯 app_paths(仅"在文件管理器打开",非破坏性)
rm_allow, trash_allow, open_allow = set(), set(), set()
for it in data.get("green", []):
for p in (it.get("trash_paths") or []):
rp = expand(p)
rm_allow.add(rp); trash_allow.add(rp); open_allow.add(rp)
for it in data.get("yellow", []):
for p in (it.get("trash_paths") or []):
rp = expand(p)
trash_allow.add(rp); open_allow.add(rp)
if it.get("path"):
rp = expand(it["path"])
if os.path.exists(rp):
open_allow.add(rp)
# 红灯只允许"打开"(应用本体在 /Applications,删除让用户在访达里自己卸)
for it in data.get("red", []):
for p in (it.get("app_paths") or []):
rp = expand(p)
if os.path.exists(rp):
open_allow.add(rp)
return data, tpl, rm_allow, trash_allow, open_allow
def move_to_trash(path):
if sys.platform == "darwin":
_trash_macos(path)
elif sys.platform.startswith("win"):
_trash_windows(path)
else:
raise OSError("移到废纸篓仅支持 macOS / Windows")
def _trash_macos(path):
# osascript Finder delete -> macOS Trash, recoverable. First run may prompt
# for Finder automation permission. Fall back to ~/.Trash move if it fails.
script = 'tell application "Finder" to delete (POSIX file %s as alias)' % json.dumps(path)
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if r.returncode != 0:
dest = os.path.join(HOME, ".Trash",
os.path.basename(path.rstrip("/")) + "." + time.strftime("%H%M%S"))
shutil.move(path, dest)
def _trash_windows(path):
# Send to Recycle Bin via SHFileOperationW with FOF_ALLOWUNDO (stdlib ctypes).
# UNTESTED on this build — verify on a real Windows machine.
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
FO_DELETE = 3
FOF_ALLOWUNDO = 0x0040
FOF_NOCONFIRMATION = 0x0010
FOF_SILENT = 0x0004
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = os.path.abspath(path) + "\x00\x00" # double-null terminated list
op.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT
rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if rc != 0:
raise OSError("SHFileOperation failed (code %d)" % rc)
def hard_delete(path):
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
else:
os.remove(path)
def open_in_file_manager(path):
# 非破坏性:在访达 / 资源管理器里打开该位置,方便用户自己审查删除
target = path if os.path.isdir(path) else os.path.dirname(path)
if sys.platform == "darwin":
# .app 是 bundle,对它用 open 会"启动应用"而非显示;必须用 open -R 在访达里选中。
if target.rstrip("/").endswith(".app"):
r = subprocess.run(["open", "-R", target], capture_output=True, text=True)
if r.returncode != 0:
raise OSError((r.stderr or "open -R 失败").strip())
return
# 普通文件夹:先试直接打开看内容;沙盒容器(如微信)open 会报 -10814,
# 退回 open -R 在父目录里选中它。两者都失败才算错。
r = subprocess.run(["open", target], capture_output=True, text=True)
if r.returncode != 0:
r2 = subprocess.run(["open", "-R", target], capture_output=True, text=True)
if r2.returncode != 0:
raise OSError((r.stderr or r2.stderr or "open 失败").strip())
elif sys.platform.startswith("win"):
subprocess.run(["explorer", target]) # explorer 退出码不可靠,不据此判成败
else:
raise OSError("打开文件夹仅支持 macOS / Windows")
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype="application/json"):
b = body.encode("utf-8") if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_GET(self):
if self.path in ("/", "/index.html"):
blob = json.dumps(DATA, ensure_ascii=False)
cfg = json.dumps({"token": TOKEN, "endpoint": "/action"})
html = TPL.replace("__REPORT_DATA__", blob).replace("__DELETE_CONFIG__", cfg)
self._send(200, html, "text/html; charset=utf-8")
else:
self._send(404, "not found", "text/plain")
def do_POST(self):
if self.path != "/action":
self._send(404, json.dumps({"ok": False, "error": "not found"}))
return
# DNS-rebinding guard: only accept local Host
host = (self.headers.get("Host") or "").split(":")[0]
if host not in ("127.0.0.1", "localhost"):
self._send(403, json.dumps({"ok": False, "error": "host 不被允许"}))
return
n = int(self.headers.get("Content-Length", 0))
try:
req = json.loads(self.rfile.read(n) or b"{}")
except Exception:
self._send(400, json.dumps({"ok": False, "error": "请求格式错误"}))
return
if req.get("token") != TOKEN:
self._send(403, json.dumps({"ok": False, "error": "token 校验失败"}))
return
mode = req.get("mode")
allow = {"rm": RM_ALLOW, "trash": TRASH_ALLOW, "open": OPEN_ALLOW}.get(mode)
if allow is None:
self._send(400, json.dumps({"ok": False, "error": "未知操作"}))
return
done = []
for p in (req.get("paths") or []):
rp = expand(p)
if rp not in allow:
self._send(403, json.dumps({"ok": False, "error": "路径不在白名单:%s" % p}))
return
# 二级护栏:只允许用户目录或 /Applications(后者仅 open 用,删除白名单不含它)
roots = (HOME, "/Applications")
if not any(rp == base or rp.startswith(base + os.sep) for base in roots):
self._send(403, json.dumps({"ok": False, "error": "路径越界:%s" % p}))
return
try:
if mode == "open":
open_in_file_manager(rp)
elif not os.path.exists(rp):
pass # already gone, treat as success
elif mode == "trash":
move_to_trash(rp)
else:
hard_delete(rp)
done.append(p)
except Exception as e:
self._send(500, json.dumps({"ok": False, "error": str(e)}))
return
self._send(200, json.dumps({"ok": True, "done": done}))
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
global DATA, TPL, RM_ALLOW, TRASH_ALLOW, OPEN_ALLOW
DATA, TPL, RM_ALLOW, TRASH_ALLOW, OPEN_ALLOW = load(sys.argv[1])
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
url = "http://127.0.0.1:%d/" % port
print("报告服务已启动:" + url)
print("绿灯可删 %d 项 | 橙灯可移废纸篓/打开文件夹 %d 项 | 页面上点" % (len(RM_ALLOW), len(TRASH_ALLOW) - len(RM_ALLOW)))
print("用完按 Ctrl+C 停止服务(服务关掉后按钮即失效)")
webbrowser.open(url)
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\n已停止服务。")
if __name__ == "__main__":
main()
Related skills
How it compares
Use storage-analyzer for categorized disk scans with HTML reports; skip it for RAM profiling or cloud storage billing analysis.
FAQ
Does storage-analyzer delete files during the scan?
storage-analyzer runs a read-only scan using df, du, diskutil, stat, and ls, forbidding rm or mv during analysis; deletion happens only if the user explicitly acts via the HTML report.
Which operating systems does storage-analyzer support?
storage-analyzer auto-detects macOS or Windows, scans full-disk usage, and outputs a three-tier interactive HTML report with ranked cleanup guidance for each platform.