3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フォルダサイズを自動で監視&グラフ化!

Last updated at Posted at 2025-11-03

はじめに

社内のオンプレミスのファイルサーバの容量がひっ迫すると、「どのフォルダが原因?」を調べるのに毎回時間がかかる——。そんな悩みを解決するために、Pythonで「フォルダサイズ自動監視・可視化ツール」を開発しました。
このツールは、指定しておいたフォルダサイズを定期的に自動取得後、CSV形式で蓄積。そのデータをもとにグラフを自動生成してPDFとして出力します。これにより、容量の増減傾向や原因フォルダを誰でも簡単に把握でき、ファイルサーバ運用の効率化と可視化を実現します。

背景と課題

現在、私は設計業務だけでなく、プロジェクト内のインフラ管理も兼任しております。
ファイルサーバの容量がひっ迫すると、システム部門から「不要なファイル削減依頼」が届き、現状、対応に次のような課題がありました。

  • 各フォルダ容量を手動で確認し、手間がかかる
  • 容量の大きいフォルダを特定するのに時間と労力が必要
  • 容量の推移を追えず、原因分析が難しい

このように、毎回の確認が「人手に頼りがち」になっているのが悩みでした。そこで、これらの課題を解決すべく、Python「業務効率化 × 自作ツール」開発に取り組みました。

開発の目的

✅フォルダサイズを定期的に自動取得し、手作業での容量確認をなくす
✅容量増加の傾向を可視化し、ひっ迫状態を早期に発見できる仕組みを整える
✅チーム全体で情報を共有し、計画的なファイル整理と容量管理を促進する

システム構成

本ツールは以下のテキストファイルと3つのPythonスクリプトとで構成されています。
下表の上2つのスクリプトをWindowsのタスク スケジューラで定期的に実行(例:毎週末)。

ファイル名 役割
root_folder.txt 監視対象のルートフォルダ一覧を定義(1行1パス)
folder_size_auto_scan.py 監視対象フォルダの配下を再帰的に走査し、階層ごとの容量
を集計してCSVを出力
folder_size_auto_plot.py 複数のCSVからフォルダサイズの推移を処理日時順でグラフ
化してPDFを出力
folder_size_for_analysis.py ユーザーがさらに詳細に分析したいフォルダを選択し、PDF
に出力

フォルダ構成 (例)

tools_folder_size/
├─ folder_size_auto_scan.py
├─ folder_size_for_analysis.py
├─ folder_size_auto_plot.py
├─ root_folder.txt          (← 監視対象のルートフォルダのパス)
├─ folder_size_csv/         (← CSVの出力先)
│   ├─ folder_size_20251024_235959.csv
│   ├─ folder_size_20251031_235959.csv
│   └─ ...
└─ folder_size_pdf/        (← PDFの出力先)
    ├─ folder_size_plot_(depth0)_20251102_233000.pdf
    └─ ...

1. 監視対象のルートフォルダを定義 (root_folder.txt)

  • 監視したいフォルダのルートパスを、1行につき1つずつ記載したテキストファイル(例:root_folder.txt)を作成する
D:\fileserver01\project_A
D:\fileserver01\project_B
D:\fileserver01\project_C
D:\fileserver01\project_D
D:\fileserver01\project_E

2. 自動フォルダサイズ取得スクリプト (folder_size_auto_scan.py)

  • 各ルートフォルダを再帰的に走査して容量を集計
  • ルートフォルダの配下フォルダ (Depth:階層数)を指定して出力を制御
  • folder_size_csv/ にCSVを自動保存します
  • Windows のタスク スケジューラで定期的に実行
"""
フォルダサイズを再帰的に集計し、指定した最大階層までの結果を CSV として
スクリプト直下の `folder_size_csv/` に出力するスクリプト。

======================================
■ このスクリプトでできること
======================================
- `root_folder.txt`(1行1パス)の各ルートフォルダを起点に、配下を再帰走査して
  フォルダごとの合計サイズ(バイト)を集計
- 親フォルダのサイズには配下(全子孫)のサイズをすべて加算
- 出力対象は「Depth <= max_depth」(最大階層は `DEFAULT_MAX_DEPTH` で指定)
- 出力CSVはパスで昇順ソートし、人間可読のサイズ列(例: 1.2 GB)と
  実行時刻列(`YYYY-MM-DD HH:MM:SS`)を付与

======================================
■ 入出力
======================================
- 入力:
  - 監視対象ルート一覧: `root_folder.txt`(1行1パス、文字コードは `FILE_ENCODING`)
    * 例:
        D:/Project_A
        D:/Project_B
- 出力:
  - スクリプト直下の `folder_size_csv/` に CSV を保存
    * 例: `folder_size_YYYYMMDD_HHMMSS.csv`
  - CSV 文字コード: `utf-8-sig`

======================================
■ 出力CSVの列順・内容
======================================
[Depth, Folder Path, Size (bytes), Size, Execution Time]
- Depth: ルート=0、直下=1 … の整数
- Folder Path: フォルダのフルパス
- Size (bytes): 合計サイズ(バイト)
- Size: 人間可読の単位付き文字列(例: 1.2 GB)
- Execution Time: 出力時刻(`YYYY-MM-DD HH:MM:SS`)

======================================
■ 動作仕様/注意点
======================================
- シンボリックリンクは追跡しません(無視)
- 権限不足や走査中に消えたエントリはスキップ(警告表示)
- 集計は「全階層」を通算(親に子孫のサイズをすべて加算)
- 出力対象は `Depth <= max_depth` の行のみ
- 出力先 `folder_size_csv/` は存在しなければ自動作成

======================================
■ 使い方(標準フロー)
======================================
1) スクリプトと同じフォルダに `root_folder.txt` を配置(文字コードは `FILE_ENCODING`。
   既定は `"shift-jis"`。必要に応じて `"utf-8"` に変更)
2) 必要に応じて `DEFAULT_MAX_DEPTH` を設定(既定は 2)
3) スクリプトを実行
4) `folder_size_csv/` に CSV が出力されます

======================================
■ エラー/例外時の挙動
======================================
- `root_folder.txt` が存在しない: 警告を表示し、空リストとして処理(実質的に終了)
- 監視対象が空: 警告を表示し、処理を終了
- 個別エントリの権限不足/消失: 該当エントリをスキップし処理を継続

======================================
■ 動作環境メモ
======================================
- OS: Windows を想定(`FILE_ENCODING = "shift-jis"` が既定)
- Python: `pandas` が必要
- 文字化けする場合は `FILE_ENCODING` を `"utf-8"` へ変更してください
"""

import os
from datetime import datetime
from pathlib import Path

import pandas as pd


# =============================================================================
# 設定
# =============================================================================
# このスクリプトが置かれているフォルダ内で出力を扱う
program_folder = Path(__file__).parent.resolve()

# 監視対象フォルダ(1行1パス)を列挙したテキストファイル
ROOTS_LIST_FILE = program_folder / "root_folder.txt"
# ROOTS_LIST_FILE のテキストエンコーディング
FILE_ENCODING = "shift-jis"

# ルートフォルダから下の階層数 (0 = ルートのみ)
DEFAULT_MAX_DEPTH = 2

# 出力結果の保存先フォルダ名
folder_name = "folder_size_csv"



# =============================================================================
# Utilities
# =============================================================================
def get_csv_folder(program_folder, folder_name):
    """CSVの保存フォルダを作成(なければ作成)して、そのPathを返す。"""
    folder_path = program_folder / folder_name
    folder_path.mkdir(parents=True, exist_ok=True)
    return folder_path


