ファイル操作 中級 更新: 2026-04-26

フォルダ内のファイルを拡張子別に自動仕分けするスクリプト

ダウンロードフォルダや「とりあえず保存」フォルダに溜まった雑多なファイルを、拡張子から判定して画像・PDF・Excel・動画など9カテゴリのサブフォルダへ自動的に仕分けるPythonスクリプトです。同名衝突はタイムスタンプ付与で回避し、サブフォルダ再帰や原本コピー保存にも対応。実行ごとに仕分け結果のサマリーTXTを残すので、整理後に「あのファイルどこ行った?」となっても追跡できます。

ダウンロードフォルダや「とりあえず保存」フォルダに、PDF・Excel・画像・動画が混在で溜まっていませんか?このスクリプトは対象フォルダを走査し、拡張子から判定したカテゴリ(画像 / PDF / Excel / 動画など9種)のサブフォルダへ自動でファイルを仕分けます。`shutil.move` で物理整理するモードと、`shutil.copy2` で原本を残すモードの2択です。

このスクリプトでできること

  • 拡張子による9カテゴリ自動判定(画像・PDF・Excel・Word・PowerPoint・動画・音声・テキスト・圧縮)
  • 移動モード/コピー保存モードの切替
  • 同名ファイル衝突時のタイムスタンプ自動付与
  • サブフォルダ再帰スキャン(仕分け済みフォルダは自動除外)
  • 仕分けサマリーTXTの自動出力(カテゴリ別集計 + 詳細ログ表)
フォルダ内のファイル整理(フォルダ仕分け).pybes をダウンロード

.pybes ファイルをPybesにインポートすると、スクリプトと設定フィールドが自動で読み込まれます。

設定フィールド

このスクリプトで使用する設定フィールドです。Pybes上でGUIから値を入力できます。

target_dir フォルダ 必須

対象フォルダ

仕分け対象のファイルが入っているフォルダ

デフォルト: C:\Users\<your-name>\Downloads

include_sub チェックボックス 必須

サブフォルダも含める

ON にすると配下のサブフォルダ内のファイルも対象に含めます

デフォルト: false

file_mode ドロップダウン 必須

元ファイルの扱い

「移動する」は元の場所から消えます。「コピーして残す」は原本を残します。

選択肢: 移動する, コピーして残す

デフォルト: 移動する

コード解説

import sys
import json
import os
import shutil
from datetime import datetime

with open(sys.argv[1], encoding="utf-8") as f:
    inputs = json.load(f)

target_dir = inputs["対象フォルダ"]
include_sub = inputs["サブフォルダも含める"] == "true"
file_mode = inputs["元ファイルの扱い"]

CATEGORY_MAP = {
    "画像":       {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".ico", ".heic", ".raw"},
    "PDF":        {".pdf"},
    "Excel":      {".xlsx", ".xls", ".xlsm", ".xlsb", ".csv"},
    "Word":       {".docx", ".doc", ".dotx"},
    "PowerPoint": {".pptx", ".ppt", ".potx"},
    "動画":       {".mp4", ".mov", ".avi", ".mkv", ".wmv", ".flv", ".webm", ".m4v"},
    "音声":       {".mp3", ".wav", ".aac", ".flac", ".m4a", ".ogg", ".wma"},
    "テキスト":   {".txt", ".md", ".log"},
    "圧縮":       {".zip", ".rar", ".7z", ".tar", ".gz", ".lzh"},
}

SKIP_DIRS = set(CATEGORY_MAP.keys()) | {"その他", "サマリー"}

def get_category(ext):
    ext = ext.lower()
    for category, exts in CATEGORY_MAP.items():
        if ext in exts:
            return category
    return "その他"

def resolve_dest(dest_dir, filename):
    dest_path = os.path.join(dest_dir, filename)
    if not os.path.exists(dest_path):
        return dest_path
    name, ext = os.path.splitext(filename)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    return os.path.join(dest_dir, f"{name}_{ts}{ext}")

