スクリプト・ファイル自動化

削除する前にサイズと SHA-256 で重複ファイルを探す

まずファイルサイズで絞り込み、その後 SHA-256 digest を比較し、元ファイルを変更せずに確認専用の CSV を作成します。7ファイルの合成例を使い、どの path が report に含まれ、どれが含まれないかを確認します。

目次を表示

この翻訳はAIで作成しました。コード、単位、数値は原文と併せて確認してください。各言語のネイティブ話者による校閲は、まだ完了していません。 English

対象読者削除判断と scan を分離したまま、重複の可能性があるファイルを特定したい人向けのガイドです。

準備するもの
  • Python 3.12 と、そのバージョンを起動できるターミナルコマンド。
  • outputs フォルダを作成できる作業フォルダ。
  • scan 対象 files の read permission があり、scan 中に他の process が変更しないこと。
  • 必要なのは Python 標準ライブラリのみです: csv, hashlib, os, pathlib, stat.

01report が意味するものを定義する

この workflow では、まず files を byte size で group 化します。複数 path を含む size group だけ hash 計算が必要です。異なる sizes の files が完全に同じ bytes を持つことはありません。同じ size group 内で SHA-256 digests が一致した files を duplicate candidates とします。names の一致は必要条件でも十分条件でもありません。

candidate group は削除指示ではありません。同一 content が異なる folders で別の目的を持つ場合があり、digest も絶対的な equality proof ではありません。report は自動的に keep file を選ばず、一致したすべての paths を記録します。

027つの合成ファイルを作成する

以下の dataset は合成データであり、この記事のために作成したものです。コードを create_duplicate_demo.py として保存し、作業フォルダから実行してください。binary writes を使うことで contents を正確にします。newline は追加されず、empty files は 0 bytes です。

python
from pathlib import Path

DEMO = Path("outputs") / "duplicate_demo"
FILES = {
    "archive/a_saved.txt": b"abc",
    "empty_a.txt": b"",
    "empty_b.txt": b"",
    "notes/a.txt": b"abc",
    "notes/a_copy.txt": b"abc",
    "notes/other.txt": b"xyz",
    "unique.txt": b"solo",
}

DEMO.parent.mkdir(parents=True, exist_ok=True)
DEMO.mkdir()  # Refuse to reuse an existing destination.
for relative_path, content in FILES.items():
    target = DEMO / relative_path
    target.parent.mkdir(parents=True, exist_ok=True)
    with target.open("xb") as stream:
        stream.write(content)
text
python create_duplicate_demo.py
相対 path正確な text contentBytes
archive/a_saved.txtabc3
empty_a.txtEmpty0
empty_b.txtEmpty0
notes/a.txtabc3
notes/a_copy.txtabc3
notes/other.txtxyz3
unique.txtsolo4

7つの files の合計は 16 bytes です。4つは size 3 ですが、notes/other.txt は3つの abc files とは異なる bytes を持ちます。これは equal size だけでは不十分であることを意図的に確認するための構成です。

03report を scan 対象フォルダの外に置く

  1. 次の script を find_duplicate_files.py として setup script と同じ場所に保存します。
  2. この例では SOURCE を outputs/duplicate_demo のままにします。
  3. outputs/duplicate_review は source と分離しておきます。この destination はまだ存在していてはいけません。
  4. 同じ working folder から以下の command で script を実行します。
text
python find_duplicate_files.py

script は resolved source folder 内に report destination がある場合、それを拒否します。これにより scan が自身の output を含めることを防ぎます。relative paths は引き続き terminal の working directory に依存します。

04ファイルを scan して review CSV を作成する

scanner は traversal 中に見つかった symbolic links を skip し、non-regular file entries を無視します。traversal または read errors が発生すると実行を停止します。hashing 前後の metadata comparison により一部の concurrent changes を検出できますが、locked snapshot を作成するものではありません。

python
import csv
import hashlib
import os
import stat
from pathlib import Path

SOURCE = Path("outputs") / "duplicate_demo"
OUTPUT_DIR = Path("outputs") / "duplicate_review"
CHUNK_BYTES = 1024 * 1024


def signature(info: os.stat_result) -> tuple[int, ...]:
    return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)


def raise_walk_error(error: OSError) -> None:
    raise error


def file_digest(path: Path, expected: os.stat_result) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        before = os.fstat(stream.fileno())
        if not stat.S_ISREG(before.st_mode) or signature(before) != signature(expected):
            raise RuntimeError(f"File changed before hashing: {path}")
        while chunk := stream.read(CHUNK_BYTES):
            digest.update(chunk)
        after = os.fstat(stream.fileno())
    if signature(after) != signature(expected) or signature(path.lstat()) != signature(expected):
        raise RuntimeError(f"File changed during hashing: {path}")
    return digest.hexdigest()