def format_bytes(num_bytes, suffix="B"):
    """バイト数を単位付き文字列に変換。 例: "1.2 GB" """
    value = float(num_bytes)

    if value < 0:
        value = 0.0

    units = ["", "K", "M", "G", "T", "P"]
    for unit in units:
        if value < 1024.0:
            return f"{value:.1f} {unit}{suffix}"
        value /= 1024.0
    # P(ペタ)を超える場合もPで表示
    return f"{value:.1f} P{suffix}"


def read_root_paths(root_list_file, file_encoding):
    """監視対象のルートフォルダ一覧を読み込む。"""
    try:
        with open(root_list_file, "r", encoding=file_encoding) as f:
            root_folders = [Path(line.strip()) for line in f if line.strip()]
            if not root_folders:
                print(f"---> 監視ディレクトリのリストが空です: {root_list_file}")
            return root_folders
    except FileNotFoundError:
        print(f"---> 監視ディレクトリのリストが見つかりません: {root_list_file}")
        return []


# =============================================================================
# Recursive Folder Size Collection
# =============================================================================
def get_folder_sizes_up_to_depth(root, max_depth):
    """指定階層までのフォルダサイズを再帰的に集計。"""
    rows = []

    def recurse(dir_path, depth):
        """dir_path配下の総サイズ(バイト)を返し、必要ならrowsに1行追加する。"""
        total = 0

        try:
            with os.scandir(dir_path) as entries:
                for entry in entries:
                    # シンボリックリンクは無視
                    if entry.is_symlink():
                        continue
                    try:
                        if entry.is_file(follow_symlinks=False):
                            total += entry.stat().st_size
                        elif entry.is_dir(follow_symlinks=False):
                            total += recurse(Path(entry.path), depth + 1)
                    except (FileNotFoundError, PermissionError) as e:
                        print(f"---> スキップ: {entry.path} ({type(e).__name__})")
                        # 途中で消えた/権限がない場合はスキップ
                        continue

        except (FileNotFoundError, PermissionError) as e:
            print(f"---> スキップ: {dir_path} ({type(e).__name__})")
            # ディレクトリアクセス不可は0扱い(パスと例外型を表示)
            return 0

        if depth <= max_depth:
            rows.append(
                { "Depth": depth,
                  "Folder Path": str(dir_path),
                  "Size (bytes)": int(total),
                }
            )

        return total

    # 走査開始
    recurse(root, 0)
    return rows


# =============================================================================
# Folder Size Collection
# =============================================================================
def collect_folder_sizes(root_paths, max_depth):
    """各ルートフォルダ配下のサイズを再帰集計。"""
    all_rows = []

    for root_folder in root_paths:
        if not root_folder.exists():
            print(f"---> 監視ディレクトリが存在しません: {root_folder}")
            continue

        print(f"\n---> {root_folder} のサイズを計測中")
        rows = get_folder_sizes_up_to_depth(root_folder, max_depth)
        all_rows.extend(rows)

        # 簡易出力
        for r in rows:
            size_str = format_bytes(int(r["Size (bytes)"]))
            print(f"[Depth={r['Depth']}] {r['Folder Path']}: {size_str}")

    return all_rows


# =============================================================================
# Output
# =============================================================================
def save_sizes_to_csv_with_pandas(rows, output_folder):
    """収集したデータをCSVに保存。"""
    df = pd.DataFrame(rows, columns=["Depth", "Folder Path", "Size (bytes)"])

    # 人間可読のサイズ表記列を追加
    df["Size"] = df["Size (bytes)"].apply(format_bytes)

    # パスで昇順ソート
    df = df.sort_values(by="Folder Path", ascending=True)

    # 実行時刻(出力ファイル名・列に使用)
    now = datetime.now()
    df["Execution Time"] = now.strftime("%Y-%m-%d %H:%M:%S")

    # 列順を固定
    df = df[["Depth", "Folder Path", "Size (bytes)", "Size", "Execution Time"]]

    # 保存(例: folder_size_20251021_214809.csv)
    csv_filename = f"folder_size_{now.strftime('%Y%m%d_%H%M%S')}.csv"
    csv_path = output_folder / csv_filename
    df.to_csv(csv_path, index=False, encoding="utf-8-sig")
    return csv_path


# =============================================================================
# Main
# =============================================================================
def main():
    """エントリポイント。root一覧を読み、サイズ集計してCSVを出力する。"""
    # 監視対象フォルダの読込み
    root_paths = read_root_paths(ROOTS_LIST_FILE, FILE_ENCODING)
    if not root_paths:
        print("\n---> 監視対象フォルダのリストが空です。")
        return
    
    # CSV出力先フォルダを用意
    output_dir = get_csv_folder(program_folder, folder_name)

    # 集計
    all_rows = collect_folder_sizes(root_paths, DEFAULT_MAX_DEPTH)

    # 出力
    if all_rows:
        csv_path = save_sizes_to_csv_with_pandas(all_rows, output_dir)
        total_bytes = sum(r["Size (bytes)"] for r in all_rows if "Depth" in r and r["Depth"] == 0)
        print(f"\n---> 監視ルートフォルダ数: {len(root_paths)}")
        print(f"---> 収集した全フォルダ数: {len(all_rows)}")
        print(f"---> 合計サイズ(Depth=0): {format_bytes(total_bytes)}")
        print(f"\n---> [OK] CSVファイルを保存しました: {csv_path}")
    else:
        print("\n---> 有効なフォルダが見つかりませんでした。")


if __name__ == "__main__":
    main()

・出力例(CSV)

Depth Folder Path Size (bytes) Size Execution Time
0 D:\Project_A 123456789 117.7 MB 2025-10-21 21:48:09
1 D:\Project_A\data 34567890 32.9 MB 2025-10-21 21:48:09
2 D:\Project_A\data\a 10590618 10.1 MB 2025-10-21 21:48:09

3. 自動可視化スクリプト (folder_size_auto_plot.py)

  • 複数の CSV を読み込み、指定階層(既定:Depth=0/ルートフォルダ )を抽出
  • 同一日付×同一フォルダの重複データがある場合は「最新の Execution Time」を採用
  • 棒グラフをA4縦レイアウトで自動ページ分割してPDF出力
  • PDF内のすべてのグラフは Y 軸スケールを統一(比較しやすさ向上)
  • folder_size_pdf/ にPDFを自動保存します
  • Windows のタスク スケジューラで定期的に実行