def collect_files(base_dir, include_sub):
    result = []
    base_abs = os.path.abspath(base_dir)
    if include_sub:
        for root, dirs, files in os.walk(base_dir):
            root_abs = os.path.abspath(root)
            if root_abs == base_abs:
                dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
            for fname in files:
                result.append(os.path.join(root, fname))
    else:
        for fname in os.listdir(base_dir):
            fpath = os.path.join(base_dir, fname)
            if os.path.isfile(fpath):
                result.append(fpath)
    return result

try:
    print(f"対象フォルダ: {target_dir}")
    print(f"サブフォルダ含める: {'はい' if include_sub else 'いいえ'}")
    print(f"元ファイル: {file_mode}")
    print("---")

    files = collect_files(target_dir, include_sub)
    total = len(files)
    print(f"ファイル数: {total} 件")

    summary = {}
    action_log = []

    for i, src_path in enumerate(files):
        fname = os.path.basename(src_path)
        _, ext = os.path.splitext(fname)
        category = get_category(ext)

        dest_dir = os.path.join(target_dir, category)
        os.makedirs(dest_dir, exist_ok=True)

        dest_path = resolve_dest(dest_dir, fname)

        if file_mode == "移動する":
            shutil.move(src_path, dest_path)
            action = "移動"
        else:
            shutil.copy2(src_path, dest_path)
            action = "コピー"

        summary.setdefault(category, []).append(fname)
        action_log.append((src_path, category, dest_path))

        print(f"  [{i+1}/{total}] {action}: {fname}{category}/")

    # サマリーTXT生成(カテゴリ別集計 + 詳細ログ表形式)
    ts_now = datetime.now().strftime("%Y%m%d_%H%M%S")
    summary_dir = os.path.join(target_dir, "サマリー")
    os.makedirs(summary_dir, exist_ok=True)
    summary_path = os.path.join(summary_dir, f"仕分けサマリー_{ts_now}.txt")

    # 表の列幅を計算
    col_src      = max((len(src)      for src, _, _      in action_log), default=10)
    col_category = max((len(category) for _, category, _ in action_log), default=8)
    col_dest     = max((len(dest)     for _, _, dest     in action_log), default=10)
    col_src      = max(col_src,      len("元のパス"))
    col_category = max(col_category, len("カテゴリ"))
    col_dest     = max(col_dest,     len("移動先パス"))

    sep    = f"+{'-' * (col_src + 2)}+{'-' * (col_category + 2)}+{'-' * (col_dest + 2)}+"
    header = f"| {'元のパス':<{col_src}} | {'カテゴリ':<{col_category}} | {'移動先パス':<{col_dest}} |"

    with open(summary_path, encoding="utf-8", mode="w") as f:
        f.write("仕分けサマリー\n")
        f.write(f"実行日時: {datetime.now().strftime('%Y/%m/%d %H:%M:%S')}\n")
        f.write(f"対象フォルダ: {target_dir}\n")
        f.write(f"サブフォルダ含める: {'はい' if include_sub else 'いいえ'}\n")
        f.write(f"元ファイルの扱い: {file_mode}\n")
        f.write(f"合計: {total}\n")
        f.write("=" * 40 + "\n\n")

        # カテゴリ別集計
        f.write("【カテゴリ別集計】\n")
        for category in sorted(summary.keys()):
            fnames = summary[category]
            f.write(f"  {category}: {len(fnames)}\n")
        f.write("\n")

        # 詳細ログ(表形式)
        f.write("【詳細ログ】\n")
        f.write(sep + "\n")
        f.write(header + "\n")
        f.write(sep + "\n")
        for src, category, dest in action_log:
            f.write(f"| {src:<{col_src}} | {category:<{col_category}} | {dest:<{col_dest}} |\n")
        f.write(sep + "\n")

    print("---")
    print(f"完了! 合計: {total} 件")
    for category, fnames in sorted(summary.items()):
        print(f"  {category}: {len(fnames)} 件")
    print(f"サマリー: {summary_path}")