def main() -> None:
    root = SOURCE.resolve(strict=True)
    if not root.is_dir():
        raise ValueError("SOURCE must be a directory.")
    if OUTPUT_DIR.resolve().is_relative_to(root):
        raise ValueError("The report folder must be outside SOURCE.")

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Stop if the destination already exists.

    by_size = {}
    scanned = 0
    for directory, directories, filenames in os.walk(
        root, followlinks=False, onerror=raise_walk_error
    ):
        base = Path(directory)
        directories[:] = sorted(
            name for name in directories if not (base / name).is_symlink()
        )
        for name in sorted(filenames):
            path = base / name
            info = path.lstat()
            if not stat.S_ISREG(info.st_mode):
                continue
            by_size.setdefault(info.st_size, []).append((path, info))
            scanned += 1

    groups = []
    hashed = 0
    for size, members in sorted(by_size.items()):
        if len(members) < 2:
            continue
        by_hash = {}
        for path, initial in members:
            digest = file_digest(path, initial)
            by_hash.setdefault(digest, []).append(path)
            hashed += 1
        for digest, paths in sorted(by_hash.items()):
            if len(paths) > 1:
                ordered = sorted(paths, key=lambda path: path.as_posix())
                groups.append((size, digest, ordered))

    report = OUTPUT_DIR / "duplicates.csv"
    with report.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.writer(stream)
        writer.writerow([
            "group_id", "size_bytes", "sha256", "relative_path", "review_status"
        ])
        for number, (size, digest, paths) in enumerate(groups, start=1):
            for path in paths:
                writer.writerow([
                    f"G{number:03d}", size, digest,
                    path.relative_to(root).as_posix(), "UNREVIEWED"
                ])

    matched = sum(len(paths) for _, _, paths in groups)
    print(f"Scanned {scanned} regular file paths; hashed {hashed}.")
    print(f"Duplicate candidates: {len(groups)} groups, {matched} paths.")
    print(f"Review CSV: {report.as_posix()}")


if __name__ == "__main__":
    main()

05想定される groups と比較する

想定 report には header に加えて5つの data rows があります。groups は size、その後 digest の順に並び、各 group 内の paths は sort されます。以下の表では sha256 column を省略しています。ここでは digests を計算していないためです。script を実行するとその column が入力されます。

group_idsize_bytesrelative_pathreview_status
G0010empty_a.txtUNREVIEWED
G0010empty_b.txtUNREVIEWED
G0023archive/a_saved.txtUNREVIEWED
G0023notes/a.txtUNREVIEWED
G0023notes/a_copy.txtUNREVIEWED

hashing が必要な files は6個です。2つの empty files と4つの three-byte files です。four-byte file には同じ size の peer がないため、hashing stage では skip されます。想定 console text は手作業で導出したもので、execution log ではありません。

text
Scanned 7 regular file paths; hashed 6.
Duplicate candidates: 2 groups, 5 paths.
Review CSV: outputs/duplicate_review/duplicates.csv

06matching bytes だけでなく意味も確認する

  1. report に含まれる各 path がまだ存在し、scan 後に source が変更されていないことを確認します。
  2. destructive decision を行う前に candidate files を byte for byte で比較します。別に使用可能な backup が存在することも確認します。
  3. folder purpose, ownership, 他 files からの references, application requirements を確認します。同じ contents だからといって interchangeability が保証されるわけではありません。
  4. 提案する action は別の review copy に記録します。group の最初の path を自動的に preferred copy と解釈しないでください。

empty files は意図的な placeholders の場合があります。contents が一致しても不要とは限らず、それらを削除しても file-content bytes は減りません。

07safeguards を自分で確認する

  • 2つの empty files と3つすべての abc files が表示され、notes/other.txt と unique.txt が表示されないことを確認します。
  • 各 group が1つの shared size と1つの shared 64-character SHA-256 hexadecimal value を持つことを確認します。
  • OUTPUT_DIR を変更せずに再実行します。新しい report が書かれる前に FileExistsError で停止するはずです。
  • unique sizes だけを含む別の source を試します。header-only report と zero hashed files になることを確認します。

これらは reader checks であり、この記事のために実行された tests ではありません。zero groups の completed report と、exception で停止した scan は別です。

08よくあるエラーを確認する

症状確認すること
FileNotFoundError先に setup を実行し、SOURCE と terminal の working directory を確認します。
FileExistsError以前の destination を確認し、outputs の下に新しい folder を選択します。existing empty destinations も拒否されます。
PermissionError or another read erroraccess problems を解決するか、source を意図的に狭めます。interrupted scan を complete として扱わないでください。
File changed before or during hashingsource を変更する applications を停止し、新しい output destination で再 scan します。
Unexpected byte sizestext editor が newline を追加したか encoding を変更した可能性があります。binary-writing setup script で例を作り直します。
Empty folder or partial report after failureinterrupted run は destination を rollback しません。その result を completed reports と分けて保持します。

09制限を理解する

hashing は content を chunks で読み取りますが、script は file metadata, paths, groups を memory に保持します。これは constant-memory inventory ではありません。比較するのは完全な bytes であり、visual similarity や document meaning ではありません。見た目が同じ documents でも digests が異なる場合があります。

report は independent physical copies ではなく paths を数えます。hard links は separate content storage を持たなくても複数の reported paths を生成する場合があるため、reported sizes がそのまま disk savings になるとは限りません。Windows junctions, mount points, hostile concurrent changes には symbolic-link checks を超える追加処理が必要です。

実行・検証の記録

2026-09-20 · 手作業で確認した例 · 対象: Python 3.12 · 標準ライブラリ: csv, hashlib, os, pathlib, stat · 未実行

  • 7 files を手作業で数えました: size 0 が2つ、size 3 が4つ、size 4 が1つ、合計 16 bytes。
  • 合成 byte strings を確認し、同一 content groups として2つの empty files と3つの abc files を特定しました。
  • size filter を追跡し、hashing candidates が6 files、想定 review report が2 groups・5 paths になることを確認しました。
  • exclusive destination creation, report placement, traversal error handling, deletion operations がないことを確認しました。
  • 想定 console text と report row order を手作業で導出しました。
検証範囲の限界
  • この回答の作成者はコードを実行しておらず、files や reports は作成していません。
  • SHA-256 digests は計算または確認していません。想定 content groups は合成 inputs を目視して判断しました。
  • output collisions, permission failures, concurrent changes, links, interrupted writes, large directories はテストしていません。
  • reference pages はライブで開いたり確認したりしていません。

サイト全体の執筆・検証方針

参考資料

説明と例は独自に作成しました。関連する動作や概念は、以下の公式資料で確認できます。