"""
フォルダサイズの時系列を棒グラフで描画し、階層フォルダ数が多い場合は
複数ページのPDFに分割して保存する可視化ツール (階層指定/自動検出用)。

======================================
■ このスクリプトでできること
======================================
- フォルダサイズを記録した複数CSVを読み込み、指定階層(TARGET_DEPTH)だけ抽出
- 同一日付×同一フォルダの重複がある場合は「最新の Execution Time」を採用
- すべてのグラフで「共通のY軸単位・スケール」を自動決定(B/KB/MB/GB/TB…)
- フォルダ数が多くても A4 縦の PDF に複数ページで自動分割して保存
- TARGET_DEPTH=0 と >0 で同じルートフォルダの場合、同じ色の棒グラフに設定

======================================
■ 前提・必須ファイル
======================================
- root_folder.txt(1行1パス, 文字コードは FILE_ENCODING)
  * 例:
      D:/Project_A
      D:/Project_B
- 可視化対象のCSV群(列名が必須・順不同で可)
  * 必須列:
      Depth, Folder Path, Size (bytes), Size, Execution Time
  * 「Execution Time」は日時(例: 2025/10/24 23:59:00)
  * Depth は整数で、0=ルート階層、1=直下 … を想定
- CSVの検索場所(自動検出の優先順)
  1) スクリプト直下の `folder_size_csv/` フォルダ
  2) スクリプト直下(同じディレクトリ)

======================================
■ 色と描画順の仕様
======================================
- TARGET_DEPTH = 0:
    - フォルダごとに異なる色(tab20 カラーマップで順繰り)
    - **描画順はアルファベット昇順**
- TARGET_DEPTH > 0:
    - 同じ root_folder.txt のルート配下は同色(ルート単位で色を固定)
    - **描画順はアルファベット昇順**(root_folder.txt の行順ではありません)

======================================
■ 可視化設計
======================================
- 1ページあたり GRID_ROWS 個のサブプロットを縦に積む(横は1固定)
- 目盛は MaxNLocator により概ね 5 本程度に自動調整
- X軸ラベル(YYYY-mm-dd)は最下段のサブプロットのみ表示(省スペース)
- 全フォルダで共通のY軸スケール(全体最大値を基準に単位と上限を決定)

======================================
■ 使い方(標準フロー)
======================================
1) スクリプトと同じフォルダに root_folder.txt を配置する
2) 可視化したい CSV を `folder_size_csv/` またはスクリプト直下に置く
3) 必要に応じて DEFAULT_TARGET_DEPTH を設定(デフォルトは 0)
4) スクリプトを実行する(CSVは自動検出されます)
5) A4縦の PDF が同フォルダに出力される
   例: folder_size_plot_(depth0)_YYYYmmdd_HHMMSS.pdf

======================================
■ エラー/例外時の挙動
======================================
- root_folder.txt が見つからない: エラーメッセージ表示後に終了
- CSVが見つからない: 探索場所(`folder_size_csv/` → スクリプト直下)を表示し、終了
- 有効行なし: エラーメッセージ表示後に終了
- CSV 読込失敗: 該当ファイルをスキップして続行(WARN を表示)


======================================
■ 動作環境メモ
======================================
- OS: Windows を想定(FILE_ENCODING="shift-jis")
- Python: pandas, matplotlib, japanize_matplotlib が必要
- 文字化けする場合は FILE_ENCODING を "utf-8" 等に変更してください
- 「Date」列は内部計算用に生成(Execution Time の日付のみを正規化)
"""

from pathlib import Path
import sys
from datetime import datetime

import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib  # noqa: F401 (日本語フォント有効化の副作用目的)
from matplotlib import ticker as mticker
from matplotlib.backends.backend_pdf import PdfPages


# =============================================================================
# Settings
# =============================================================================
# このスクリプトが置かれているフォルダ(解決済み絶対パス)
program_folder = Path(__file__).parent.resolve()

# 監視対象ルートの一覧ファイル(1行1パス)
ROOTS_LIST_FILE = program_folder / "root_folder.txt"
# root_folder.txt の文字コード(Windows日本語環境を想定)
FILE_ENCODING = "shift-jis"

# デフォルトの描画対象階層(0=ルート階層)
DEFAULT_TARGET_DEPTH = 0

# CSVフォルダ名
folder_name = "folder_size_csv"

# PDF出力先ディレクトリを指定
PDF_DIR = program_folder / "folder_size_pdf"
PDF_DIR.mkdir(parents=True, exist_ok=True)  # 無ければ自動作成

# レイアウト(A4縦)
GRID_ROWS = 5  # 1ページに配置するサブプロット数(縦方向のみ; 横は1固定)
FIGSIZE_INCH = (8.27, 11.69)  # A4縦のインチサイズ
MARGINS = dict(left=0.1, right=0.95, top=0.95, bottom=0.15)  # 余白(図全体の外周)


# =============================================================================
# Utilities
# =============================================================================
def read_root_paths(root_file, file_encoding):
    """
    監視対象ルート一覧ファイル(root_folder.txt)を読み込む。

    Returns
    -------
    list[Path]
        空行を除いたパスのリスト。ファイルが無い場合は終了。
    """
    try:
        with open(root_file, "r", encoding=file_encoding) as f:
            root_dirs = [Path(line.strip()) for line in f if line.strip()]
            return root_dirs
    except FileNotFoundError:
        print(f"---> 監視ディレクトリのリストが見つかりません: {root_file}")
        sys.exit(1)


def chunked(seq, size):
    """
    イテラブルを size ごとの塊(チャンク)に分割するジェネレータ。
    """
    for i in range(0, len(seq), size):
        yield seq[i: i + size]


def pick_unit_and_scale(max_value):
    """
    最大値に応じて単位(B, KB, MB, ...)とスケール係数を決定する。
    例)最大値が 3,200 の場合 → 'KB', 1024 を返し、表示値は (値/1024)
    """
    value = float(max_value)
    if value <= 0:
        # データが無い/0 の場合は B・スケール1で返す
        return "B", 1.0

    units = ["", "K", "M", "G", "T", "P"]  # 2の10乗刻みの接頭辞
    scale = 1.0                            # 実値をこの scale で割って表示する
    value = float(max_value)
    for u in units:
        # value が 1024 未満になった段階の単位を採用
        if value < 1024:
            return f"{u}B", scale
        # 1024 を超える間は次の単位へ(scale も 1024 倍ずつ)
        value /= 1024
        scale *= 1024
    # それでも大きすぎる場合は PB 固定
    return "PB", float(1024**5)


def normalize_path_str(p):
    """
    パス表記ゆれを統一(\ → /、末尾スラッシュ除去)。
    OS 違いやユーザ入力表記の差を吸収し、startswith 判定を安定化。
    """
    return str(Path(p)).replace("\\", "/").rstrip("/")


def find_root_folder(path, root_paths):
    """
    指定 path がどのルート配下かを探索して返す。
    - normalize したうえで startswith による包含判定を行う
    - 最初にマッチしたルート(root_folder.txt の上から優先)を返す
    """
    p_norm = normalize_path_str(path)
    for root in root_paths:
        r_norm = normalize_path_str(root)
        if p_norm.startswith(r_norm):
            return str(root)
    return None  # どのルートにも属さない場合


def build_root_color_map(root_paths, cmap_name):
    """
    ルートごとに色を割り当てる辞書を生成する(カラーマップから順繰りに取得)。
    """
    cmap = plt.get_cmap(cmap_name)
    n = cmap.N
    return {str(root): cmap(i % n) for i, root in enumerate(root_paths)}


def build_date_xlabels(df):
    """
    DataFrame の Date 列(Datetime型/日付正規化済み)から、
    ユニークな日付を昇順で抽出し、'YYYY-mm-dd' の文字列配列にする。
    """
    # NaT を除外 → 正規化(00:00:00) → 昇順 → 重複除去
    s = pd.to_datetime(df["Date"], errors="coerce").dropna().dt.normalize()
    s = s.sort_values().drop_duplicates()
    # 棒グラフの xtick 用に文字列化
    return s.dt.strftime("%Y-%m-%d").tolist()

def select_csv_files(initial_folder):
    """指定フォルダ内のCSVファイルを自動検出して返す。"""
    candidates = []

    csv_dir = initial_folder / folder_name
    if csv_dir.exists():
        candidates.extend(sorted(csv_dir.glob("*.csv")))

    if not candidates:
        candidates.extend(sorted(initial_folder.glob("*.csv")))

    return candidates