except Exception as e:
    print(f"エラーが発生しました: {e}", file=sys.stderr)
L1–12

標準ライブラリ4つ(sys / json / os / shutil / datetime)を読み込み、sys.argv[1] でPybesから渡されるJSON設定ファイルを inputs 辞書に展開します。inputs["対象フォルダ"] のように日本語キーで参照しているのは、Pybesアプリの入力欄ラベルが name をそのまま使う仕様で、日本語のままにしておくほうが入力欄が読みやすくなるためです。チェックボックスは文字列 "true" / "false" で届くので == "true" でboolに変換しています。select の値は文字列のまま file_mode に入り、後段の if file_mode == "移動する": で挙動を分岐させます。

L14–33

CATEGORY_MAP は「カテゴリ名 → 拡張子セット」のマッピングで、SKIP_DIRS には自分が作るフォルダ名(画像, PDF, ..., その他, サマリー)を入れて再帰走査の除外対象にします。get_category は拡張子を小文字化してから順に dict を引いて照合し、未登録なら その他 を返します。値を set にしているのは、in 判定が O(1) で速いためです。

L35–58

resolve_dest は同名ファイル衝突時にタイムスタンプを付けたパスを返すヘルパーです。collect_filesinclude_sub=True のとき os.walk で再帰走査し、ルート直下のサブフォルダリストから SKIP_DIRS を除外することで「整理先フォルダ自身を再走査して無限ループ」する事故を防ぎます。dirs[:] = [...] というスライス代入が os.walk における走査対象削除の決まり文句です。

L60–144

メイン処理。ファイルを1件ずつ取り出し、カテゴリ別フォルダに shutil.move または shutil.copy2 で配置します。実行のたびに サマリー/仕分けサマリー_YYYYMMDD_HHMMSS.txt を生成し、カテゴリ別集計と「元のパス → 移動先パス」の表を書き出します。copy2 を使っているのは更新日時などのメタ情報を保持するためで、copy だと日時情報が失われます。

仕組みの詳細

拡張子からカテゴリを決める仕組み

CATEGORY_MAP で「カテゴリ名 → 拡張子セット」のマッピングを定義し、get_category で拡張子を小文字化してから順に照合します。マッチしないファイルは その他 行きにフォールバック。値を set にしているのは in 判定が O(1) で速いためで、新カテゴリを足したい場合も dict に1行追加するだけで対応できます。

サブフォルダ走査と無限再帰の回避

os.walk でフォルダを再帰スキャンしますが、そのままだと「整理先フォルダ自身」も再走査して、画像/ の中の画像をまた 画像/ に入れ直す無限ループに陥ります。これを防ぐため、ルート直下のサブフォルダリストから 画像 / PDF / ... / サマリー を除外する dirs[:] = [...] の書き換えを入れています。os.walk では dirs リストをその場で書き換えると以降の走査対象から外れる、という仕様を利用した定石です。

同名ファイル衝突の解決

移動先に既に同じ名前のファイルがあると shutil.move は上書きしてしまうため、resolve_destos.path.exists をチェックし、衝突時は 元の名前_YYYYMMDD_HHMMSS.拡張子 のようにタイムスタンプを付与します。これで report.pdf が複数フォルダに散らばっていても全部失われずに保存されます。

サマリーTXTで仕分け結果を後追いできる

サマリー/仕分けサマリー_YYYYMMDD_HHMMSS.txt に「カテゴリ別集計」と「元のパス → 移動先パス」の表を出力します。実行直後に「あれ?このファイルどこ行った?」となっても、サマリーTXTを Ctrl+F で検索すれば移動先がすぐ分かります。

応用・カスタマイズ

新しいカテゴリを追加する

CATEGORY_MAP"フォント": {".ttf", ".otf", ".woff", ".woff2"} のような行を1行足すだけで新カテゴリ対応が完了します。SKIP_DIRSCATEGORY_MAP.keys() から自動構築されるため、サブフォルダ走査の除外設定も自動的に追従します。

