はじめに
社内のオンプレミスのファイルサーバの容量がひっ迫すると、「どのフォルダが原因?」を調べるのに毎回時間がかかる——。そんな悩みを解決するために、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)
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 ダイアログ が順に表示されます。
① 階層(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)
今後の発展アイデア
- 社内のメール送信による自動レポート
- しきい値を超えたフォルダをメールによる自動警告
- 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