# =============================================================================
# Data Processing
# =============================================================================
def load_and_merge(csv_files, target_depth):
    """
    複数 CSV から指定 target_depth の行だけを抽出し、
    「同日×同フォルダ」では Execution Time が最新の 1 件を残して結合する。
    """
    frames = []

    for path in csv_files:
        if not path.exists():
            print(f"[WARN] ファイルなし: {path}")
            continue
        try:
            df = pd.read_csv(path)
        except Exception as exc:
            print(f"[WARN] 読込失敗: {path.name} -> {exc}")
            continue

        # --- ここが重要:可視化対象の階層だけに限定 ---
        df = df.loc[df["Depth"] == int(target_depth)].copy()
        if df.empty:
            # 当該CSVに可視化対象階層が無い場合はスキップ
            continue

        # 実測時刻(Execution Time)→ 日付(Date)を内部生成
        df["Execution Time"] = pd.to_datetime(df["Execution Time"], errors="coerce")
        df["Date"] = df["Execution Time"].dt.normalize()

        # 可視化に必要な列のみ残す(順序は後段操作に合わせる)
        frames.append(df[["Date", "Execution Time", "Folder Path", "Size (bytes)", "Depth"]])

    if not frames:
        # 1ファイルも有効データが無い場合の空DataFrame(後段で empty 判定)
        return pd.DataFrame(columns=["Date", "Execution Time", "Folder Path", "Size (bytes)", "Depth"])

    # ---- 同一日付×同一フォルダの最新時刻を 1 件残す手順 ----
    merged = pd.concat(frames, ignore_index=True)

    # (1) 並べ替え:Date→Folder Path→Execution Time(昇順)
    #     最新を採るため、いったん昇順にしてから drop_duplicates(keep='last') を使う
    merged = merged.sort_values(["Date", "Folder Path", "Execution Time"], ascending=True)

    # (2) 「同日×同フォルダ」の重複を後ろ(最新)だけ残して削除
    #     これにより、1日1フォルダあたり 1 レコードに圧縮される
    df = merged.drop_duplicates(subset=["Date", "Folder Path"], keep="last")

    # (3) 可視化時は日付の昇順で見せたいので再度 Date で整列
    df = df.sort_values("Date", ascending=True).reset_index(drop=True).copy()

    return df


# =============================================================================
# Folder Sorting
# =============================================================================
def order_folders_by_root(df, target_depth):
    """ フォルダの描画順を決める。常にアルファベット昇順(対象階層やルート選択順にかかわらず) """
    folders = df["Folder Path"].dropna().unique().tolist()
    return sorted(folders)


# =============================================================================
# Plotting
# =============================================================================
def plot_page(fig, df, folders, date_labels, folder_colors, unit, scale, y_max_scaled):
    """
    1 ページ分の複数サブプロットを描画(Y 軸スケールは共通)。
    欠測日(記録なし)は 0 として埋め、棒グラフの本数を各日付で揃える。
    """
    # 縦 GRID_ROWS 行のグリッド(横は1固定)、上下の間隔は0(省スペース)
    gs = fig.add_gridspec(GRID_ROWS, hspace=0)  # サブプロット間の縦方向余白は0(省スペース)
    axes = gs.subplots(sharex=True, sharey=False).flatten()

    # x 軸再インデックス用:'YYYY-mm-dd' 文字列ラベルに対応する DatetimeIndex
    full_dates = pd.to_datetime(date_labels)

    for ax, folder in zip(axes, folders):
        # 該当フォルダの行だけを抽出
        df_f = df.loc[df["Folder Path"] == folder].copy()
        if df_f.empty:
            # データが無い場合は何も描かない(空サブプロット)
            continue

        # Date を index にし、全ての日付(full_dates)で再インデックス
        # → 欠測日は fill_value=0 でゼロ埋め(棒の本数を揃える)
        s = (
            df_f.set_index("Date")["Size (bytes)"]
            .sort_index()
            .reindex(full_dates, fill_value=0)
        )

        # 表示は選択された単位でスケーリング
        y_values = (s.values / scale).tolist()
        color = folder_colors.get(folder, "steelblue") # 未割り当ては既定色
        ax.bar(date_labels, y_values, color=color)
        ax.set_ylim(0, y_max_scaled)                  # ページ間で揃えるため共通上限

        # 目盛本数は5 に制限(ラベル重なりを抑制)
        ax.yaxis.set_major_locator(mticker.MaxNLocator(nbins=5))
        ax.set_ylabel(f"[{unit}]", fontsize=8, loc="center")
        plt.setp(ax.get_yticklabels(), fontsize=8)

        # タイトル代わりに枠内へフォルダ名を表示(省スペース)
        ymax = ax.get_ylim()[1]
        ax.text(
            0.5,                      # X: 軸範囲の中央(0〜1 の相対値)
            ymax * 0.15,              # Y: 上から少し下げた位置に配置
            str(folder),
            ha="center",
            va="top",
            fontsize=8,
            fontweight="bold",
            color="black",
            bbox=dict(facecolor="white", edgecolor="none", alpha=0.7, boxstyle="round,pad=0.2"),
            transform=ax.get_yaxis_transform(),  #タイトルをY軸座標系(0〜1)で配置
        )

    # 補助グリッド
    for ax in fig.axes:
        ax.grid(axis="x", linestyle="--", alpha=0.5)
        ax.grid(axis="y", linestyle="--", alpha=0.5)

    # X 軸ラベルは最下段のみ表示 (全サブプロットで共有)
    if len(axes) > 0:
        final_ax = axes[-1]
        final_ax.tick_params(axis="x", labelbottom=True)
        plt.setp(final_ax.get_xticklabels(), rotation=90, ha="center", fontsize=8)

    # 上段側の X ラベル/目盛は自動で非表示に (共有設定による)
    for ax in axes:
        ax.label_outer()

    # 図全体の外周余白をまとめて調整
    fig.subplots_adjust(**MARGINS)


# =============================================================================
# PDF Saving
# =============================================================================
def save_to_pdf(df, output_pdf, folder_colors, folders_ordered, unit, scale, y_max_scaled):
    """
    ページ分割しながら PDF に保存(Y 軸スケールは全ページ共通)。
    folders を GRID_ROWS 件ずつに分割し、各チャンク = 1 ページとして描画
    """
    # 描画対象フォルダ
    folders = folders_ordered or df["Folder Path"].dropna().unique().tolist()
    if not folders:
        print("---> [INFO] 描画対象フォルダなし")
        return

    # X 軸に使う全日付ラベルを先に作っておく(全ページで共通にするため)
    date_xlabels = build_date_xlabels(df)

    # 出力先ディレクトリが無い場合に備えて作成
    output_pdf.parent.mkdir(parents=True, exist_ok=True)

    with PdfPages(output_pdf) as pdf:
        # GRID_ROWS ごとに分割 → 1 チャンク = 1 ページ
        for chunk in chunked(folders, GRID_ROWS):
            fig = plt.figure(figsize=FIGSIZE_INCH)
            plot_page(fig, df, chunk, date_xlabels, folder_colors, unit, scale, y_max_scaled)
            pdf.savefig(fig)  # 1 ページ分を保存
            plt.close(fig)    # メモリ解放(大量ページでも枯渇しないように)

    print(f"---> [OK] PDF保存完了: {output_pdf}")


