磁盘空间去哪了:一个脚本看清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个。

困境六:权限错误处理含糊。
对策:文件访问用异常捕获包裹,遇到 OSErrorPermissionError 直接跳过当前文件继续扫描下一个,不会因为个别权限问题中断整个流程。

完整源码

将以下内容保存为 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_directoryos.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排序、日志归档,每一项都对应着资源管理器做不到或者做得很别扭的事情。下一次磁盘飘红之前,不妨先跑一遍这个脚本,让数据告诉你答案。

posted @ 2026-08-02 23:50  减瓦~  阅读(10)  评论(0)    收藏  举报