Scripts e automação de arquivos

Encontre arquivos duplicados por tamanho e SHA-256 antes de excluir qualquer coisa

Filtre arquivos por tamanho, compare digests SHA-256 e grave um CSV apenas para revisão sem alterar os arquivos de origem. Use um exemplo sintético com sete arquivos para verificar quais paths pertencem ao relatório e quais não.

Ver o sumário

A tradução foi feita com IA. Confira o código, as unidades e os valores junto com o original. A revisão por falantes nativos de cada idioma ainda não foi concluída. English

Para quem éEste guia é voltado a pessoas que querem identificar arquivos potencialmente redundantes mantendo as decisões de exclusão separadas do scan.

Preparação
  • Python 3.12 e um comando de terminal que inicie essa versão.
  • Uma pasta de trabalho em que você possa criar uma pasta outputs.
  • Permissão de leitura para os arquivos escaneados, sem outro processo alterá-los durante o scan.
  • É necessária apenas a biblioteca padrão do Python: csv, hashlib, os, pathlib e stat.

01Defina o que o relatório significa

Este workflow agrupa primeiro os files por byte size. Apenas size groups com vários paths precisam de hashing: files com tamanhos diferentes não podem conter exatamente os mesmos bytes. Dentro de cada size group, SHA-256 digests correspondentes identificam duplicate candidates. Matching names não são necessários nem suficientes.

Um candidate group não é uma instrução de exclusão. Conteúdo idêntico pode ter propósitos diferentes em folders diferentes, e um digest não é uma prova absoluta de igualdade. O report registra todos os matching paths sem escolher automaticamente um file para manter.

02Crie sete arquivos sintéticos

O dataset a seguir é sintético e foi criado para este artigo. Salve o código como create_duplicate_demo.py e execute-o a partir da sua working folder. Binary writes tornam o conteúdo exato: nenhum newline é adicionado, e os empty files contêm zero 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
Caminho relativoConteúdo de texto exatoBytes
archive/a_saved.txtabc3
empty_a.txtEmpty0
empty_b.txtEmpty0
notes/a.txtabc3
notes/a_copy.txtabc3
notes/other.txtxyz3
unique.txtsolo4

Os sete files contêm 16 bytes no total. Quatro têm size 3, mas notes/other.txt contém bytes diferentes dos três files abc. Isso testa deliberadamente por que tamanho igual, sozinho, é insuficiente.

03Mantenha o relatório fora da pasta escaneada

  1. Salve o próximo script como find_duplicate_files.py ao lado do setup script.
  2. Mantenha SOURCE como outputs/duplicate_demo neste exemplo.
  3. Mantenha outputs/duplicate_review separado do source. Essa destination ainda não deve existir.
  4. Execute o script a partir da mesma working folder usando o comando abaixo.
text
python find_duplicate_files.py

O script rejeita uma report destination dentro da resolved source folder. Isso impede que o scan inclua seu próprio output. Relative paths ainda dependem do working directory do terminal.

04Escaneie arquivos e grave o CSV de revisão

O scanner ignora symbolic links encontrados durante o traversal e ignora non-regular file entries. Traversal e read errors fazem a execução parar. Metadata comparisons em torno do hashing detectam algumas concurrent changes, mas não criam um 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()

05Compare os grupos esperados

O relatório tem cinco linhas de dados e o cabeçalho. Os grupos são ordenados por tamanho e hash, com os caminhos de cada grupo ordenados. A tabela omite a coluna SHA-256 para facilitar a leitura; o CSV gerado a inclui e seus valores foram conferidos na execução.

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

São calculados hashes de seis arquivos: os dois vazios e os quatro de três bytes. O arquivo de quatro bytes é ignorado porque não há outro do mesmo tamanho. A execução correspondeu à saída esperada no console.

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

06Revise o significado, não apenas bytes correspondentes

  1. Verifique se cada reported path ainda existe e se o source não mudou desde o scanning.
  2. Compare os candidate files byte for byte antes de uma destructive decision. Confirme que existe um backup separado e utilizável.
  3. Verifique folder purpose, ownership, references de outros files e application requirements. Conteúdo idêntico não estabelece interchangeability.
  4. Registre uma proposed action em uma review copy separada. Não interprete o primeiro path de um group como a cópia automaticamente preferida.