# =============================================================================
# Main
# =============================================================================
def main() -> None:
    """
    実行フローを統括するエントリポイント。
    入力(GUI) → ルート読込 → CSV選択 → 結合/整形 → 配色決定 → PDF出力
    """
    # 1) ルートフォルダのパス(色割り当て等で使用)
    root_paths = read_root_paths(ROOTS_LIST_FILE, FILE_ENCODING)

    # Depthはデフォルト値を採用
    target_depth = DEFAULT_TARGET_DEPTH

    # CSVは自動検出
    csv_files = select_csv_files(program_folder)
    if not csv_files:
        print("---> [ERROR] CSVが見つかりません。")
        print(f"---> 探索1: {program_folder / folder_name}")
        print(f"---> 探索2: {program_folder}")
        sys.exit(1)

    # csvからフォルダのデータフレーム作成
    df = load_and_merge(csv_files, target_depth)
    if df.empty:
        print("---> [ERROR] 有効なデータがありません。")
        sys.exit(1)
        
    # 2) フォルダをアルファベット順で並べ替え
    folders_ordered = order_folders_by_root(df, target_depth)

    # 3) ルートごとに色を割り当て(tab20 カラーマップ)
    #    Depth=0: フォルダごとに異なる色
    #    Depth>0: 同一ルート配下は同色(root_folder.txt の行順に準じる)
    root_color_map = build_root_color_map(root_paths, cmap_name="tab20")
    folder_colors = {}

    if target_depth == 0:
        base_folders = sorted(df["Folder Path"].dropna().unique().tolist())
        tab20_colors = plt.get_cmap("tab20").colors
        for i, f in enumerate(base_folders):
            folder_colors[f] = tab20_colors[i % len(tab20_colors)]
    else:
        for f in df["Folder Path"].unique():
            root_folder = find_root_folder(f, root_paths)
            folder_colors[f] = root_color_map.get(root_folder, "gray")

    # 4) 全フォルダの Size (bytes) の最大値から単位/スケールを決定
    #    Y 軸の上限は「最大値 / scale × 1.05」(5%の余裕)
    global_max_bytes = float(df["Size (bytes)"].max() or 0.0)
    unit, scale = pick_unit_and_scale(global_max_bytes)
    y_max_scaled = (global_max_bytes / scale) * 1.05 if global_max_bytes > 0 else 1.0

    # 5) PDF 出力(複数ページ対応)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_pdf = PDF_DIR / f"folder_size_plot_(depth{int(target_depth)})_{timestamp}.pdf"
    save_to_pdf(df, output_pdf, folder_colors, folders_ordered, unit, scale, y_max_scaled)


if __name__ == "__main__":
    main()

・出力例(PDF)

04_output_sample.png

4. ユーザー分析用可視化スクリプト (folder_size_for_analysis.py)

主な機能

  • ユーザーは GUI(tkinter)を操作して以下を実行
    • 描画対象の階層 TARGET_DEPTH(0以上の整数)を入力
    • 描画対象のルートフォルダを選択(複数選択可)
    • 可視化したい CSV ファイルを複数選択
  • 同一日付×同一フォルダの重複データがある場合は「最新の Execution Time」を採用
  • 全フォルダで共通の Y 軸スケールと単位(B / KB / MB / GB / TB)を自動決定
  • 棒グラフを A4 縦レイアウトで自動ページ分割して PDF 出力
  • folder_size_pdf/ にPDFを保存します
  • 基本、folder_size_auto_plot.pyに、GUI(tkinter)関連するコードをプラス
Pythonコードを表示
"""
フォルダサイズの時系列を棒グラフで描画し、階層フォルダ数が多い場合は
複数ページのPDFに分割して保存する可視化ツール。

======================================
■ このスクリプトでできること
======================================
- フォルダサイズを記録した複数CSVを読み込み、指定階層(TARGET_DEPTH)だけ抽出
- 同一日付×同一フォルダの重複がある場合は「最新の Execution Time」を採用
- すべてのグラフで「共通のY軸単位・スケール」を自動決定(B/KB/MB/GB/TB…)
- フォルダ数が多くても A4 縦の PDF に複数ページで自動分割して保存
- TARGET_DEPTH=0 と >0 で配色と描画順の仕様を切り替え

======================================
■ 前提・必須ファイル
======================================
- root_folder.txt(1行1パス, 文字コードは FILE_ENCODING)
  * 例: 
      D:/Project_A
      D:/Project_B
- 可視化対象のCSV群(列名が必須・順不同で可)
  * 必須列: 
      Depth, Folder Path, Size (bytes), Size, Execution Time
  * 「Execution Time」は日時(例: 2025/10/24 23:59:00)
  * Depth は整数で、0=ルート階層、1=直下 … を想定

======================================
■ 色と描画順の仕様
======================================
- TARGET_DEPTH = 0:
    - フォルダごとに異なる色(tab20 カラーマップで順繰り)
    - フォルダはアルファベット昇順で描画
- TARGET_DEPTH > 0:
    - 同じ root_folder.txt の行番号に対応するルート配下は同色
    - root_folder.txt の行順に準じる描画(同一ルート配下を同系色に)

======================================
■ 可視化設計
======================================
- 1ページあたり GRID_ROWS 個のサブプロットを縦に積む(横は1固定)
- 目盛は MaxNLocator により概ね 5 本程度に自動調整
- X軸ラベル(YYYY-mm-dd)は最下段のサブプロットのみ表示(省スペース)
- 全フォルダで共通のY軸スケール(全体最大値を基準に単位と上限を決定)

======================================
■ 使い方(標準フロー)
======================================
1) スクリプトと同じフォルダに root_folder.txt を配置する
2) 可視化したい CSV を用意する(複数選択可)
3) 実行すると GUI で TARGET_DEPTH(0以上)を入力
4) TARGET_DEPTH>0 の場合は、可視化対象ルートを複数選択
5) CSVファイルをファイルダイアログで複数選択
6) A4縦の PDF が同フォルダに出力される
   例: folder_size_plot_(depth0)_YYYYmmdd_HHMMSS.pdf

======================================
■ エラー/例外時の挙動
======================================
- root_folder.txt が見つからない: エラーメッセージ表示後に終了
- CSV未選択・有効行なし・選択ルート配下に行なし: エラーメッセージ表示後に終了
- GUI 入力をキャンセル/失敗: デフォルト値(DEFAULT_TARGET_DEPTH)を採用
- CSV 読込失敗: 該当ファイルをスキップして続行(WARN を表示)

======================================
■ 動作環境メモ
======================================
- OS: Windows を想定(FILE_ENCODING="shift-jis"/tkinterのGUI)
- Python: pandas, matplotlib, japanize_matplotlib が必要
- 文字化けする場合は FILE_ENCODING を "utf-8" 等に変更してください
- 「Date」列は内部計算用に生成(Execution Time の日付のみを正規化)
"""

from pathlib import Path
import sys
from datetime import datetime

import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib  # noqa: F401 (日本語フォント有効化の副作用目的)
from matplotlib import ticker as mticker
from matplotlib.backends.backend_pdf import PdfPages
import tkinter as tk
from tkinter import filedialog
import tkinter.simpledialog as simpledialog
from tkinter import messagebox

# =============================================================================
# Settings
# =============================================================================
# このスクリプトが置かれているフォルダ(解決済み絶対パス)
program_folder = Path(__file__).parent.resolve()

# 監視対象ルートの一覧ファイル(1行1パス)
ROOTS_LIST_FILE = program_folder / "root_folder.txt"

# root_folder.txt の文字コード(Windows日本語環境を想定)
FILE_ENCODING = "shift-jis"

# デフォルトの描画対象階層(0=ルート階層)
DEFAULT_TARGET_DEPTH = 0

# PDF出力先ディレクトリを指定
PDF_DIR = program_folder / "folder_size_pdf"
PDF_DIR.mkdir(parents=True, exist_ok=True)  # 無ければ自動作成

# レイアウト(A4縦)
GRID_ROWS = 5  # 1ページに配置するサブプロット数(縦方向のみ; 横は1固定)
FIGSIZE_INCH = (8.27, 11.69)  # A4縦のインチサイズ
MARGINS = dict(left=0.1, right=0.95, top=0.95, bottom=0.15)  # 余白(図全体の外周)