プレビュー(dry-run)モードを足す

本番実行前に「何が動くか」を確認したい場合、if file_mode == "移動する": の分岐に elif file_mode == "プレビュー": を追加し、shutil.move / shutil.copy2 の代わりに print(f"[DRY] {src_path} → {dest_path}") だけを実行するようにします。フィールドの options にも "プレビュー" を加えればUIから選べます。

更新日時で年月別に分ける運用

拡張子ではなく更新日時で 2026/04/, 2026/03/ のように仕分けたい場合は、get_category を捨てて dest_dir = os.path.join(target_dir, datetime.fromtimestamp(os.path.getmtime(src_path)).strftime("%Y/%m")) に置き換えます。写真フォルダの整理に向いた変形です。

よくあるエラーと対処

PermissionError: WinError 32 このファイルはほかのプロセスが使用中

Excel・PDFビューア・画像ビューアで対象ファイルを開いたままだと shutil.move が失敗します。エクスプローラのプレビュー窓も握る側になるので閉じてから再実行してください。途中で止まった場合、エラー以前のファイルは既に整理済みなので、サマリーTXT(直近のファイル)と現在のフォルダを見比べると差分が分かります。

FileNotFoundError: WinError 2 指定されたファイルが見つかりません

対象フォルダ のパスが間違っているか、Windowsのパス区切り \\ をエスケープし忘れているケースです。Pybesのフォルダ選択ダイアログから選び直すと確実。ネットワークドライブの場合は実行前に接続を確認してください。

整理したくないファイル(実行ファイル等)まで動かされた

現状の CATEGORY_MAP は対応外の拡張子をすべて その他 に集めます。除外したい拡張子がある場合は get_category の冒頭で if ext in {".exe", ".lnk"}: return None のように None を返し、メインループ側で if category is None: continue でスキップするように変更してください。

同じ実行で同名ファイルが複数あるとどうなる?

タイムスタンプは秒単位なので、同一秒内に複数の同名ファイルが衝突すると2つ目以降が上書きされる可能性があります。実用上ほぼ起きませんが、心配な場合は resolve_destts_{i} のような連番を足してください。

FAQ

サブフォルダ走査時、元の階層は保たれますか?

いいえ。サブフォルダも含める = true の場合、サブフォルダ内のファイル**だけ**が拾われ、対象フォルダ直下のカテゴリフォルダにすべてフラットに集約されます。元のサブフォルダ階層は維持されません。階層を保ったままカテゴリ分けしたい場合は、dest_dir = os.path.join(target_dir, category, os.path.relpath(os.path.dirname(src_path), target_dir)) のように相対パスを連結してください。

なぜトップレベルだけ SKIP_DIRS を適用しているのですか?

整理先フォルダ(画像/, PDF/ など)と サマリー/ が常に対象フォルダ直下に作られるためです。深い階層に「画像」という名前の偶然同名なフォルダがあっても、そちらは普通のサブフォルダとして走査対象になります(中身のファイルも仕分けられます)。

Excelカテゴリに .csv が入っているのは正しい?

意図的にExcel側に寄せています。実務上CSVはExcelで開いて編集することが多く、テキストカテゴリよりExcelと同じ場所にあったほうが探しやすいためです。テキスト扱いに移したい場合は CATEGORY_MAP["Excel"] から ".csv" を抜いて "テキスト" 側に追加してください。

コピーモードで実行した後、同じフォルダで再実行するとどうなりますか?

元ファイルが残ったままなので、カテゴリフォルダにもう1セット作られ、衝突分はタイムスタンプ付きの別名で保存されます。同じファイルを何度もコピーする「鏡像状態」を避けたい場合は、初回実行を移動モードにするか、コピー後に元ファイルを手動で削除してください。

他のよくある質問を見る →
フォルダ内のファイル整理(フォルダ仕分け).pybes をダウンロード

.pybes ファイルをPybesにインポートすると、スクリプトと設定フィールドが自動で読み込まれます。