Scripts y automatización de archivos

Encontrar archivos duplicados por tamaño y SHA-256 antes de eliminar nada

Filtra los archivos por tamaño, compara los digests SHA-256 y escribe un CSV solo para revisión sin modificar los archivos de origen. Utiliza un ejemplo sintético de siete archivos para comprobar qué rutas deben aparecer en el informe y cuáles no.

Ver el índice

La traducción se ha realizado con IA. Comprueba el código, las unidades y los valores junto con el original. La revisión por hablantes nativos de cada idioma aún no se ha completado. English

Para quién esEsta guía está dirigida a personas que quieren identificar archivos potencialmente redundantes manteniendo las decisiones de eliminación separadas del escaneo.

Preparación
  • Python 3.12 y un comando de terminal que inicie esa versión.
  • Una carpeta de trabajo donde puedas crear una carpeta outputs.
  • Permiso de lectura para los archivos escaneados, sin que otro proceso los modifique durante el escaneo.
  • Solo se requiere la biblioteca estándar de Python: csv, hashlib, os, pathlib y stat.

01Definir qué significa el informe

Este workflow agrupa primero los files por byte size. Solo los size groups que contienen varias paths necesitan hashing: files de tamaños distintos no pueden contener exactamente los mismos bytes. Dentro de cada size group, los SHA-256 digests coincidentes identifican duplicate candidates. Que los names coincidan no es necesario ni suficiente.

Un candidate group no es una instrucción de eliminación. Un contenido idéntico puede cumplir propósitos diferentes en carpetas distintas, y un digest no es una prueba absoluta de igualdad. El report registra cada matching path sin elegir automáticamente qué file conservar.

02Crear siete archivos sintéticos

El dataset siguiente es sintético y fue creado para este artículo. Guarda el código como create_duplicate_demo.py y ejecútalo desde tu working folder. Las binary writes hacen que los contents sean exactos: no se añade newline y los empty files contienen 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
Ruta relativaContenido de texto exactoBytes
archive/a_saved.txtabc3
empty_a.txtEmpty0
empty_b.txtEmpty0
notes/a.txtabc3
notes/a_copy.txtabc3
notes/other.txtxyz3
unique.txtsolo4

Los siete files contienen 16 bytes en total. Cuatro tienen size 3, pero notes/other.txt contiene bytes diferentes de los tres files abc. Esto comprueba deliberadamente por qué el mismo tamaño por sí solo es insuficiente.

03Mantener el informe fuera de la carpeta escaneada

  1. Guarda el siguiente script como find_duplicate_files.py junto al setup script.
  2. Mantén SOURCE como outputs/duplicate_demo para este ejemplo.
  3. Mantén outputs/duplicate_review separado del source. Esa destination todavía no debe existir.
  4. Ejecuta el script desde la misma working folder usando el comando siguiente.
text
python find_duplicate_files.py

El script rechaza una report destination situada dentro de la resolved source folder. Esto evita que el scan incluya su propio output. Las relative paths siguen dependiendo del working directory de la terminal.

04Escanear archivos y escribir el CSV de revisión

El scanner omite symbolic links encontrados durante el traversal e ignora non-regular file entries. Los traversal y read errors detienen la ejecución. Las metadata comparisons alrededor del hashing detectan algunos concurrent changes, pero no crean un 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()

05Comparar los grupos esperados

El informe tiene cinco filas de datos más el encabezado. Los grupos se ordenan por tamaño y hash, y las rutas de cada grupo están ordenadas. La tabla omite la columna SHA-256 para facilitar la lectura; el CSV generado la incluye y sus valores se comprobaron durante la ejecución.

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

Se calculan hashes de seis archivos: los dos vacíos y los cuatro de tres bytes. El archivo de cuatro bytes se omite porque no tiene otro del mismo tamaño. La ejecución coincidió con la salida de consola esperada.

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

06Revisar el significado, no solo los bytes coincidentes

  1. Comprueba que cada reported path siga existiendo y que el source no haya cambiado desde el scanning.
  2. Compara los candidate files byte for byte antes de tomar una destructive decision. Confirma que exista un backup independiente y utilizable.
  3. Comprueba folder purpose, ownership, references de otros files y application requirements. Los contents idénticos no establecen interchangeability.
  4. Registra una proposed action en una review copy separada. No interpretes la primera path de un group como la copia automáticamente preferida.