# =============================================================================
# # GUI: User Inputs
# =============================================================================
def get_target_depth(target_depth):
    """GUIで描画対象のフォルダ階層を入力する。キャンセル時はNoneを返す。"""
    try:
        root = tk.Tk()
        root.withdraw()
        val = simpledialog.askinteger(
            title="TARGET_DEPTH の入力",
            prompt="描画するフォルダの階層を入力してください(0以上の整数)",
            initialvalue=target_depth,
            minvalue=0,
            parent=root,
        )
        root.destroy()
        
        if val is None:
            print("---> [INFO] Depth入力がキャンセルされました。処理を終了します。")
            return None
        return int(val)
    except Exception:
        print(f"---> [WARN] GUI入力失敗 → DEFAULT_TARGET_DEPTH={target_depth}")
        return None


def select_csv_files(initial_folder):
    """GUIでCSVファイルを複数選択し、選択結果のパスを返す。"""
    try:
        root = tk.Tk()
        root.withdraw()
        init = str(initial_folder) if initial_folder and initial_folder.exists() else None
        paths = filedialog.askopenfilenames(
            title="フォルダサイズのCSVを選択(複数可)",
            initialdir=init,
            filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
        )
        root.destroy()

        if not paths:
            print("---> [INFO] CSVファイル選択がキャンセルされました。処理を終了します。")
            return None        
        return [Path(p) for p in paths]
    except Exception:
        return []


def choose_multiple_roots(root_paths):
    """
    root_folder.txt 内のルート候補から、描画対象ルートを GUI で複数選択する。
    TARGET_DEPTH > 0 のときにのみ使用。
    """
    try:
        root = tk.Tk()
        root.title("描画対象のルートフォルダを選択(複数可)")
        root.geometry("700x400")
        tk.Label(root, text="描画対象ルートを選んでください(複数選択可)").pack(pady=8)

        frame = tk.Frame(root)
        frame.pack(fill="both", expand=True, padx=10, pady=10)
        scrollbar = tk.Scrollbar(frame)
        scrollbar.pack(side="right", fill="y")

        lb = tk.Listbox(frame, selectmode="extended")
        lb.pack(side="left", fill="both", expand=True)
        lb.config(yscrollcommand=scrollbar.set)      # スクロール連動
        scrollbar.config(command=lb.yview)

        # Listbox のアイテム(表示用の文字列配列にしておく)
        items = [str(p) for p in root_paths]
        for s in items:
            lb.insert("end", s)

        selected = []  # 最終的に返す選択結果(文字列)

        def on_ok():
            # 現在の選択(複数可)だけを確定
            for i in lb.curselection():
                selected.append(items[i])
            root.destroy()

        def on_all():
            # すべてを可視化対象とする
            selected.extend(items)
            root.destroy()

        def on_cancel():
            # キャンセル押下 → Noneを返すため印を付ける
            selected.clear()
            # 「キャンセルされた」ということを示す印(フラグ文字列)
            selected.append("__CANCEL__")
            root.destroy()

        btns = tk.Frame(root)
        btns.pack(pady=6)
        tk.Button(btns, text="OK(選択したもの)", command=on_ok, width=22).grid(row=0, column=0, padx=4)
        tk.Button(btns, text="すべて選ぶ", command=on_all, width=14).grid(row=0, column=1, padx=4)
        tk.Button(btns, text="キャンセル", command=on_cancel, width=12).grid(row=0, column=2, padx=4)

        # イベントループ開始(ボタン押下で destroy される)
        root.mainloop()

        # ---- キャンセル判定 ----
        if "__CANCEL__" in selected:
            print("---> [INFO] ルート選択がキャンセルされました。処理を終了します。")
            return None
        # ---- 通常選択 ----
        if not selected:
            # 何も選ばれず閉じた場合は全件扱い
            print("---> [INFO] 全ルートフォルダが選択されました。")
            return [str(p) for p in root_paths]

        return selected

    except Exception:
        # GUI 初期化に失敗した等 → 全件扱い(フィルタなし)
        print("---> [INFO] 全ルートフォルダが選択されました。")
        return [str(p) for p in root_paths]


# =============================================================================
# Utilities
# =============================================================================
def read_root_paths(root_file, file_encoding):
    """
    監視対象ルート一覧ファイル(root_folder.txt)を読み込む。

    Returns
    -------
    list[Path]
        空行を除いたパスのリスト。ファイルが無い場合は終了。
    """
    try:
        with open(root_file, "r", encoding=file_encoding) as f:
            root_dirs = [Path(line.strip()) for line in f if line.strip()]
            return root_dirs
    except FileNotFoundError:
        print(f"---> 監視ディレクトリのリストが見つかりません: {root_file}")
        sys.exit(1)


def chunked(seq, size):
    """
    イテラブルを size ごとの塊(チャンク)に分割するジェネレータ。
    """
    for i in range(0, len(seq), size):
        yield seq[i: i + size]


def pick_unit_and_scale(max_value):
    """
    最大値に応じて単位(B, KB, MB, ...)とスケール係数を決定する。
    例)最大値が 3,200 の場合 → 'KB', 1024 を返し、表示値は (値/1024)
    """
    value = float(max_value)
    if value <= 0:
        # データが無い/0 の場合は B・スケール1で返す
        return "B", 1.0

    units = ["", "K", "M", "G", "T", "P"]  # 2の10乗刻みの接頭辞
    scale = 1.0                            # 実値をこの scale で割って表示する
    value = float(max_value)
    for u in units:
        # value が 1024 未満になった段階の単位を採用
        if value < 1024:
            return f"{u}B", scale
        # 1024 を超える間は次の単位へ(scale も 1024 倍ずつ)
        value /= 1024
        scale *= 1024
    # それでも大きすぎる場合は PB 固定
    return "PB", float(1024**5)


def normalize_path_str(p):
    """
    パス表記ゆれを統一(\ → /、末尾スラッシュ除去)。
    OS 違いやユーザ入力表記の差を吸収し、startswith 判定を安定化。
    """
    return str(Path(p)).replace("\\", "/").rstrip("/")


def find_root_folder(path, root_paths):
    """
    指定 path がどのルート配下かを探索して返す。
    - normalize したうえで startswith による包含判定を行う
    - 最初にマッチしたルート(root_folder.txt の上から優先)を返す
    """
    p_norm = normalize_path_str(path)
    for root in root_paths:
        r_norm = normalize_path_str(root)
        if p_norm.startswith(r_norm):
            return str(root)
    return None  # どのルートにも属さない場合


def build_root_color_map(root_paths, cmap_name):
    """
    ルートごとに色を割り当てる辞書を生成する(カラーマップから順繰りに取得)。
    """
    cmap = plt.get_cmap(cmap_name)
    n = cmap.N
    return {str(root): cmap(i % n) for i, root in enumerate(root_paths)}


def build_date_xlabels(df):
    """
    DataFrame の Date 列(Datetime型/日付正規化済み)から、
    ユニークな日付を昇順で抽出し、'YYYY-mm-dd' の文字列配列にする。
    """
    # NaT を除外 → 正規化(00:00:00) → 昇順 → 重複除去
    s = pd.to_datetime(df["Date"], errors="coerce").dropna().dt.normalize()
    s = s.sort_values().drop_duplicates()
    # 棒グラフの xtick 用に文字列化
    return s.dt.strftime("%Y-%m-%d").tolist()