Empty files podem ser placeholders intencionais. Conteúdo correspondente não os torna desnecessários, e removê-los não eliminaria nenhum file-content byte.

07Verifique você mesmo as proteções

  • Confirme que ambos os empty files e todos os três files abc aparecem, enquanto notes/other.txt e unique.txt não aparecem.
  • Verifique se cada group tem um shared size e um shared 64-character SHA-256 hexadecimal value.
  • Execute novamente sem alterar OUTPUT_DIR. FileExistsError deve parar a execução antes que outro report seja gravado.
  • Experimente uma source separada contendo apenas unique sizes. Espere um header-only report e zero hashed files.

Estas são reader checks, não tests executados para este artigo. Um completed report com zero groups é diferente de um scan que parou com uma exception.

08Reconheça erros comuns

SintomaO que verificar
FileNotFoundErrorExecute primeiro o setup e verifique SOURCE em relação ao working directory do terminal.
FileExistsErrorRevise a destination anterior e escolha uma nova folder dentro de outputs. Existing empty destinations também são recusadas.
PermissionError or another read errorResolva access problems ou reduza deliberadamente o source. Não trate um interrupted scan como completo.
File changed before or during hashingPare as applications que modificam o source e faça o scan novamente usando uma nova output destination.
Unexpected byte sizesUm text editor pode ter adicionado newline ou alterado o encoding. Recrie o exemplo com o binary-writing setup script.
Empty folder or partial report after failureUm interrupted run não reverte sua destination. Mantenha esse result separado de completed reports.

09Entenda os limites

Hashing lê o content em chunks, mas o script armazena file metadata, paths e groups na memória. Isto não é um constant-memory inventory. Ele compara bytes completos, não visual similarity ou document meaning; documentos visualmente iguais podem ter digests diferentes.

O report conta paths, não independent physical copies. Hard links podem gerar vários reported paths sem armazenamento de conteúdo separado, portanto reported sizes não garantem disk savings. Windows junctions, mount points e hostile concurrent changes exigem tratamento adicional além dos symbolic-link checks.

Registro de execução e verificação

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

  • A preparação criou exatamente 7 arquivos de origem regulares, totalizando 16 bytes: dois arquivos de tamanho 0, quatro de tamanho 3 e um de tamanho 4.
  • A execução principal imprimiu exatamente: 'Scanned 7 regular file paths; hashed 6.', 'Duplicate candidates: 2 groups, 5 paths.' e 'Review CSV: outputs/duplicate_review/duplicates.csv'.
  • duplicates.csv continha exatamente 5 linhas de dados além do cabeçalho.
  • G001 continha empty_a.txt e empty_b.txt, ambos com tamanho 0 e SHA-256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • G002 continha archive/a_saved.txt, notes/a.txt e notes/a_copy.txt, todos com tamanho 3 e SHA-256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.
  • notes/other.txt e unique.txt ficaram corretamente ausentes do relatório de duplicados.
  • Seis arquivos tiveram seus hashes calculados: os dois arquivos de zero bytes e todos os quatro arquivos de três bytes; unique.txt foi ignorado porque não havia outro arquivo com o mesmo tamanho.
  • O conteúdo e os hashes dos arquivos de origem eram idênticos antes e depois da varredura.
  • Uma segunda execução foi interrompida com FileExistsError porque outputs/duplicate_review já existia.
  • Uma origem com arquivos de tamanhos 1, 2 e 3 bytes produziu 'hashed 0', zero grupos duplicados e um relatório CSV contendo apenas o cabeçalho.
  • Colocar OUTPUT_DIR abaixo de SOURCE gerou ValueError: 'The report folder must be outside SOURCE.'
  • Com um arquivo de link simbólico e um diretório de link simbólico adicionados à origem, o scanner continuou relatando apenas 7 caminhos de arquivos regulares e os mesmos 2 grupos duplicados.
Limites da verificação
  • Não foram testadas falhas de permissão, mutação simultânea de arquivos durante o cálculo de hashes, contabilização de hard links, junctions ou mount points do Windows, gravações interrompidas, condições de corrida hostis do sistema de arquivos nem desempenho em diretórios grandes.

Fontes de referência

As explicações e os exemplos são de elaboração própria. Os comportamentos e conceitos relacionados podem ser consultados nas fontes oficiais abaixo.