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

削除する前にサイズと 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 と比較する

レポートはヘッダーと5行のデータで構成されます。グループはサイズとハッシュ順、各グループ内のパスも並べ替えられています。読みやすさのため下表ではSHA-256列を省略していますが、生成されたCSVには含まれており、実行時に値を確認しました。

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

ハッシュ計算の対象は空のファイル2個と3バイトのファイル4個、合計6個です。4バイトのファイルには同じサイズの相手がないためスキップされます。実際のコンソール出力は想定と一致しました。

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 を超える追加処理が必要です。

実行・検証の記録

ChatGPT Python tool · Python 3.13.5 · standard library csv/hashlib/os/pathlib/stat

  • セットアップでは通常ファイルが正確に7個作成され、合計サイズは16バイトだった。内訳はサイズ0のファイルが2個、サイズ3のファイルが4個、サイズ4のファイルが1個だった。
  • メイン実行では正確に 'Scanned 7 regular file paths; hashed 6.', 'Duplicate candidates: 2 groups, 5 paths.', 'Review CSV: outputs/duplicate_review/duplicates.csv' が出力された。
  • duplicates.csv にはヘッダーに加えて正確に5行のデータが含まれていた。
  • G001 には empty_a.txt と empty_b.txt が含まれ、どちらもサイズ0で、SHA-256 は e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 だった。
  • G002 には archive/a_saved.txt、notes/a.txt、notes/a_copy.txt が含まれ、すべてサイズ3で、SHA-256 は ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad だった。
  • notes/other.txt と unique.txt は重複レポートに正しく含まれていなかった。
  • ハッシュ計算されたファイルは6個だった。サイズ0の2ファイルとサイズ3の4ファイルすべてが対象となり、unique.txt は同じサイズの相手がなかったためスキップされた。
  • スキャン前後で元ファイルの内容とハッシュは同一だった。
  • outputs/duplicate_review がすでに存在していたため、2回目の実行は FileExistsError で停止した。
  • ファイルサイズが1、2、3バイトの元データでは 'hashed 0'、重複グループ0件、ヘッダーのみのCSVレポートが生成された。
  • OUTPUT_DIR を SOURCE の配下に配置すると ValueError: 'The report folder must be outside SOURCE.' が発生した。
  • 元データにシンボリックリンクファイルとシンボリックリンクディレクトリを追加しても、スキャナーは通常ファイルパスを7件だけ報告し、同じ2つの重複グループを検出した。
検証範囲の限界
  • 権限エラー、ハッシュ計算中のファイル変更、ハードリンクの扱い、Windows の junction や mount point、中断された書き込み、悪意あるファイルシステム競合、大規模ディレクトリでの性能はテストしていない。

参考資料

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