# =============================================================================
# Data Processing
# =============================================================================
def load_and_merge(csv_files, target_depth):
    """
    複数 CSV から指定 target_depth の行だけを抽出し、
    「同日×同フォルダ」では Execution Time が最新の 1 件を残して結合する。
    """
    frames = []

    for path in csv_files:
        if not path.exists():
            print(f"[WARN] ファイルなし: {path}")
            continue
        try:
            df = pd.read_csv(path)
        except Exception as exc:
            print(f"[WARN] 読込失敗: {path.name} -> {exc}")
            continue

        # --- ここが重要:可視化対象の階層だけに限定 ---
        df = df.loc[df["Depth"] == int(target_depth)].copy()
        if df.empty:
            # 当該CSVに可視化対象階層が無い場合はスキップ
            continue

        # 実測時刻(Execution Time)→ 日付(Date)を内部生成
        df["Execution Time"] = pd.to_datetime(df["Execution Time"], errors="coerce")
        df["Date"] = df["Execution Time"].dt.normalize()

        # 可視化に必要な列のみ残す(順序は後段操作に合わせる)
        frames.append(df[["Date", "Execution Time", "Folder Path", "Size (bytes)", "Depth"]])

    if not frames:
        # 1ファイルも有効データが無い場合の空DataFrame(後段で empty 判定)
        return pd.DataFrame(columns=["Date", "Execution Time", "Folder Path", "Size (bytes)", "Depth"])

    # ---- 同一日付×同一フォルダの最新時刻を 1 件残す手順 ----
    merged = pd.concat(frames, ignore_index=True)

    # (1) 並べ替え:Date→Folder Path→Execution Time(昇順)
    #     最新を採るため、いったん昇順にしてから drop_duplicates(keep='last') を使う
    merged = merged.sort_values(["Date", "Folder Path", "Execution Time"], ascending=True)

    # (2) 「同日×同フォルダ」の重複を後ろ(最新)だけ残して削除
    #     これにより、1日1フォルダあたり 1 レコードに圧縮される
    df = merged.drop_duplicates(subset=["Date", "Folder Path"], keep="last")

    # (3) 可視化時は日付の昇順で見せたいので再度 Date で整列
    df = df.sort_values("Date", ascending=True).reset_index(drop=True).copy()

    return df


# =============================================================================
# Folder Sorting
# =============================================================================
def order_folders_by_root(df, target_depth):
    """ フォルダの描画順を決める。常にアルファベット昇順(対象階層やルート選択順にかかわらず) """
    folders = df["Folder Path"].dropna().unique().tolist()
    return sorted(folders)


# =============================================================================
# Plotting
# =============================================================================
def plot_page(fig, df, folders, date_labels, folder_colors, unit, scale, y_max_scaled):
    """
    1 ページ分の複数サブプロットを描画(Y 軸スケールは共通)。
    欠測日(記録なし)は 0 として埋め、棒グラフの本数を各日付で揃える。
    """
    # 縦 GRID_ROWS 行のグリッド(横は1固定)、上下の間隔は0(省スペース)
    gs = fig.add_gridspec(GRID_ROWS, hspace=0)  # サブプロット間の縦方向余白は0(省スペース)
    axes = gs.subplots(sharex=True, sharey=False).flatten()

    # x 軸再インデックス用:'YYYY-mm-dd' 文字列ラベルに対応する DatetimeIndex
    full_dates = pd.to_datetime(date_labels)

    for ax, folder in zip(axes, folders):
        # 該当フォルダの行だけを抽出
        df_f = df.loc[df["Folder Path"] == folder].copy()
        if df_f.empty:
            # データが無い場合は何も描かない(空サブプロット)
            continue

        # Date を index にし、全ての日付(full_dates)で再インデックス
        # → 欠測日は fill_value=0 でゼロ埋め(棒の本数を揃える)
        s = (
            df_f.set_index("Date")["Size (bytes)"]
            .sort_index()
            .reindex(full_dates, fill_value=0)
        )

        # 表示は選択された単位でスケーリング
        y_values = (s.values / scale).tolist()
        color = folder_colors.get(folder, "steelblue") # 未割り当ては既定色
        ax.bar(date_labels, y_values, color=color)
        ax.set_ylim(0, y_max_scaled)                  # ページ間で揃えるため共通上限

        # 目盛本数は5 に制限(ラベル重なりを抑制)
        ax.yaxis.set_major_locator(mticker.MaxNLocator(nbins=5))
        ax.set_ylabel(f"[{unit}]", fontsize=8, loc="center")
        plt.setp(ax.get_yticklabels(), fontsize=8)

        # タイトル代わりに枠内へフォルダ名を表示(省スペース)
        ymax = ax.get_ylim()[1]
        ax.text(
            0.5,                      # X: 軸範囲の中央(0〜1 の相対値)
            ymax * 0.15,              # Y: 上から少し下げた位置に配置
            str(folder),
            ha="center",
            va="top",
            fontsize=8,
            fontweight="bold",
            color="black",
            bbox=dict(facecolor="white", edgecolor="none", alpha=0.7, boxstyle="round,pad=0.2"),
            transform=ax.get_yaxis_transform(),  #タイトルをY軸座標系(0〜1)で配置
        )

    # 補助グリッド
    for ax in fig.axes:
        ax.grid(axis="x", linestyle="--", alpha=0.5)
        ax.grid(axis="y", linestyle="--", alpha=0.5)

    # X 軸ラベルは最下段のみ表示 (全サブプロットで共有)
    if len(axes) > 0:
        final_ax = axes[-1]
        final_ax.tick_params(axis="x", labelbottom=True)
        plt.setp(final_ax.get_xticklabels(), rotation=90, ha="center", fontsize=8)

    # 上段側の X ラベル/目盛は自動で非表示に (共有設定による)
    for ax in axes:
        ax.label_outer()

    # 図全体の外周余白をまとめて調整
    fig.subplots_adjust(**MARGINS)


# =============================================================================
# PDF Saving
# =============================================================================
def save_to_pdf(df, output_pdf, folder_colors, folders_ordered, unit, scale, y_max_scaled):
    """
    ページ分割しながら PDF に保存(Y 軸スケールは全ページ共通)。
    folders を GRID_ROWS 件ずつに分割し、各チャンク = 1 ページとして描画
    """
    # 描画対象フォルダ(明示順があればそれを使い、無ければ登場順に)
    folders = folders_ordered or df["Folder Path"].dropna().unique().tolist()
    if not folders:
        print("---> [INFO] 描画対象フォルダなし")
        return

    # X 軸に使う全日付ラベルを先に作っておく(全ページで共通にするため)
    date_xlabels = build_date_xlabels(df)

    # 出力先ディレクトリが無い場合に備えて作成
    output_pdf.parent.mkdir(parents=True, exist_ok=True)

    with PdfPages(output_pdf) as pdf:
        # GRID_ROWS ごとに分割 → 1 チャンク = 1 ページ
        for chunk in chunked(folders, GRID_ROWS):
            fig = plt.figure(figsize=FIGSIZE_INCH)
            plot_page(fig, df, chunk, date_xlabels, folder_colors, unit, scale, y_max_scaled)
            pdf.savefig(fig)  # 1 ページ分を保存
            plt.close(fig)    # メモリ解放(大量ページでも枯渇しないように)

    print(f"---> [OK] PDF保存完了: {output_pdf}")

    # PDF完了メッセージをGUI表示 =======
    try:
        root = tk.Tk()
        root.withdraw()  # メインウィンドウ非表示
        messagebox.showinfo(
            "PDF出力完了",
            f"グラフPDFの作成が完了しました。\n\n保存先:\n{output_pdf}"
        )
        root.destroy()
    except Exception:
        # GUIが使用できない環境(CLIなど)ではprintのみ
        print(f"[INFO] PDF出力完了: {output_pdf}")

