磁盘空间去哪了:一个脚本看清Windows磁盘占用真相
C盘又飘红了,可你根本说不清是哪个目录吃掉了空间。这种无力感,几乎是每个Windows用户的共同记忆。
资源管理器为什么指望不上
资源管理器是为浏览文件设计的,不是为分析磁盘占用设计的。当你想搞清楚空间去哪了,它会在每个环节让你失望。
逐目录查看。右键单击某个文件夹选属性,资源管理器慢吞吞遍历完后只给一个数字。想对比多个目录?重复这个动作吧。
找不到大文件。可以切换到按大小排序,但只对当前目录有效。要找出整个磁盘上最大的文件,得逐层进入每个子目录切换排序方式。
深度不可控。要么只看直接子项,要么在属性面板里一口气扫到底,没有"只看前3层"这种折中选项。
没有报告导出。所有信息只能截屏保存,无法生成文本报告,更别提记录每个文件的完整路径。
多盘符无力。机器上有C、D、E多个盘符时,想看整台机器的空间分布,只能逐个盘符手动操作。
权限错误含糊。遇到无法访问的系统目录,弹出一串难以理解的提示,你不知道哪些文件被跳过、影响多大。
困境与对策
资源管理器的每一道坎,脚本都给了对应的解法。
困境一:只能逐目录查看,无法横向对比。
对策:支持一次扫描多个路径。用 -p 指定多个路径,脚本会分别扫描并汇总输出;不传参数时自动检测所有可用盘符全盘扫描。命令 python disk_usage.py -p C:\ D:\ 就能同时看两个盘。实现上通过遍历所有有效路径,把每个路径的统计结果累加到全局总量上。
困境二:找不到整个磁盘上最大的文件。
对策:扫描过程中维护一个最大文件候选列表,最终输出Top N。用 --top 50 可以列出前50个最大文件及其完整路径。找大文件不再需要层层点击。
困境三:递归深度不可控,要么太浅要么扫到底。
对策:用 -d 参数限制递归层数,默认5层。-d 2 快速看顶层分布,-d 10 看深层细节。实现上在 os.walk 遍历时根据路径分隔符数量计算当前深度,达到上限就清空待访问子目录列表阻止下钻。
困境四:没有报告导出能力。
对策:统计报告既打印到终端,也同步写入日志文件。默认在脚本同目录生成同名的 .log 文件,把扫描到的每个文件完整路径都记下来,事后分析或转发都方便。用 --log 可以指定日志路径。
困境五:多盘符场景无力。
对策:脚本启动时会遍历A到Z所有盘符检测可用盘,一条命令就能扫遍整台机器。多路径结果合并到同一份报告里,目录统计合并、最大文件列表合并后重新排序截取前N个。
困境六:权限错误处理含糊。
对策:文件访问用异常捕获包裹,遇到 OSError 或 PermissionError 直接跳过当前文件继续扫描下一个,不会因为个别权限问题中断整个流程。
完整源码
将以下内容保存为 disk_usage.py 即可直接运行,仅依赖Python 3标准库,无需安装任何第三方包。建议配合后面的工作原理章节阅读,每个函数的职责会更容易理解。
#!/usr/bin/env python3
"""
磁盘文件大小统计工具
统计指定路径下的文件大小分布,支持最大递归深度控制和多路径扫描。
"""
import os
import sys
import argparse
import string
import logging
from pathlib import Path
def get_all_drives():
"""获取Windows系统所有可用盘符"""
drives = []
for letter in string.ascii_uppercase:
drive = f"{letter}:\\"
if os.path.exists(drive):
drives.append(drive)
return drives
def setup_logger(log_path):
"""设置日志记录器,将扫描的文件路径写入日志文件"""
logger = logging.getLogger('disk_usage')
logger.setLevel(logging.INFO)
# 文件处理器,追加模式
fh = logging.FileHandler(log_path, mode='w', encoding='utf-8')
fh.setLevel(logging.INFO)
fh.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(fh)
return logger, log_path
def scan_directory(start_path, max_depth=5, logger=None):
"""
扫描目录并返回统计信息
返回:
total_size: 总大小(字节)
file_count: 文件总数
dir_stats: 目录统计信息 {dir_path: {size, count}}
largest_files: 最大的N个文件 [(path, size)]
"""
total_size = 0
file_count = 0
dir_stats = {}
largest_files = []
# 用于跟踪前N大文件的最小阈值,优化性能
min_top_file_size = 0
start_path = os.path.abspath(start_path)
start_depth = start_path.rstrip(os.sep).count(os.sep)
for root, dirs, files in os.walk(start_path, followlinks=False):
# 计算当前深度
current_depth = root.rstrip(os.sep).count(os.sep) - start_depth
# 如果达到最大深度,阻止进一步递归
if current_depth >= max_depth:
dirs.clear()
# 处理当前目录的文件
dir_file_count = 0
dir_total_size = 0
for file in files:
try:
file_path = os.path.join(root, file)
stat = os.stat(file_path)
file_size = stat.st_size
total_size += file_size
file_count += 1
dir_file_count += 1
dir_total_size += file_size
# 记录最大文件 (保持最多100个,提高性能)
if file_size >= min_top_file_size:
largest_files.append((file_path, file_size))
if len(largest_files) > 100:
largest_files.sort(key=lambda x: x[1], reverse=True)
largest_files.pop()
min_top_file_size = largest_files[-1][1] if largest_files else 0
# 记录到日志
if logger:
logger.info(file_path)
except (OSError, PermissionError):
continue
# 记录目录统计(仅统计有文件的目录)
if dir_file_count > 0:
dir_stats[root] = {
'size': dir_total_size,
'count': dir_file_count,
'depth': current_depth
}
# 排序最大文件
largest_files.sort(key=lambda x: x[1], reverse=True)
return total_size, file_count, dir_stats, largest_files
def format_size(size_bytes):
"""格式化字节大小为可读字符串"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if abs(size_bytes) < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
def write_report(f, start_path, total_size, file_count, dir_stats, largest_files,
max_depth, top_n=20, no_top_files=False):
"""将统计报告写入文件或打印到终端"""
# 计算所有目录的公共前缀(用于缩短路径显示)
all_dirs = list(dir_stats.keys())
dir_common = ''
if all_dirs:
try:
if len(all_dirs) > 1:
dir_common = os.path.commonpath(all_dirs)
else:
dir_common = os.path.dirname(all_dirs[0])
except ValueError:
pass
print(f"\n{'='*65}", file=f)
print(f" 磁盘文件大小统计报告", file=f)
print(f"{'='*65}", file=f)
print(f" 扫描路径: {start_path}", file=f)
print(f" 最大深度: {max_depth} 层", file=f)
print(f" 文件总数: {file_count:,}", file=f)
print(f" 总大小 : {format_size(total_size)}", file=f)
print(f"{'='*65}", file=f)
if not dir_stats:
print(f"\n --- 未扫描到任何文件 ---", file=f)
return
print(f"\n📁 目录大小分布 (Top {top_n}):", file=f)
print(f" {'目录':<45} {'文件数':>8} {'大小':>10}", file=f)
print(f" {'-'*45} {'-'*8} {'-'*10}", file=f)
sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[1]['size'], reverse=True)
for dir_path, stat in sorted_dirs[:top_n]:
display = dir_path
if dir_common and dir_path.startswith(dir_common):
rel = dir_path[len(dir_common):].lstrip('\\').lstrip('/')
if rel:
display = rel
if len(display) > 42:
display = '...' + display[-39:]
print(f" {display:<45} {stat['count']:>8,} {format_size(stat['size']):>10}", file=f)
if largest_files and not no_top_files:
visible_n = min(top_n, len(largest_files))
print(f"\n🔍 最大的文件 (Top {visible_n}):", file=f)
print(f" {'':>3} {'路径':<50} {'大小':>10}", file=f)
print(f" {'-'*3} {'-'*50} {'-'*10}", file=f)
try:
common_prefix = os.path.commonpath([f[0] for f in largest_files[:top_n]])
except ValueError:
common_prefix = ''
for i, (path, size) in enumerate(largest_files[:top_n], 1):
display = path[len(common_prefix):].lstrip('\\').lstrip('/') if path.startswith(common_prefix) and common_prefix else path
if len(display) > 50:
display = '...' + display[-47:]
print(f" {i:>2}. {display:<50} {format_size(size):>8}", file=f)
def main():
parser = argparse.ArgumentParser(
description='磁盘文件大小统计工具 - 递归扫描目录并统计文件大小分布',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=r"""
示例:
%(prog)s # 扫描所有盘符,最大深度5层
%(prog)s -p C:\Users # 扫描指定路径
%(prog)s -d 3 # 最大递归深度为3层
%(prog)s -p D:\Projects -d 10 # 扫描D:\Projects,允许10层
%(prog)s -p C:\ D:\ -d 2 # 扫描C盘和D盘根目录,深度2层
%(prog)s --top 50 # 显示前50个最大目录/文件
"""
)
parser.add_argument('-p', '--path', nargs='+', metavar='路径',
help='开始扫描路径,可指定多个。默认为所有盘符根路径')
parser.add_argument('-d', '--depth', type=int, default=5, metavar='N',
help='最大递归层数(默认: 5)')
parser.add_argument('--top', type=int, default=20, metavar='N',
help='显示前N个最大目录和文件(默认: 20)')
parser.add_argument('--no-top-files', action='store_true',
help='不显示最大文件列表')
parser.add_argument('--log', metavar='文件',
help='日志文件路径(默认: 脚本目录/脚本名.log)')
args = parser.parse_args()
# 参数校验
if args.depth < 0:
print("错误: 递归深度不能为负数")
sys.exit(1)
if args.top < 1:
print("错误: --top 参数必须 >= 1")
sys.exit(1)
# 确定扫描路径
if args.path:
paths = [os.path.abspath(p) for p in args.path]
else:
print("正在检测可用盘符...")
paths = get_all_drives()
if not paths:
print("错误: 未检测到任何盘符")
sys.exit(1)
print(f"发现 {len(paths)} 个盘符: {', '.join(p[0] for p in paths)}")
# 验证路径
valid_paths = []
for p in paths:
if os.path.exists(p):
valid_paths.append(p)
else:
print(f"警告: 路径不存在, 跳过: {p}")
if not valid_paths:
print("错误: 没有有效的扫描路径")
sys.exit(1)
max_depth = args.depth
# 设置日志
if args.log:
log_path = os.path.abspath(args.log)
else:
script_path = Path(sys.argv[0])
log_path = script_path.with_suffix('.log')
logger, log_path = setup_logger(log_path)
# 分路径统计
all_dir_stats = {}
all_largest_files = []
grand_total_size = 0
grand_file_count = 0
for vp in valid_paths:
print(f"\n[{vp}] 正在扫描 (深度 {max_depth} 层)...")
total_size, file_count, dir_stats, largest_files = scan_directory(vp, max_depth, logger)
grand_total_size += total_size
grand_file_count += file_count
all_dir_stats.update(dir_stats)
all_largest_files.extend(largest_files)
if len(valid_paths) > 1:
print(f" -> {format_size(total_size)}, {file_count:,} 个文件")
# 合并并排序最大文件
all_largest_files.sort(key=lambda x: x[1], reverse=True)
all_largest_files = all_largest_files[:args.top]
# 生成起始路径显示文本
if len(valid_paths) == 1:
start_display = valid_paths[0]
else:
start_display = ", ".join(valid_paths[:3])
if len(valid_paths) > 3:
start_display += f" 等 {len(valid_paths)} 个路径"
# 如果多路径,先打印多路径汇总
print(f"\n{'='*65}")
print(f" 🔄 多路径扫描汇总")
print(f"{'='*65}")
for vp in valid_paths:
print(f" 📂 {vp}")
write_report(sys.stdout, start_display, grand_total_size, grand_file_count,
all_dir_stats, all_largest_files, max_depth, args.top,
no_top_files=args.no_top_files)
# 统计报告也写入日志文件
with open(log_path, 'a', encoding='utf-8') as lf:
write_report(lf, start_display, grand_total_size, grand_file_count,
all_dir_stats, all_largest_files, max_depth, args.top,
no_top_files=args.no_top_files)
# 显示日志文件位置
print(f"\n📄 日志文件: {log_path}")
if __name__ == '__main__':
main()
工作原理:从命令到报告
脚本的整体流程可以归纳为三个阶段:解析与准备、递归扫描、汇总与输出。
解析与准备阶段。脚本启动后用 argparse 解析命令行参数,校验递归深度不能为负、Top N必须大于等于1。然后确定扫描路径:用户指定了就用指定的,没指定就遍历所有盘符检测可用盘。最后初始化日志记录器,以覆盖写入模式打开日志文件。
递归扫描阶段。核心函数 scan_directory 用 os.walk 遍历目录树,每进入一层目录就根据路径分隔符数量计算当前深度,达到上限就清空待访问子目录列表阻止下钻。对每个文件调用 os.stat 获取大小,累加到全局总数和当前目录的本地统计中,同时把文件路径写入日志。
这里有两个关键设计值得注意。一是Top N文件的高效维护。脚本不是扫描完再全量排序,而是边扫描边维护一个最多100个的候选列表,并记录当前列表中的最小值作为阈值。只有大于等于阈值的文件才进入候选列表,列表超长时排序后弹出最小的那个。这种做法避免了对海量文件做全量排序的开销。二是异常跳过。文件访问用 try/except 包裹,遇到权限问题直接跳过继续,保证扫描不会因个别文件中断。
汇总与输出阶段。如果指定了多个路径,脚本会把所有路径的结果合并:总大小和文件数累加,目录统计合并到同一字典,最大文件列表合并后重新排序截取前N个。write_report 函数计算所有目录的公共前缀用于缩短显示路径,再按大小排序输出目录榜单和最大文件榜单。报告既打印到终端,也追加写入日志文件末尾。
运行效果
把脚本保存为 disk_usage.py,在终端里运行:
python disk_usage.py -p D:\Projects -d 3
你会得到一份类似这样的报告:
=================================================================
磁盘文件大小统计报告
=================================================================
扫描路径: D:\Projects
最大深度: 3 层
文件总数: 12,345
总大小 : 23.45 GB
=================================================================
📁 目录大小分布 (Top 20):
目录 文件数 大小
--------------------------------------------- -------- ----------
node_modules 234 5.23 GB
.git/objects 567 2.10 GB
dist 89 1.45 GB
src/components 156 0.87 GB
...
🔍 最大的文件 (Top 20):
路径 大小
--- -------------------------------------------------- --------
1. node_modules/large-lib/index.js 120.45 MB
2. .git/objects/pack/pack-abc.pack 98.32 MB
3. dist/bundle.js 45.10 MB
...
哪几个目录吃掉了大部分空间、最大的文件藏在哪、总共扫了多少文件,一眼看清。
参数速查
脚本所有参数集中说明如下,组合使用即可应对不同场景。
-p 或 --path:指定扫描路径,可一次传多个,空格分隔。不传时默认扫描所有可用盘符。
-d 或 --depth:最大递归层数,默认5。设为0表示只扫描起始目录下的直接子项。
--top:报告中显示前N个最大目录和最大文件,默认20。
--no-top-files:只显示目录榜单,不显示最大文件列表。扫描海量文件时可以加快输出。
--log:自定义日志文件路径。不传时默认使用脚本同目录下的同名 .log 文件。
小结
资源管理器适合浏览文件,不适合分析磁盘占用。这个脚本把"我的磁盘空间都被什么吃掉了"这个问题,从一次次右键属性的笨拙操作,简化成一条命令就能拿到答案。深度可控、多路径扫描、Top N排序、日志归档,每一项都对应着资源管理器做不到或者做得很别扭的事情。下一次磁盘飘红之前,不妨先跑一遍这个脚本,让数据告诉你答案。

浙公网安备 33010602011771号