Los empty files pueden ser placeholders intencionados. Que su contenido coincida no significa que sean innecesarios, y eliminarlos no reduciría ningún file-content byte.

07Comprobar personalmente las protecciones

  • Confirma que aparezcan los dos empty files y los tres files abc, mientras notes/other.txt y unique.txt no aparecen.
  • Comprueba que cada group tenga un único shared size y un único shared 64-character SHA-256 hexadecimal value.
  • Vuelve a ejecutar sin cambiar OUTPUT_DIR. FileExistsError debería detener la ejecución antes de escribir otro report.
  • Prueba una source separada que contenga únicamente unique sizes. Espera un header-only report y zero hashed files.

Estas son reader checks, no tests ejecutados para este artículo. Un completed report con zero groups es diferente de un scan que se detuvo con una exception.

08Reconocer errores comunes

SíntomaQué comprobar
FileNotFoundErrorEjecuta primero el setup y comprueba SOURCE frente al working directory de la terminal.
FileExistsErrorRevisa la destination anterior y elige una carpeta nueva dentro de outputs. También se rechazan las existing empty destinations.
PermissionError or another read errorResuelve los access problems o reduce deliberadamente el source. No consideres completo un interrupted scan.
File changed before or during hashingDetén las applications que modifican el source y vuelve a escanear usando una nueva output destination.
Unexpected byte sizesUn text editor puede haber añadido un newline o cambiado el encoding. Recrea el ejemplo con el binary-writing setup script.
Empty folder or partial report after failureUn interrupted run no revierte su destination. Mantén ese result separado de los completed reports.

09Comprender los límites

El hashing lee el content en chunks, pero el script almacena file metadata, paths y groups en memoria. No es un constant-memory inventory. Compara bytes completos, no visual similarity ni document meaning; documentos que parecen iguales pueden tener digests diferentes.

El report cuenta paths, no independent physical copies. Los hard links pueden producir varias reported paths sin almacenamiento de contenido separado, por lo que los reported sizes no garantizan disk savings. Windows junctions, mount points y hostile concurrent changes requieren tratamiento adicional más allá de los symbolic-link checks.

Registro de ejecución y verificación

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

  • La preparación creó exactamente 7 archivos de origen normales con un total de 16 bytes: dos archivos de tamaño 0, cuatro de tamaño 3 y uno de tamaño 4.
  • La ejecución principal imprimió exactamente: 'Scanned 7 regular file paths; hashed 6.', 'Duplicate candidates: 2 groups, 5 paths.' y 'Review CSV: outputs/duplicate_review/duplicates.csv'.
  • duplicates.csv contenía exactamente 5 filas de datos además de su encabezado.
  • G001 contenía empty_a.txt y empty_b.txt, ambos de tamaño 0 y con SHA-256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • G002 contenía archive/a_saved.txt, notes/a.txt y notes/a_copy.txt, todos de tamaño 3 y con SHA-256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.
  • notes/other.txt y unique.txt no aparecieron correctamente en el informe de duplicados.
  • Se calcularon hashes para seis archivos: los dos archivos de cero bytes y los cuatro archivos de tres bytes; unique.txt se omitió porque no tenía otro archivo del mismo tamaño.
  • El contenido y los hashes de los archivos de origen eran idénticos antes y después del análisis.
  • Una segunda ejecución se detuvo con FileExistsError porque outputs/duplicate_review ya existía.
  • Una fuente con archivos de 1, 2 y 3 bytes produjo 'hashed 0', cero grupos duplicados y un informe CSV que contenía solo el encabezado.
  • Colocar OUTPUT_DIR dentro de SOURCE produjo ValueError: 'The report folder must be outside SOURCE.'
  • Al añadir un archivo de enlace simbólico y un directorio de enlace simbólico al origen, el escáner siguió informando solo 7 rutas de archivos normales y los mismos 2 grupos duplicados.
Límites de la verificación
  • No se probaron fallos de permisos, mutaciones simultáneas de archivos durante el cálculo de hashes, contabilización de enlaces duros, junctions o mount points de Windows, escrituras interrumpidas, condiciones de carrera hostiles del sistema de archivos ni rendimiento con directorios grandes.

Fuentes de referencia

Las explicaciones y los ejemplos son de elaboración propia. Puedes consultar los comportamientos y conceptos relacionados en las siguientes fuentes oficiales.