# =============================================================================
# Main
# =============================================================================
def main() -> None:
    """
    実行フローを統括するエントリポイント。
    入力(GUI) → ルート読込 → CSV選択 → 結合/整形 → 配色決定 → PDF出力
    """
    # 1) 描画対象階層(TARGET_DEPTH)を決定
    target_depth = get_target_depth(DEFAULT_TARGET_DEPTH)
    if target_depth is None:
        sys.exit(0)

    # 2) root_folder.txt を読み込み(監視ルート一覧を取得)
    root_paths = read_root_paths(ROOTS_LIST_FILE, FILE_ENCODING)

    # 3) TARGET_DEPTH >= 0 の場合のみ描画対象ルートを複数選択
    #    Depth=0 はルート階層単位の可視化なので選択不要
    if target_depth >= 0:
        selected_roots = choose_multiple_roots(root_paths)
        if selected_roots is None:
            sys.exit(0)

    # 4) 描画対象となるフォルダサイズ CSV を選択
    csv_files = select_csv_files(program_folder)
    if csv_files is None:
        sys.exit(0)
    elif not csv_files:
        print("---> [ERROR] CSVが選択されていません。")
        sys.exit(1)

    # 5) CSV を読み込み、target_depth に一致する行を抽出・結合
    #    (同日×同フォルダは最新1件を残す)
    df = load_and_merge(csv_files, target_depth)
    if df.empty:
        print(f"---> [ERROR] TARGET_DEPTH={target_depth}の有効なデータがありません。")
        sys.exit(1)

    # 6) TARGET_DEPTH > 0 の場合、選択されたルート配下のみに絞り込み
    #    normalize_path_str() でパス表記を統一し、startswith で包含判定
    if target_depth > 0:
        sel_norms = {normalize_path_str(r) for r in selected_roots}
        df = df[df["Folder Path"].apply(lambda p: any(normalize_path_str(p).startswith(r) for r in sel_norms))]
        if df.empty:
            print("---> [ERROR] 選択ルート配下のデータなし")
            sys.exit(1)

    # 7) フォルダをアルファベット順で並べ替え
    folders_ordered = order_folders_by_root(df, target_depth)

    # 8) ルートごとに色を割り当て(tab20 カラーマップ)
    #    Depth=0: フォルダごとに異なる色
    #    Depth>0: 同一ルート配下は同色(root_folder.txt の行順に準じる)
    root_color_map = build_root_color_map(root_paths, cmap_name="tab20")
    folder_colors = {}

    if target_depth == 0:
        base_folders = sorted(df["Folder Path"].dropna().unique().tolist())
        tab20_colors = plt.get_cmap("tab20").colors
        for i, f in enumerate(base_folders):
            folder_colors[f] = tab20_colors[i % len(tab20_colors)]
    else:
        for f in df["Folder Path"].unique():
            root_folder = find_root_folder(f, root_paths)
            folder_colors[f] = root_color_map.get(root_folder, "gray")

    # 9) 全フォルダの Size (bytes) の最大値から単位/スケールを決定
    #    Y 軸の上限は「最大値 / scale × 1.05」(5%の余裕)
    global_max_bytes = float(df["Size (bytes)"].max() or 0.0)
    unit, scale = pick_unit_and_scale(global_max_bytes)
    y_max_scaled = (global_max_bytes / scale) * 1.05 if global_max_bytes > 0 else 1.0

    # 10) PDF 出力(複数ページ対応)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_pdf = PDF_DIR / f"folder_size_plot_(depth{int(target_depth)})_{timestamp}.pdf"
    save_to_pdf(df, output_pdf, folder_colors, folders_ordered, unit, scale, y_max_scaled)

if __name__ == "__main__":
    main()

・GUI の流れ

このスクリプトでは、ユーザーがマウス操作だけで設定を行えるよう
tkinter の GUI ダイアログ が順に表示されます。

GUI ダイアログ(例)

05_GUI_Sample.png

① 階層(TARGET_DEPTH)の入力

最初に、描画対象とするフォルダ階層を入力します。

  • 入力例
  • 0 → ルートフォルダ単位で可視化
  • ≧1 → サブフォルダ単位で可視化
  • 入力をキャンセルすると処理は終了します

② ルートフォルダの選択(複数選択可)

次に、root_folder.txt に記載された監視ルートの中から
可視化対象とするルートを選びます。

  • 複数選択可能(Ctrl / Shift キーを併用)
  • ボタンの動作:
ボタン 動作内容
OK(選択したもの) 選択したルートのみを可視化対象にする
すべて選ぶ 全ルートを対象にする
キャンセル 処理を終了する

③ CSVファイルの選択

最後に、可視化したいフォルダサイズのCSVを選択します。
folder_size_auto_scan.py で生成したCSVを指定)

  • 表示タイトル
  • 複数ファイルを選択した場合、全データを統合して可視化します
  • 選択をキャンセルすると処理を終了します

・出力例(PDF)

すべての設定が完了すると、
選択条件に基づいた棒グラフが自動生成され、PDFとして出力されます。

  • 出力先:folder_size_pdf/
  • 出力ファイル名:(例:folder_size_plot_(depth1)_20251103_150000.pdf

📄 出力サンプルPDFはこちら(Google Drive)

今後の発展アイデア

  • 社内のメール送信による自動レポート
  • しきい値を超えたフォルダをメールによる自動警告
  • Dash+Plotlyで、Webダッシュボード化+グラフ化
  • ロガー機能の追加

まとめ

今回の開発を通して、Pythonを使えば「日常の面倒な作業」の自動化見える化できることを実感した。
もし同じようにファイルサーバの容量管理に悩んでいる方がおられましたら、参考にしてみてください。

学習を通して得たこと

今回のツール開発は、Pythonスクール 「BizCodeX」 の「アウトプット①」として取り組みました。実際の業務課題を出発点にしたことで、自然とモチベーションが高まり、学びがより実践的なものになりました。

入校から5週間目にして初めて完成した成果物で、開発にかけた時間は約30時間。
入校前のスキルは、Python入門テキストを読んで「テキストファイルを読み込んで処理する」程度でしたが、今回は 実際の業務課題をベースに「自分で動く仕組み」を作り上げるという貴重な経験を得ることができました。

このアウトプットを通して最も学んだのは、AIが生成したコードを「コピペで終わらせない」ことの重要性です。

真の学習は、コードが生成され、動作確認ができた「後」に始まります。この考え方は、スクールでの指導を通じて教わったことです。

具体的には、次のような手順でコード理解を深めるよう指導を受けました。

 1. 1つ1つの処理でインプットとアウトプットを確認する
 2. コードの振る舞い(ロジックの流れ)を理解する
 3. 実際にコードを読みながら、フロー(処理の順序)を自分の言葉でまとめる
 4. なぜAIがそのコードを提案したのか、AIに質問して理由を理解する
 5. Google検索なども活用し、他のアプローチがないか検討する
 6. フロー(処理の順序)の理解が正しいかをAIに確認する 
  (理解内容を言語化し、「この説明で合っていますか?」「何割くらい正しいですか?」とAIに尋ね、フィードバックをもとに修正・補強していく。)

このように、「AIに頼る」じゃなくて「AIと一緒に考える」として活用する姿勢を学びました。
そして、この姿勢こそが、AI時代のプログラミング学習で最も大切な考え方であると、スクールでの指導を通して強く実感しました。

参考

  • python:3.12.9
  • pandas:2.2.3
  • matplotlib:3.10.1
  • japanize_matplotlib: 1.1.3
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?