Mostrando entradas con la etiqueta Automatización de procesos. Mostrar todas las entradas
Mostrando entradas con la etiqueta Automatización de procesos. Mostrar todas las entradas

domingo, 19 de abril de 2026

DATOS. Archivos PDF

Bibloteca PyMuPDF (V)

Obtención y generación de imágenes

Dado que los documentos .pdf, además de texto y tablas, también pueden contener gráficos y que PyMuPDF es una herramienta que permite trabajar con este tipo de archivos de forma integral, parece conveniente terminar esta serie de entradas tratando sobre el trabajo de PyMuPDF con imágenes.

En primer lugar vamos a extraer las imágenes de un archivo .pdf para comporbar empíricamente que esto es posible y aprender cómo hacerlo. El siguiente script es un ejemplo de ello.



# 0. Bibliotecas necesarias

import fitz  # PyMuPDF
import io
from PIL import Image # Librería opcional para procesar el formato si es necesario

# 1. Acceder al objeto documento
doc = fitz.open("mi_documento.pdf")		# ruta relativa/nombre del documento .pdf sobre el que se trabaja

total_imagenes = 0  					# Iniciar el contador de img del doc

print(f"Analizando documento: {doc.name}\n")

# 2. Recorrer cada página del doc
for num_pagina, pagina in enumerate(doc):
    lista_imagenes = pagina.get_images(full=True)    # Obtener lista de imágenes de la página actual
    
    if lista_imagenes:
        cantidad_en_pagina = len(lista_imagenes)
        print(f"Página {num_pagina + 1}: se encontraron {cantidad_en_pagina} imágenes.")
        total_imagenes += cantidad_en_pagina
        
# 3. Procesar cada imagen encontrada
        for indice_img, img in enumerate(lista_imagenes):
            xref = img[0]                             # XREF es el identificador único del objeto
            base_image = doc.extract_image(xref)
            imagen_bytes = base_image["image"]
            extension = base_image["ext"] 			  # png, jpeg, etc.
            
# 4. Guardar la imagen en el disco
            nombre_archivo = f"imagen_p{num_pagina+1}_{indice_img+1}.{extension}"
            with open(nombre_archivo, "wb") as f:
                f.write(imagen_bytes)
    else:
        print(f"Página {num_pagina + 1}: no contiene imágenes.")

# 5. Informe final
print("-" * 30)
if total_imagenes > 0:
    print(f"EXTRACCIÓN COMPLETADA: Se han guardado {total_imagenes} imágenes.")
else:
    print("INFORME: El documento no contiene imágenes integradas.")

doc.close()


Este script realiza dos funciones: extrae las imágenes que obtiene del documento y realiza el recuento parcial (por página) y total (todo el documento) de ellas. Las imágenes las copia como archivos .jpg en el directorio del script, y la información la imprime directamente en pantalla (cmd o shell). Por razones que no están del todo claras, pero que se relacionan con la forma en que fue creado el .pdf, este script extrae una imagen por cada página del documento aunque visualmente sólo existe una sobre (en la primera página). Dado que no es una cuestión que ahora resulta de interés y que podría implicar deternerse demasiado en cuestiones que actualmente son secundarias, he preferido no ahondar en cómo resolver este problema.

Tengo más interés en tratar una segunda cuestión relacionada con las imágenes y el uso de PyMuPDF, como es la funcionlidad que esta biblioteca nos ofrece de crear una imagen de cada una de las páginas del documento, cuestión esta que da respuesta a lo que dejé pendiente en esta entrada en que la que se abordó el acceso de datos de los archivos .pdf.



# 0. Importar biblitecas necesarias

import fitz  # PyMuPDF

# 1. Abrir el documento

pdf_path = "mi_documento.pdf"	#Intoduce aquí el nombre o la ruta relativa de tu archivo .pdf
doc = fitz.open(pdf_path)
total_paginas = len(doc)

print(f"Documento cargado: {pdf_path}")
print(f"El archivo tiene {total_paginas} páginas (del 1 al {total_paginas}).")

# 2. Bucle de solicitud de página a imagen

while True:
    print("\n" + "-"*40)
    entrada = input("Indica el número de página a extraer (o escribe 'salir' para finalizar): ").lower()

    if entrada == "salir":
        break

    if not entrada.isdigit():       # Validar que la entrada sea un número
        print("Error: Por favor, introduce un número válido.")
        continue
    num_pag = int(entrada)

# 3. Validar rango de páginas (ajustando a índice 0)

    if 1 <= num_pag <= total_paginas:
        pagina = doc[num_pag - 1]
        
# 4. Mejorar la resolución (Zoom de 2x)
    
        matriz = fitz.Matrix(2, 2)          # Por defecto se renderiza a 72 DPI. Multiplicamos por 2 para mayor nitidez.
        pix = pagina.get_pixmap(matrix=matriz)
        
# 5. Guardar la imagen en directorio del script (en formato .png)

        nombre_salida = f"captura_pagina_{num_pag}.png"
        pix.save(nombre_salida)
        
        print(f"¡Éxito! La página {num_pag} ha sido guardada como '{nombre_salida}'.")
    else:
        print(f"Error: La página {num_pag} no existe. El rango es 1-{total_paginas}.")

# 6. Cerrar recursos

doc.close()
print("Script finalizado.")
  

En este caso he desarrollado una especie de utilidad para para facilitar al usuario la conversión a imagen (.npg) de las páginas que desee del documento .pdf sobre el que decida trabajar. Esto supone complicar un poco más el script al introducir el procedimiento cíclico de input, pero da utilidad inmediata al script. Los archivos de imagen generados se guardan directa y automáticamente en la raiz de la ruta en que se encuentra el script.

DATOS. Archivos PDF

Bibloteca PyMuPDF (IV)

Acceso a pdf. Sólo tablas

Esta entrada es la complementaria de la anterior: si en aquella extraíamos sólo los párrafos no-tabla, en esta optamos por extraer sólo el texto de las tablas, primero a un archivo .txt (por mantener la mayor similitud posible con el script anterior), que cómo podrás comprobar tras la lectura del script que sigue, modifica lo que en origen fué un if not... por un if.. tal y como se expresa en el punto 5 del script.



# 0. Importamos bibliotecas

import fitz  # PyMuPDF

# 1. Acceder al objeto documento
doc = fitz.open("plataformas_educativas.pdf")

# Creamos el archivo .txt de salida
with open("solo_tablas_extraidas.txt", "w", encoding="utf-8") as archivo_txt:
    for pag in doc:
        archivo_txt.write(f"--- Tablas de la Página {pag.number + 1} ---\n")
        
# 2. Localizar las áreas de las tablas
        tabs = pag.find_tables()
        tab_rects = [table.bbox for table in tabs]

# 3. Obtener los bloques de texto de toda la página
        blocks = pag.get_text("blocks")

        for b in blocks:
            rect_bloque = fitz.Rect(b[:4])
            
# 4. Verificamos si el bloque está DENTRO de alguna tabla
            dentro_de_tabla = False
            for t_rect in tab_rects:
                if rect_bloque.intersects(t_rect): 
                    dentro_de_tabla = True
                    break
            
# 5. Escribir solo si SÍ está dentro de una tabla
            if dentro_de_tabla and b[6] == 0:
                archivo_txt.write(b[4] + "\n")

        archivo_txt.write("\n" + "="*30 + "\n\n")

doc.close()
print("Proceso finalizado: Se ha extraído únicamente el contenido de las tablas.")


Pero como estamos hablando de una estructura de tabla, lo más apropiado es generar como respuesta un archivo que se ajuste a esa misma estructura, lo que aquí concretamos como archivo .scv en el script que sigue:



# 0. Importar bibliotecas necesarias

import fitz  # PyMuPDF
import csv   # Biblioteca para manejar archivos CSV

# 1. Acceder al objeto documento
doc = fitz.open("plataformas_educativas.pdf")

# 2. Abrir el archivo CSV para escritura
with open("tablas_extraidas.csv", "w", newline="", encoding="utf-8") as archivo_csv:
    escritor = csv.writer(archivo_csv)

    for pag in doc:
# 3. Localizar las tablas en la página actual
        tabs = pag.find_tables()     
        for i, tabla in enumerate(tabs):
# Escribimos una fila vacía o un encabezado para separar tablas
            escritor.writerow([f"--- TABLA {i+1} - PÁGINA {pag.number + 1} ---"])
            
# 4. Extraer el contenido estructurado (lista de listas)
            contenido_tabla = tabla.extract()
           
# 5. Escribir todas las filas de la tabla en el CSV
            for fila in contenido_tabla:
                # Cada 'fila' ya es una lista con el texto de cada celda
                escritor.writerow(fila)
# Añadir una fila vacía para separar de la siguiente tabla
            escritor.writerow([])

doc.close()
print("Proceso finalizado: Las tablas se han guardado con éxito en 'tablas_extraidas.csv'")


El "éxito" de este script es localizar las tablas en el texto, lo cual es posible por su claridad formal; de hecho en una primera versión la tabla no contaba con demilitadores en toda su estructura y el script no fue capaz de identificarla como tabla. Tras una modificación de encuadre, el script funcionó perfectamente, así que aconsejo mejorar la tabla siempre que esto sea posible; en caso contrario habrá que hacer algunos ajustes en el script, lo que no siempre garantiza el éxito.

DATOS. Archivos PDF

Bibloteca PyMuPDF (III)

Acceso a pdf. Sólo textos

Aunque PyMuPDF está pensado para extraer (entre otras cosas) el texto del documento con independencia de en qué presentación se encuentre (como vimos en la entrada anterior), cuando en el documento se diferencian bloques de texto (párrafos) y tablas, es posible que deseemos diferenciar unos textos de otros. En este caso vamos a crear un script que da respuesta a esta necesidad.



#0. Importar la biblioteca

import fitz  # PyMuPDF

# 1 Acceder al objeto documento
doc = fitz.open("plataformas_educativas.pdf")

# Generar documento de salida (.txt)
with open("plataformas_txt2.txt", "w", encoding="utf-8") as archivo_txt:
    for pag in doc:
        archivo_txt.write(f"--- Página {pag.number + 1} ---\n")
        
# 1. Localizar las tablas en la página actual
        tabs = pag.find_tables()                     # Busca las tablas presentes en el texto
        tab_rects = [table.bbox for table in tabs]   # Obtenemos los rectángulos que delimitan las tablas

# 2. Obtener los bloques de texto (con independencia de su ubicación)
        blocks = pag.get_text("blocks")              # Cada 'bloque' devuelve (x0, y0, x1, y1, "texto", block_no, block_type)
        for b in blocks:
            rect_bloque = fitz.Rect(b[:4])           # Coordenadas del bloque de texto
            
# 3. Verificar si el bloque está dentro de alguna tabla
            dentro_de_tabla = False                  # Declara la variable para controlar presencia de texto
            for t_rect in tab_rects:
                if rect_bloque.intersects(t_rect):   # Si el bloque de texto toca el área de la tabla...
                    dentro_de_tabla = True           # Cambia el contenido de la variable y...
                    break                            # Se sale del ciclo (y no se "captura" el bloque de texto)
            
# 4. Escribir solo si no es parte de una tabla y es tipo texto (0)
            if not dentro_de_tabla and b[6] == 0:    # Opción contraria a la precedente (if not)
                archivo_txt.write(b[4] + "\n")       # Entonces sí se "capttura" el bloque de texto y se escribe en el .txt (almacen)

        archivo_txt.write("\n\n")                    # Añade salto de línea para diferenciar bloques de texto en el .txt

doc.close()                                          #Cerrar objeto Document

print("Proceso finalizado: Párrafos extraídos omitiendo áreas de tablas.")


Lee detenidamente el script y sus comentarios. Advertirás que la base de su funcionamiento es la identificación de los sectores de texto y de las estructuras de las tablas, ejecutando un proceso opcional, en función del objetivo: capturar (al .txt de saldida) los bloques de texto que no están asociados a la/s tabla/s.

sábado, 18 de abril de 2026

DATOS. Archivos PDF

Bibloteca PyMuPDF (II)

Acceso a documentos pdf

Dentro de los diferentes usos que puede tener la biblioteca PyMuPDF, la primera y más básica es la de facilitar el acceso a los documentos; a los documento .pdf, pero no sólo.

La instrucción para acceder a un archivo .pdf es muy simple: doc = fitz.open("archivo.pdf"), habiendo importado PyMuPDF como import fitz.

En realidad, lo que hacemos con esta instrucción es acceder al objeto, tal y como muestra la visualización de esta instrucción cuando los solicitamos print(doc) -> Document('ar_pdf.pdf'), pero a partir de esa instucción, podemos acceder al recuento del número de páginas (print(doc.page_count)), a los metadatos del documento (print(doc.metadata)) o asignar una de sus páginas a una variable (pag_1 = doc[0]), su contenido a otra (texto_1 = pag_1.get_text()) y solicitar la visualización de éste por pantalla (print(texto_1)), cosa que lograremos sí realmente la página en cuestión contiene texto.

Si queremos acceder al contenido completo del archivo (al texto), podremos usar un bucle que recorrar todas sus páginas utilizando la misma función pag.get_text()...


#Acceso a todas las páginas y a todo su contenido
for pag in doc:
    texto = pag.get_text()
    print(texto)

... pero si queremos almacenar este texto en un archivo .txt, deberemos crearlo (función with open(), recorrer las páginas del documento (for pag in doc:), extrayendo su contenido (texto = pag.get_text()) y manipular el archivo .txt como ya sabemos y aquí desarrolla el punto 4 del script.



#0. Importar la biblioteca

import fitz  # PyMuPDF

# 1 Acceder al objeto documento
doc = fitz.open("ar_pdf.pdf")

# 2. Abrir (o crear) el archivo TXT ('w' para modo escritura, 'encoding' para uso de las tildes)
with open("contenido_extraido.txt", "w", encoding="utf-8") as archivo_txt:
    
# 3. Acceso a todas las páginas y a su contenido
    for pag in doc:
        texto = pag.get_text()
        
# 4. Escribir el texto de la página en el archivo TXT
        archivo_txt.write(f"--- Página {pag.number + 1} ---\n")
        archivo_txt.write(texto)
        archivo_txt.write("\n\n")  # Añade espacio entre páginas

# 5. Cerrar el documento PDF
doc.close()

print("El contenido ha sido archivado con éxito en 'contenido_extraido.txt'")


Este script captaura TODO el texto que reconoce en el documento (con independencia de que se encuentre dentro de tablas o no), por lo que no es útil para diferenciar entre distintos tipos de presentación del texto; pero podemos crear scipt que se centren obtener el texto según su presentación. Eso en la próxima entrada.

DATOS. Archivos PDF

Bibloteca PyMuPDF (I)

Presentación

Inicio esta entrada con un reconocimiento de influencia en su autoría: estas notas parten de la devueltas por IA-Gemini en consulta realizada el día 16/04/2026. A partide de ahí se desarrollar un proceso personal de indagación e interpretación

PyMuPDF (de nombre import fitz) es una biblioteca Python para la manipulación de diferentes tipos de documento (vg. eBooks), entre los que destacan los documentos .pdf, que son sobre los que aquí se empleará.

Además de ciertas capacidades multiformato, permite la extracción de datos, manteniendo la estructura (columnas y párrafos) y el contenido (texto, tablas, imágenes y metadatos); también soporta la búsqueda de texto específico en el documento, la manipulación de páginas (insertar, rotar, eliminar o reordenar páginas), añadir elementos (anotaciones, marcas de agua, enlaces y formularios) y redactar (y eliminar) información sensible. En cuanto al trabajo con gráficos (renderizado) permite convertir páginas en imágenes.

Un módulo específico (PyMuPDF4LLM) permite la integración con la IA, facilitando la conversión de PDF en lenguajes de marcas (Markdown estructurado), muy útil para los modelos de lenguaje (LLM) y sistemas RAG.

Para trabajar con esta biblioteca necesitamos instalarla previamente pip install pymupdf, aunque te aconsejo que consultes estas páginas web, además de instalarla:

martes, 31 de marzo de 2026

DATOS. Directorios y archivos

Seleccionar archivos

Criterio: extensiones de archivos categorizadas

El objetivo de esta función es la selección de archivos en función de su extensión; más concretamente realizar el recuento de archivos atendiendo a las categorías establecidas a partir de la tipología documental.

Lo específico del contexto es la diferenciación entre archivos derivados del uso de procesadores de texto y de otras fuentes, como los resultantes de la conversión a .pdf o los creados como hojas de cálculo. Este contexto se ajusta a procesos de análisis documental como los que tienen lugar cuando se estudia la composición de los expedientes de alumnos.

Al igual que en otras ocasiones, pasamos a continuación a realizar el análisis del script, diferenciando sus tres partes y empezando por las bibliotecas necesarias.



#--- Bibilotecas ----------------------------------------------------

import os
import csv
import pandas as pd
import matplotlib.pyplot as plt


Podemos apreciar la presencia de cuatro bibliotecas cuya función indica la funcionalidad esperada para la función: el uso de directorios y archivos mediante os, la creación de una base de datos simple mediante csv, el análisis de datos mediante pandas y la creación de un gráfico estadístico mediante matplotlib.

Pasamos a mostrar la función la cual iniciamos con los datos necesarios para la categorización mediante categorias_ext y la declaración de la lista datos_archivos, necesaria para el recuento que resulte.



#--- Función ----------------------------------------------------------

def analizar_directorio(ruta_objetivo):

    categorias_ext = {
        'archivo-procesador': ['.doc', '.docx', '.odt'],
        'archivo-pdf': ['.pdf'],
        'archivo-HCalc': ['.xls', '.xlsx', '.ods']
    }
    
    datos_archivos = []
    conteo_categorias = {cat: 0 for cat in categorias_ext}
    conteo_categorias['otros'] = 0

# 1. Búsqueda en profundidad (os.walk)
    for raiz, directorios, archivos in os.walk(ruta_objetivo):
        for nombre_archivo in archivos:
            nombre, ext = os.path.splitext(nombre_archivo)
            ext = ext.lower()
            
            categoria = 'otros'
            for cat, extensiones in categorias_ext.items():
                if ext in extensiones:
                    categoria = cat
                    break
            
            if categoria != 'otros':
                datos_archivos.append({
                    'Nombre': nombre_archivo,
                    'Extensión': ext,
                    'Categoría': categoria
                })
                conteo_categorias[categoria] += 1
            else:
                conteo_categorias['otros'] += 1

# 2. Guardar en CSV
    ruta_csv = os.path.join(os.path.dirname(__file__), 'analisis_archivos.csv')
    with open(ruta_csv, 'w', newline='', encoding='utf-8') as f:
        escritor = csv.DictWriter(f, fieldnames=['Nombre', 'Extensión', 'Categoría'])
        escritor.writeheader()
        escritor.writerows(datos_archivos)

# 3. Mostrar listado por consola
    print(f"{'NOMBRE':<50} | {'CATEGORÍA'}")
    print("-" * 70)
    for fila in datos_archivos:
        print(f"{fila['Nombre'][:48]:<50} | {fila['Categoría']}")

# 4. Tabla resumen con Pandas
    df_resumen = pd.DataFrame(list(conteo_categorias.items()), columns=['Tipo', 'Cantidad'])
    # Solo mostramos las categorías que nos interesan (excluyendo 'otros' si prefieres)
    df_resumen = df_resumen[df_resumen['Tipo'] != 'otros']
    
    print("\n--- RESUMEN DE DATOS ---")
    print(df_resumen.to_string(index=False))

# 5. Gráfico Sectorial
    if df_resumen['Cantidad'].sum() > 0:
        plt.figure(figsize=(8, 6))
        plt.pie(df_resumen['Cantidad'], labels=df_resumen['Tipo'], autopct='%1.1f%%', startangle=140)
        plt.title('Distribución de Archivos por Categoría')
        plt.show()
    else:
        print("\nNo se encontraron archivos de las categorías especificadas para generar el gráfico.")


Lo que sigue en la función son las diferentes partes o fases de su desarrollo, que se relacionan, cada una de ellas, con las bibliotecas vistas en la primera parte del análisis, incluyendo al archivo del documento csv y la visualización por consola de la tabla de datos y del gráfico resultante del uso de matplotlib.

Aunque el almacenamoento y la visualización son interesantes, la razón de ser de la función se desarrolla en la identificada como # 1. Búsqueda en profundidad (os.walk), ya que es en ella donde tiene lugar la búsqueda de los archivos, la identificación diferenciada del nombre y de la extensión y su almacenamiento en función de la categoría que deriva de ésta.

Finalizamos el análisis con el del script de ejecución de la función, que se simplifica al máximo al constar únicamente con el procedimiento para concretar la ruta en la que realizar el análisis ruta = r"Mi_Ruta" y la llamada a la función, incluyendo el parámetro resultante de lo anterior analizar_directorio(ruta)



# --- Script de ejecución de la función ----------------------------------------------------------------------

ruta = r"Mi_Ruta" #Indica aquí tu ruta absoluta
analizar_directorio(ruta)


Todo lo que resulta del uso del script corre a cargo de la función analizar_directorio()

domingo, 29 de marzo de 2026

DATOS. Directorios y archivos

Unificar la extensión

Hay ocasiones en las que nos interesa unificar el tipo de documentos a fin de simplificar procesos posteriores. En estos casos disponer de un procedimiento que transforma todos los arhcivos a un mismo tipo es una buena solución.

Cuando la procedencia de los archivos es diversa suele suceder que acabamos trbajando con documentos de contenido similar (o igual) pero en diferentes formatos. Un ejemplo de ello son los archivos derivados del uso de procesadores de texto en los que incluso usando el mismo servicio (MSO-Word) podemos generar documentos .doc o documentos .docx. Si a esto añadimos el uso de LO-Writer obtendremos una colección de archivos en tres formatos diferentes: .doc, .docx y .odt. Cierto que LO-Writer permite acceder a todos ellos indistintamente y de forma fiable y segura, pero si queremos automatizar determinados procesos desde Python unificar la tipología de los archivos puede considerarse una media previa cuanto menos conveniente.

El script que sigue permite unificar todos los archivos de una ruta al formato .docx, lo que facilita el posterior tratamiento de todos los archivos con las funciones disponibles en la biblioteca python-docx.




#Bibliotecas necesarias ---------------------------------------------------

import os
import subprocess
import pathlib

#Función -------------------------------------------------------------------

def convertir_y_limpiar(ruta_absoluta):
    # Configuración de ruta
    RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe'
    
    if not os.path.exists(RUTA_LIBREOFFICE):
        print(f"Error: No se encontró LibreOffice en {RUTA_LIBREOFFICE}")
        return

    extensiones_a_convertir = {'.doc', '.odt'}
    
    if not os.path.exists(ruta_absoluta):
        print(f"Error: La ruta {ruta_absoluta} no existe.")
        return

    print(f"Procesando archivos en: {ruta_absoluta}...\n")
    
    for raiz, dirs, archivos in os.walk(ruta_absoluta):
        for nombre_archivo in archivos:
            ruta_original = pathlib.Path(os.path.join(raiz, nombre_archivo))
            ext = ruta_original.suffix.lower()

            # 1. Ignorar si ya es .docx
            if ext == '.docx':
                continue

            # 2. Procesar solo .doc y .odt
            if ext in extensiones_a_convertir:
                # Definimos cómo se llamaría el nuevo archivo
                ruta_nuevo_docx = ruta_original.with_suffix('.docx')
                
                print(f"Transformando: {nombre_archivo} -> {ruta_nuevo_docx.name}")
                
                try:
                    # Ejecutar conversión
                    resultado = subprocess.run([
                        RUTA_LIBREOFFICE, 
                        '--headless', 
                        '--convert-to', 'docx', 
                        '--outdir', str(ruta_original.parent),
                        str(ruta_original)
                    ], check=True, capture_output=True)

                    # 3. Verificación de seguridad: ¿Existe el nuevo archivo?
                    if ruta_nuevo_docx.exists():
                        # Eliminamos el original solo si el nuevo ya existe
                        os.remove(ruta_original)
                        print(f"   [OK] Convertido y eliminado original.")
                    else:
                        print(f"   [!] Error: No se encontró el archivo convertido {ruta_nuevo_docx.name}")

                except Exception as e:
                    print(f"   [X] Error procesando {nombre_archivo}: {e}")

    print("\n¡Limpieza y conversión completadas!")

#Script de aplicación de función ---------------------------------------------------------------------

if __name__ == "__main__":
    mi_ruta = "Mi_Ruta"
    convertir_y_limpiar(mi_ruta)


Este script convierte archivos de tipo extensiones_a_convertir = {'.doc', '.odt'} a archivos .docx usando LO RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe'. No es la única opción, pero sí muy recomendable (y gratuita), motivo por el que insisto en el interés que tiene trabajar con LibreOffice también para automatizar procesos con Python.

En este caso el script susituye los archivos originales por los convertidos, por lo que deberás hacer una copia de seguirdad de aquellos si los quieres. Este otro script mantiene los archivos originales y genera un nuevo directorio donde archiva los convertidos



#---Bibliotecas necesarias ------------------------------------------------

import os
import subprocess
import pathlib

#---Función de conversión -------------------------------------------------

def convertir_a_nueva_carpeta(ruta_origen, carpeta_destino):
    # 1. Configuración de rutas
    RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe'
    
    if not os.path.exists(RUTA_LIBREOFFICE):
        print(f"Error: No se encontró LibreOffice.")
        return

    # 2. Crear la carpeta de destino si no existe
    ruta_destino_abs = pathlib.Path(carpeta_destino).resolve()
    if not ruta_destino_abs.exists():
        os.makedirs(ruta_destino_abs)
        print(f"Carpeta creada: {ruta_destino_abs}")

    extensiones_a_convertir = {'.doc', '.odt'}

    print(f"Leyendo de: {ruta_origen}")
    print(f"Guardando en: {ruta_destino_abs}\n")
    
    for raiz, dirs, archivos in os.walk(ruta_origen):
        for nombre_archivo in archivos:
            ruta_original = pathlib.Path(os.path.join(raiz, nombre_archivo))
            ext = ruta_original.suffix.lower()

            if ext in extensiones_a_convertir:
                print(f"Convertiendo: {nombre_archivo}...")
                
                try:
                    # 3. Ejecutar conversión apuntando al NUEVO directorio
                    subprocess.run([
                        RUTA_LIBREOFFICE, 
                        '--headless', 
                        '--convert-to', 'docx', 
                        '--outdir', str(ruta_destino_abs), # <--- AQUÍ está el cambio clave
                        str(ruta_original)
                    ], check=True, capture_output=True)

                    # 4. Verificación (Opcional, pero recomendada)
                    nuevo_archivo = ruta_destino_abs / (ruta_original.stem + ".docx")
                    if nuevo_archivo.exists():
                        print(f"   [OK] Guardado en destino.")
                    
                except Exception as e:
                    print(f"   [X] Error con {nombre_archivo}: {e}")

    print("\n¡Proceso finalizado! Los archivos originales permanecen intactos.")

# --- Llamada a la función ------------------------------------------------------------

if __name__ == "__main__":
    origen = "Mi_Ruta_Documentos"  # Carpeta donde están tus .doc/.odt
    destino = "Documentos_Convertidos" # Nueva carpeta donde se guardarán
    convertir_a_nueva_carpeta(origen, destino)


La conversión a .docx no es la única posible, y en muchos casos tampoco la más adecuada, siendo .pdf la extensión preferida para realizar determinadas transaciones o el almacenamiento de documentos. Además también disponemos de biblitecas Python para trabajar con estos documentos. Por ello te proporciono un tercer y último script que convierte archivos procedentes de los servicios de procesador de texto a .pdf tomando a LibreOffice como tecnología de base.



#---Bibliotecas necesarias ------------------------------------------------

import os
import subprocess
import pathlib

#---Función de conversión -------------------------------------------------

def generar_pdfs_en_destino(ruta_origen, carpeta_pdf):
    # --- Configuración ---
    RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe'
    
    if not os.path.exists(RUTA_LIBREOFFICE):
        print(f"Error: LibreOffice no detectado en {RUTA_LIBREOFFICE}")
        return

    # Extensiones que queremos convertir a PDF
    extensiones_admitidas = {'.doc', '.docx', '.odt'}

    # Crear carpeta de destino absoluta
    ruta_salida = pathlib.Path(carpeta_pdf).resolve()
    if not ruta_salida.exists():
        os.makedirs(ruta_salida)
        print(f"Carpeta de salida creada: {ruta_salida}")

    print(f"Iniciando conversión de documentos a PDF...")
    print(f"Origen: {ruta_origen}\n" + "-"*30)

    # --- Procesamiento ---
    for raiz, dirs, archivos in os.walk(ruta_origen):
        for nombre_archivo in archivos:
            ruta_archivo_original = pathlib.Path(os.path.join(raiz, nombre_archivo))
            ext = ruta_archivo_original.suffix.lower()

            if ext in extensiones_admitidas:
                print(f"Procesando: {nombre_archivo}...")
                
                try:
                    # Ejecutar LibreOffice para exportar a PDF
                    subprocess.run([
                        RUTA_LIBREOFFICE,
                        '--headless',
                        '--convert-to', 'pdf',
                        '--outdir', str(ruta_salida),
                        str(ruta_archivo_original)
                    ], check=True, capture_output=True)

                    # Verificación de que el PDF se creó
                    nombre_esperado = ruta_archivo_original.stem + ".pdf"
                    if (ruta_salida / nombre_esperado).exists():
                        print(f"   [OK] PDF generado correctamente.")
                    else:
                        print(f"   [!] Advertencia: No se confirmó la creación de {nombre_esperado}")

                except Exception as e:
                    print(f"   [X] Error al convertir {nombre_archivo}: {e}")

    print("\n" + "-"*30 + "\n¡Proceso completado! Los archivos originales no han sido modificados.")

# --- Ejecución den la función -----------------------------------------------------------------------

if __name__ == "__main__":
    # Define tus rutas aquí
    mi_directorio_original = "Documentos_Trabajo" 
    mi_directorio_pdfs = "Archivo_Exportado_PDF"
    
    generar_pdfs_en_destino(mi_directorio_original, mi_directorio_pdfs)


sábado, 28 de marzo de 2026

DATOS. Directorios y archivos

Procedimientos basados en IA

Pretendo finalizar con esta entrada el tratamiento de los archivos como datos, paso previso al acceso al contenidos o conjunto de datos que contiene el archivo. Llamaré documento a ese contenido. Dar este paso requiere resolver previamente dos cuestiones: de la primera nos ocupamos a continuación y de la segunda nos ocuparemos en las entradas que siguen y que tratan del acceso a diferentes tipos de archivos, emplezando por la diferenciación entre datos ordenados y datos no ordenados, y siguiendo por las tecnologías específicas para acceder a diferentes tipos de documentos. Sólo cuando hayamos realizado este recorrido podremos ocuparnos en detalle de los procedimientos de identificación y captación del contenido documental. Un largo camino por recorrer.

Volviendo a lo que nos interesa ahora, lo que aporta la IA no puede pasar desapercibido, pero tengo que decir que no porque sea especialmente relevante para la problemática que ahora nos ocupa. De hecho puede considerarse una solución más costosa y menos fucional que algunas de las ya expuestas; pero esto no quita para que debamos hablar de ella.

Y debemos hacerlo por dos motivos: el primero para aclarar una cuestión de gran interés que será motivo de reflexión en detalle en otras entradas (de otra sección), y que tiene que ver más bien con las limitaciones: hoy por hoy no es posible utilizar los modelos IA on-line con documentos confidenciales o que aporten información confidencial, por lo que sólo podremos usar soluciones off-line, que son mucho más limitadas en sus funcionalidades y rendimientos. Y esta es la segunda cuestión, que las opciones IA realmente disponibles no son nesariamente mejores soluciones que las que ya hemos planteado, aunque interese conocerlas. De eso se trata aquí y ahora.

Realmente para que este contenido sea de provecho es necesario que desarrolle varias entradas en las que explique los recursos de los que voy a hacer uso ahora, pero por una vez vamos a saltarnos el orden lógico de las cosas y ofrecer soluciones que ya deberían haber sido explicadas con cierto detalle. Pido disculpas por todo ello y espero que lo que expongo por adelantado sirva para despertar el interés de los profesionales de los SEO.

Parto de tener instalada una IA en local (yo concretamente Ollama), así como determinados modelos (en mi caso los siguientes: mxbai-embed-large:latest (embeding), deepseek-r1:1.5b, qwen2.5-coder:3b, llama3.2:3b y gemma3:1b como slm)

En esta entrada no voy a hacer mucho más que mostarte los dos script que he trabajo con IA-Gemini. Primero el modelo embeding...



#Bibliotecas necesarias --------------------------------------------

import ollama
import numpy as np
import psutil  # Para gestionar el cierre del proceso
import os
import signal

# --- CONFIGURACIÓN de ollama ---------------------------------------
MODELO_EMBEDDING = 'mxbai-embed-large' 

archivos = [
    "2023_SISE_Final.pdf", 
    "PTSC_Informe_v1.docx", 
    "Seguimiento_PSC.xlsx", 
    "Vacaciones.jpg"
]

busqueda = "Informe Seguimiento PTSC"

# --- FUNCIONES -------------------------------------------------------

def obtener_embedding(texto):
    try:
        resultado = ollama.embed(model=MODELO_EMBEDDING, input=texto)
        return resultado['embeddings'][0]
    except Exception as e:
        print(f"Error al conectar con Ollama: {e}")
        return None

def calcular_similitud_coseno(v1, v2):
    if v1 is None or v2 is None: return 0
    return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

def cerrar_ollama():
    """Busca el proceso de Ollama y lo finaliza."""
    print("\nCerrando Ollama para liberar recursos...")
    encontrado = False
    for proc in psutil.process_iter(['pid', 'name']):
        # Buscamos 'ollama' en el nombre del proceso
        if 'ollama' in proc.info['name'].lower():
            try:
                # Enviamos señal de terminación
                os.kill(proc.info['pid'], signal.SIGTERM) 
                encontrado = True
            except Exception as e:
                print(f"No se pudo cerrar el proceso {proc.info['pid']}: {e}")
    
    if encontrado:
        print("Servidor Ollama detenido correctamente.")
    else:
        print("No se encontró ningún proceso de Ollama activo.")

# --- PROCEDIMIENTO PRINCIPAL --------------------------------------------------------

try:
    print(f"--- Iniciando búsqueda inteligente para: '{busqueda}' ---")
    
    vector_busqueda = obtener_embedding(busqueda)
    resultados = []

    for archivo in archivos:
        vector_archivo = obtener_embedding(archivo)
        similitud = calcular_similitud_coseno(vector_busqueda, vector_archivo)
        score_porcentaje = max(0, similitud * 100)
        resultados.append((archivo, score_porcentaje))

    resultados.sort(key=lambda x: x[1], reverse=True)

    print("\nMejores coincidencias:")
    for nombre, score in resultados[:3]:
        if score > 50:
            print(f"Archivo: {nombre:<25} (Confianza: {score:.1f}%)")
finally:
    cerrar_ollama()
              

... y después el modelo slm



import ollama
import psutil
import os
import signal
import re

# --- CONFIGURACIÓN ---
# Usamos un modelo de lenguaje ligero para comparar
MODELO_LLM = 'gemma3:1b' 

archivos = [
    "2023_SISE_Final.pdf", 
    "PTSC_Informe_v1.docx", 
    "Seguimiento_PSC.xlsx", 
    "Vacaciones.jpg"
]

busqueda = "Informe Seguimiento PTSC"

def evaluar_relevancia_llm(nombre_archivo, consulta):
    """
    Le pide al LLM que puntúe la relación entre el archivo y la búsqueda.
    """
    prompt = f"""
    Analiza si el nombre del archivo coincide semánticamente con la búsqueda.
    Responde ÚNICAMENTE con un número del 0 al 100, donde 100 es coincidencia exacta.
    
    Búsqueda: {consulta}
    Archivo: {nombre_archivo}
    Puntuación:"""

    try:
        respuesta = ollama.generate(model=MODELO_LLM, prompt=prompt, options={"num_predict": 5, "temperature": 0})
        # Extraemos solo los números de la respuesta
        score = re.findall(r'\d+', respuesta['response'])
        return int(score[0]) if score else 0
    except Exception as e:
        print(f"Error con el modelo: {e}")
        return 0

def cerrar_ollama():
    print("\nFinalizando procesos de Ollama...")
    for proc in psutil.process_iter(['pid', 'name']):
        if 'ollama' in proc.info['name'].lower():
            try:
                os.kill(proc.info['pid'], signal.SIGTERM)
            except:
                pass

# --- PROCEDIMIENTO ---

print(f"--- Evaluando con LLM: {MODELO_LLM} ---")
resultados = []

for archivo in archivos:
    print(f"Analizando: {archivo}...", end="\r")
    score = evaluar_relevancia_llm(archivo, busqueda)
    resultados.append((archivo, score))

# Ordenar por puntuación
resultados.sort(key=lambda x: x[1], reverse=True)

print("\n\nResultados del modelo de lenguaje:")
print("-" * 45)
for nombre, score in resultados:
    # Mostramos todos para ver cómo "piensa" el modelo pequeño
    print(f"Archivo: {nombre:<25} | Score LLM: {score}/100")

cerrar_ollama()


Sobre la base de los nombres de documentos pasados como datos y el criterio de búsqueda



archivos = [
    "2023_SISE_Final.pdf", 
    "PTSC_Informe_v1.docx", 
    "Seguimiento_PSC.xlsx", 
    "Vacaciones.jpg"
]

busqueda = "Informe Seguimiento PTSC"


... estos son los resultados obtenidos:

Modelo embeding

--- Iniciando búsqueda inteligente para: 'Informe Seguimiento PTSC' ---
Mejores coincidencias:
Archivo: PTSC_Informe_v1.docx (Confianza: 79.1%)
Archivo: Seguimiento_PSC.xlsx (Confianza: 75.3%)
Archivo: Vacaciones.jpg (Confianza: 59.9%)

Modelo slm

--- Evaluando con LLM: gemma3:1b ---
Analizando: 2023_SISE_Final.pdf...
Analizando: PTSC_Informe_v1.docx...
Analizando: Seguimiento_PSC.xlsx...
Analizando: Vacaciones.jpg...

Resultados del modelo de lenguaje:

---------------------------------------------
Archivo: 2023_SISE_Final.pdf | Score LLM: 95/100
Archivo: PTSC_Informe_v1.docx | Score LLM: 95/100
Archivo: Seguimiento_PSC.xlsx | Score LLM: 95/100
Archivo: Vacaciones.jpg | Score LLM: 30/100

Como ves, los resultados son muy interesantes, aunque falta probarlos con tareas reales y de mayor carga de contenidos y de dificultad. Además puedes comprobar la funcionalidad del uso de la IA local, incluyendos sus limitaciones: lo que hemos obtenido con ella en esta ocasión no mejora sustancialmente lo conseguido, por ejemplo, usando la biblioteca rapidfuzz. Pero tiempo habrá para realizar comparaciones de mayor precisión y exigencia. Por ahora estamos probando viabilidad de medios y estrategias de uso.

DATOS. Directorios y archivos

Otros procedimientos de búsqueda

Cuando lo que buscamos está bien definido y también el módo en que se expresa en el nombre del archivo, posiblemente la mejor solución sea la planteada en el segundo script de la entrada anterior: es simple, seguda y precisa. Pero cuando no contamos con esas seguridades y se pueden haber cometido errores o la misma realidad (el mismo tipo de archivo) se puede expresar de otras formas, puede ser necesario recurrir a otro tipo de procedimientos.

Uno de ellos consiste en crear una lista de posibles nombres (conjuntos de caracteres) y utilizarla como términos para la búsqueda. Un ejemplo: supongamos que además o en lugar de "SISE", los archivos que deseamos buscar pueden contener "seguimiento" o "PTSC". En ese caso sustituímos la referencia exclusiva IDENTIFICADOR = "SISE" por unq lista, tal y como se propone en el siguiente script:



#Bibliotecas a emplear -----------------------------------------------------------------

from pathlib import Path

#Función -------------------------------------------------------------------------------

def buscar_multiples_identificadores(ruta_raiz, lista_keywords):

    directorio = Path(ruta_raiz)
    
    if not directorio.is_dir():
        print(f"Error: La ruta '{ruta_raiz}' no es válida.")
        return

    # Convertimos los términos a minúsculas para una búsqueda insensible a mayúsculas
    keywords = [k.lower() for k in lista_keywords]
    
    print(f"Buscando archivos que contengan: {lista_keywords}")
    print("-" * 50)

    conteo = 0
    
    # rglob('*') recorre todos los archivos de la ruta
    
    for archivo in directorio.rglob('*'):
        if archivo.is_file():
            nombre_archivo = archivo.name.lower()
            
            # Verificamos si AL MENOS UNO de los identificadores está en el nombre
            if any(key in nombre_archivo for key in keywords):
                print(archivo.name)
                conteo += 1

    print("-" * 50)
    print(f"Búsqueda finalizada. Total de archivos encontrados: {conteo}")

# Llamada a la función ---------------------------------------------------------

ruta = r"Mi_Ruta"	#Ruta en la que buscar los archivos

# Puedes añadir tantos identificadores como necesites en esta lista
mis_identificadores = ["SISE", "Seguimiento", "PTSC", "PSC"]		# Sustituye estos por tus términos

buscar_multiples_identificadores(ruta, mis_identificadores)


Como principales diferencias respecto al script de elemento único, ahora empleamos la lista mis_identificadores = ["SISE", "Seguimiento", "PTSC", "PSC"] como parámetro y lo utilizamos como referente para la búsqueda if any(key in nombre_archivo for key in keywords)::

  • El operador in comprueba la existencia de cada uno de los elementos de la lista y devuelve True cuando lo encuentra.
  • La parte key de for key in keywords es un pequeño motor de búsqueda que recorre la lista de identificadores uno por uno y contrasta con el nombre del archivo para comprobar la presencia del elemento o "palabra".
  • Y la función any() actúa como un conmutador binario inicialmente posicionado como False que pasa a True en cuanto una de las comprobaciones anteriores resulta también True. Esto hace que, en este caso, el archivo pase a ser mostrado en consola.

Aun disponemos de otra opción que supone un cambio en el planteamiento de trabajo y un salto en el modelo de script. Me estoy refiriendo al uso de la bibliteca Rapidfuzz que se debe instalar previamente pip install rapidfuzz.

Aunque no me voy a deterner a explicar esta biblioteca, pero sí parece pertinente una breve referencia a qué hace esta biblioteca y por qué es interesante para nuestro objetivo.

Empezaré diciendo que es una biblioteca de comparación de cadenas que se basa en el cálculo de similitud de cadenas de la función de Levenshtein...

... lo que supone realizar comparaciones entre string (cadena de texto) de forma “difusa” (fuzzy matching) y no mediante procedimientos como los empleados en la programación tradicional. Esto supone que lo que obtenemos es una lista de probabilidad de similitud, no una lista de resultados supuestamente seguros, de ahí que sea necesario establecer el porcentaje deseado, dependiendo del cual, en este caso, obtendermos una lista más o menos extensa.




#Bibliotecas necesarias ---------------------------------------------------------

from pathlib import Path
from rapidfuzz import fuzz  # Algoritmo de similitud

#Función -------------------------------------------------------------------------

def buscador_inteligente(ruta_raiz, identificadores, umbral):
    directorio = Path(ruta_raiz)
    
    if not directorio.is_dir():
        print(f"Error: {ruta_raiz} no es una ruta válida.")
        return

    print(f"Buscando coincidencias inteligentes para: {identificadores}")
    print(f"Umbral de precisión: {umbral}%\n")

    conteo = 0
    # Recorremos todos los archivos en profundidad
    for archivo in directorio.rglob('*'):
        if archivo.is_file():
            nombre_archivo = archivo.name.lower()
            
            # Comparamos cada identificador con el nombre del archivo
            for iden in identificadores:
                # partial_ratio busca si la palabra "encaja" dentro de otra aunque haya errores
                similitud = fuzz.partial_ratio(iden.lower(), nombre_archivo)
                
                if similitud >= umbral:
                    # Si la similitud es alta, lo damos por válido
                    status = "EXACTO" if similitud == 100 else f"{similitud}%"
                    print(f"[{status}] -> {archivo.name}")
                    conteo += 1
                    break # Pasamos al siguiente archivo si ya encontramos coincidencia

    print(f"\n--- Búsqueda finalizada. Total: {conteo} archivos ---")

# Llamada a función ---------------------------------------------------------------

ruta_trabajo = r"Mi_Ruta" # Tu carpeta compleja
mis_ids = ["SISE", "PTSC","PSC","Seguimiento"]	# Plantea aquí tu lista de términos
punteria = 000 # Ajusta: 100 es exacto, 60 es muy flexible

buscador_inteligente(ruta_trabajo, mis_ids, punteria)


Para hacerse una idea del funcionamiento real de estos sistemas de búsqueda por comparación con el empleado en el primer script de este tipo (ver segundo script de la entrada) veamos qué resultados obtenemos en una búsqueda real de archivos SISE (informes de seguimiento de PSC/PTSC) en un conjunto de expedientes SEO.

Buscado Número de archivos
Buscador simple (SISE) 34 archivos
Buscador múltiple (["SISE", "PTSC","PSC","Seguimiento"]) 65 archivos
Buscador fuzz ["SISE", "PTSC","PSC","Seguimiento"](75%) 450 archivos
Buscador fuzz ["SISE", "PTSC","PSC","Seguimiento"](85%) 65 archivos
Buscador fuzz ["SISE", "PTSC","PSC","Seguimiento"](95%) 65 archivos
Buscador fuzz ["SISE", "PTSC","PSC","Seguimiento"](100%) 65 archivos

Una correcta valoración de estos resultados pasaría por comprobar cuantos de los archivos detectados con los procedimientos menos restricitivos son realmente seguimientos de alumnado realizado por PTSC/PSC. Evidentemente, salvo error de denominación, podemos estar seguros de que los 34 archivos identificados con el procedimiento restrictivo lo son (ese mismo es el número de archivos obtenemos si reducimos la lista fuzz a únicamente "SISE", como cabe esperar). También podemos estar seguros de que los 450 archivos que derivan de un 75% de nivel de similitud está dando lugar a múltiples falsos positivos; pero lo que no podemos saber sin "mirar dentro" de los archivos es cuantos del resto de los procedimientos, de esos 65 que derivan de sistemas más abiertos de búsqueda (donde la causa de la variación está en el número de "palabras" que incluya la búsqueda y no el porcentaje de similitud que apliquemos), son realmente "SISE".

Estas cuestiones quedarán aquí pendientes de comprobación, porque no es esta sección lugar para este tipo de análisis. Es posible que las retome en otro momento y lugar, pero por ahora finalizamos esta entrada. Quedan algunas cosas pendientes, pero serán para otra.

Aquí, y a modo de resumen, recordar la línea de trabajo en la que hemos avanzado hasta el momento: empezamos por buscar archivos considerando al archivo como dato en si mismo; avanzamos en los procedimientos de búsqueda de archivos, proimero superficial y después en profundidad, y nos planteamos una segunda forma de entender el archivo como dato: la extensión y el nombre. Para el análisis del nombre se han empleado procedimientos simples (un término) y complejos (una colección de términos), pero ambos dentro de lo que nos permite la biblioteca pathlib. Pero además podemos aplicar procedimientos matemáticos de análisis de texto (string) de base estadística como los que aporta la biblioteca rapidfuzz. Y aun no hemos terminado...

viernes, 27 de marzo de 2026

DATOS. Directorios y archivos

Nombre y extensión

Después de haber aprendido en la entrada anterior a buscar archivos dentro de directorios complejos en lo que llamamos modo-en-profundidad, vamos a aprender en ésta a trabajar con los dos componentes del identificador del archivo: su nombre y su extensión. Ambas forman parte de la información relevante sobre un archivo previa a acceder a su contenido.

Nos interesa saber cómo identificar y seleccionar archivos en función de su extensión para múltiples cuestiones; por ejemplo, para seleccionar dentro de un amplio conjunto de archivos aquellos que pertenecen a una categoría determinada, por ejemplo, archivos .pdf



# Biblioteca --------------------------------------------------------------
from pathlib import Path

# ---- Función -------------------------------------------------------------

def buscar_pdf(carpeta_inicial):
    # Convertimos el string en un objeto Path para usar sus métodos
    directorio = Path(carpeta_inicial)

    # Verificamos que la ruta proporcionada sea un directorio real
    if not directorio.is_dir():
        print(f"La ruta '{carpeta_inicial}' no es un directorio válido.")
        return

    print(f"Buscando archivos PDF en: {directorio.absolute()}\n")
    print("-" * 50)

    # .rglob('*.pdf') busca de forma recursiva archivos que terminen en .pdf
    # El asterisco '*' es un comodín que representa cualquier nombre de archivo
    conteo = 0
    for archivo in directorio.rglob('*.pdf'):
        # Verificamos que sea un archivo (por si existe una carpeta llamada algo.pdf)
        if archivo.is_file():
            # Imprimimos solo el nombre y la extensión, sin la ruta
            print(archivo.name)
            conteo += 1

    # Resumen final para el usuario
    print("-" * 50)
    if conteo > 0:
        print(f"Busqueda finalizada. Se encontraron {conteo} archivos PDF.")
    else:
        print("No se encontraron archivos PDF en esta ubicación.")

# --- Ejecutar la función ------------------------------------------------------------

ruta_objetivo = "Mi_Ruta" 	# Cambia ".Mi_Ruta" por la ruta que desees de tu sistema

# Llamada a la función
buscar_pdf(ruta_objetivo)


Este script Python busca de forma recursiva for archivo in directorio.rglob('*.pdf'): (en profundidad) todos los archivos pdf en una determinada ruta (la que le proporciones) ruta_objetivo = "Mi_Ruta"; te muestra el listado por pantalla print(archivo.name) y te informe del número de archivos encontrados print(f"Busqueda finalizada. Se encontraron {conteo} archivos PDF.")

Este procedimiento es muy eficiente, ya que te permite buscar cualquier tipo de archivo (en función de su extensión), siendo sugiciente con sustituir ('*.pdf') por ('*.doc') en for archivo in directorio.rglob('*.pdf'): (por ejemplo) si ahora el objetivo es listar sólo los archivos tipo .doc o cualquier otra extensión.

Pasemos ahora a trabajar con la información que aporta el nombre del archivo, que normalmente revela de qué trata o sobre qué referente. Por ejemplo, en los expedientes de alumnos, es común que los nombres de los archivos tengan algo que ver con el nombre y/o los apellidos del niño; también que hagan referencia al contenido del archivo, como por ejemplo, si se trata de un informe o de una actuación con la familia.

Gracias a esto podemos obtener referencias para la búsqueda de determinados archivos en función de objetivos como, por ejemplo, la selección de todos los archivos relativos a determinado alumno o a determinado tipo de actuación. Para ello empleamos las siguientes estrategias de automatización.



#Bibliotecas necesarias---------------------------------------------------------------

from pathlib import Path

# Función ------------------------------------------------------------------------------

def buscar_nombre(ruta_raiz,IDENTIFICADOR):
    
    directorio = Path(ruta_raiz)
    
    if not directorio.is_dir():
        print(f"Error: La ruta '{ruta_raiz}' no existe o no es un directorio.")
        return

    print(f"Iniciando búsqueda profunda de informes '{IDENTIFICADOR}'...")
    print("-" * 50)

    conteo = 0
    
    # rglob('*SISE*') busca en profundidad archivos que contienen SISE en su nombre (el asterisco antes y después permite buscar SISE en cualquier posición del nombre).
    for archivo in directorio.rglob(f"*{IDENTIFICADOR}*"):
        
        if archivo.is_file():           # Verificamos que sea un archivo y no una carpeta que se llame igual
            print(archivo.name)         # Imprimimos solo el nombre + extensión (sin la ruta)
            conteo += 1

    print("-" * 50)                         #Para visualizar en consola
    print(f"Búsqueda finalizada. Se han listado {conteo} archivos.")

# Ejecución de función --------------------------------------------------------------------------

ruta_de_busqueda = r"Mi_Ruta" #Indica aquí ("Mi_Ruta") tu ruta absoluta

IDENTIFICADOR = "Mi_Termino"  # Indica aquí ("Mi_Termino") la secuencia de letras que quieres buscar en el nombre del archivo

buscar_nombre(ruta_de_busqueda, IDENTIFICADOR)   #Llamada a la función


Una vez que establecemos la ruta absoluta ruta_de_busqueda = r"Mi_Ruta" y la palabra o secuencia de letras a buscar IDENTIFICADOR = "Mi_Termino" en el nombre del archivo, datos que pasamos como parámetros a la función buscar_nombre(ruta_de_busqueda, IDENTIFICADOR), ésta (la función def buscar_nombre(ruta_raiz,IDENTIFICADOR):) asume la ruta como directorio parala búsqueda directorio = Path(ruta_raiz) e inicia la búsqueda recorriendo en profundidad .rglob() todos los directorios presenten en esa ruta for archivo in directorio.rglob(f"*{IDENTIFICADOR}*"): usando el término IDENTIFICADOR como criterio de selección.

Podemos hacer la búsqueda más compleja (modificando el identificador), y también combinar en ella procedimientos basados en la extensión y el nombre del archivo (por ejemplo, buscar en una ruta todos los archivos que sean PDF y que hagan referencia a la familia, identificado ésta referencia por la secuencia "fam"), pero la esencia del procedimiento resultará de una formulación combinada de ambos script que, cierto es, se pueden complicar mucho más de lo visto hasta aquí. Pero con ello es suficiente para nuestros fines actuales.

También podemos cambiar las tecnología a implementar para aplicar otros procedimientos más próximos a la lógica difusa y a la IA, pero eso lo veremos en una nueva entrada.

jueves, 26 de marzo de 2026

Expedientes

No-Expedientes (II)

Hemos aprendido mucho del análisis de lo que hemos venido a llamar no-expedientes de archivo único, pero el siguiente grupo de directorios (no-expedientes de dos archivos) requiere nuevos planteamientos de análisis.

Conocemos por la fase previa de este análisis que son 64 los directorios que contienen dos archivos y que el número de archivos se eleva a 128, pero desconocemos de qué tipo son estos archivos y en cuantos de los no-expedientes está presentes. Estos fueron cálculo que obtuvimos para los no-expedientes de un archivo y no veo motivo para no aplicarlos también a este nuevo subconjunto: además de obtener resultados de interés (respecto a la tipología documental), también podremos aplicar el mismo criterio que en el grupo anterios e identificar cual es la incidencia de esas tipologías no-procesador que en su momento consideramos indicadores fallidos de generación de expedientes. Para ello necesitamos realizar una asociación entre tipos de archivos y directorios.

El modo en que esto se ha concretado implica procedimientos de mayor complejidad que en el caso de los directorios con un único archivo, ya que es ncesario proceder a diferenciar los directorios en dos subgrupos, dado que sólo deberemos trabajar con uno de ellos para el análisis subsiguiente. Pero no avancemos acontecimiento antes de tiempo.

Primero debemos obtener el listado de los archivos del subconjunto (los 128 archivos) e información sobre su tipología, datos que te aporto en la tabla que va a continuación:

Tipología Número Porcentaje
.doc 79 61.7
.docx 9 7
.odt 7 5.4
Procesador 95 74.2
PDF 28 21.8
H. cálculo 5 3.9
No procesador 33 25.8

Aunque no es tiempo aun para este tipo de análisis, no me resisto a decir que respecto a los directorios de un único archivo se observa ahora un incremento porcentual del peso de los archivos que no derivan del uso del procesador de texto (pasamos de suponer el 9% del total de archivos a alcanzar cerca del 26%) lo que lógicamente tendrá una incidencia importante en el número de directorios que podremos considerar expedientes fallidos y no meros no-expdientes. Pero para obtener datos al respecto necesitamos que nuestro script nos proporcione información directa sobre la indidencia y reparto de esos archivos (.pdf y H. de cálculo) en el conjunto de directorios de dos archivos.

La segunda fase consiste precisamente en realizar esa asociación entre archivos y expedientes, pero necesitamos algo más que los datos, ya que posteriormente deberemos trabajar de forma diferenciada y específica con el subgrupo de aquellos no-expedientes que no contienen no-Procesadores. Es por ello que generamos un script que, además de identificar de qué archivo se trata (según extensión), identifique el directorio en que se encuentra y lo traslade a un directorio específico.

El resultado de este proceso son dos directorios que contienen respectivamente 26 expedientes (que podemos identificar como expedientes fallidos por contener documentos no-Procesador) y 38 no-expedientes (que contienen archivos Procesador exclusivamente. Esto supone que el 43.75% de los directorios (inicialmente no-expedientes) de dos archivos no entran dentro del grupo predominante (90,9%) de los no-expdientes de un archivo. Evidentemente un cambio muy significativo que posiblemente requiera de un análisis complementario (¿qué son en realidad los archivos .pdf?, ¿se pueden considerar directamente como archivos-con-intención-de-conformar-expediente?); aunque no nos tiene que hacer olvidar que en este segundo subgrupo siguen predominando (cerca del 60%) los no-expedientes. ¿O no?

Esa es la tercera cuestión de interés por ser la que más nos hace avanzar en el procedimiento de análisis y su automatización. Para que nos situemos: inicialmente habíamos considerado que cierta agrupación de cierto tipo de archivos podría estar indicando indicios de pre-expedientes que no podríamos considerar expedientes fallidos (por carecer de determinada composición, la antes vista, en la que no constan los informes psicopedagógicos) pero tampoco como no-expediente por observarse interés por recopilar precisamente esos documentos (los informes psicopedagógicos), que no son suficientes pero sí necesarios para que un grupo de documentos pase a constituirse en expediente.

Para facilitar el desarrollo de este análisis antes diferenciamos físicamente dos subconjuntos de directorios y ahora trabajaremos sólo sobre uno de ellos: el que contiene únicamente directorios ("expedientes) con archivos .doc, .docx y .odt. Esta disparidad tipológica, aunque reducida, sigue siendo un inconveniente para automarizar el análisis, así que vamos a realizar una copia de ese directorio y, sobre ellas, aplicaremos un procedimiento para convertir todos los archivos en .docx.



#Bibliotecas necesarias ---------------------------------------------------

import os
import subprocess
import pathlib

#Función -------------------------------------------------------------------

def convertir_y_limpiar(ruta_absoluta):
    # Configuración de ruta
    RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe'
    
    if not os.path.exists(RUTA_LIBREOFFICE):
        print(f"Error: No se encontró LibreOffice en {RUTA_LIBREOFFICE}")
        return

    extensiones_a_convertir = {'.doc', '.odt'}
    
    if not os.path.exists(ruta_absoluta):
        print(f"Error: La ruta {ruta_absoluta} no existe.")
        return

    print(f"Procesando archivos en: {ruta_absoluta}...\n")
    
    for raiz, dirs, archivos in os.walk(ruta_absoluta):
        for nombre_archivo in archivos:
            ruta_original = pathlib.Path(os.path.join(raiz, nombre_archivo))
            ext = ruta_original.suffix.lower()

            # 1. Ignorar si ya es .docx
            if ext == '.docx':
                continue

            # 2. Procesar solo .doc y .odt
            if ext in extensiones_a_convertir:
                # Definimos cómo se llamaría el nuevo archivo
                ruta_nuevo_docx = ruta_original.with_suffix('.docx')
                
                print(f"Transformando: {nombre_archivo} -> {ruta_nuevo_docx.name}")
                
                try:
                    # Ejecutar conversión
                    resultado = subprocess.run([
                        RUTA_LIBREOFFICE, 
                        '--headless', 
                        '--convert-to', 'docx', 
                        '--outdir', str(ruta_original.parent),
                        str(ruta_original)
                    ], check=True, capture_output=True)

                    # 3. Verificación de seguridad: ¿Existe el nuevo archivo?
                    if ruta_nuevo_docx.exists():
                        # Eliminamos el original solo si el nuevo ya existe
                        os.remove(ruta_original)
                        print(f"   [OK] Convertido y eliminado original.")
                    else:
                        print(f"   [!] Error: No se encontró el archivo convertido {ruta_nuevo_docx.name}")

                except Exception as e:
                    print(f"   [X] Error procesando {nombre_archivo}: {e}")

    print("\n¡Limpieza y conversión completadas!")

#Script de aplicación de función ---------------------------------------------------------------------

if __name__ == "__main__":
    mi_ruta = "Mi_Ruta"
    convertir_y_limpiar(mi_ruta)


Mediante este script, y usando la utilidad de conversión de LibreOffice RUTA_LIBREOFFICE = r'C:\Program Files\LibreOffice\program\soffice.exe', accedemos a cada uno de los archivos de la ruta mi_ruta = "Mi_Ruta" que pasamos como parámetro a la función convertir_y_limpiar(mi_ruta), identificamos su extensión y convertimos a docx ruta_nuevo_docx = ruta_original.with_suffix('.docx'). Gracias a ello, a partir de este momento podemos aplicar la lógica de análisis, que se basa parcialmente en el uso de la biblioteca python-docx. Explico primero la lógica y después muestro y comento brevemente el script.

Después de analizar posibilidades opto por una combinación de procedimientos que puede resultar restrictiva, pero que evitará falsos positivos, lo que constituye el objetrivo prioritario en este momento: se trata de seleccionar aquellos no-expedientes que pudieran ser expedientes-incipientes, habiendo definido (en su momento) como tales aquellos en los que su creador recogió los documentos indispensables pero insuficientes para condicionar que un directorio o carpeta pueda ser considerado como expediente SEO cuando cuenta con el número mínimo de archivos. Todo esto, para directorios de dos archivos se concreta del siguiente modo: los dos archivos son informes psicopedagógicos.

La consecuencia que deriva del planteamiento anterior es que tenemos que identificar aquellos directorios que contienen dos archivos-procesador (ahora ambos .docx) cuyo contenido sea un informe psicopedagógico. Sólo en ese caso, sólo con esa combinación de contendio, un directorio pasa de la categoría de no-expedientes a la de expediente-incipiente; tercera categoría en que se pueden dividir los directorios-expedientes de dos archivos.

Para identificar estos directorios he pensado que sería útil analizar primero el nombre del archivo, ya que es común que los informes psicopedagógicos lleven la secuencia "inf" , "info" o "informe" en su nombre; pero esto no es definitorio, ya que otros documentos también llevan esa secuencia sin ser informes psicopedagógicos en sentido estricto. Por ello es necesario ir más allá del análisis del título y acceder al contenido del documento (para lo es que necesaria la biblioteca python-docx) y confirmar la presencia de cierto contenido que sea propio de un informe psicopedagógico.

Este segundo paso, que sólo se ejecuta si se cumple el primer criterio (lo que puede resultar demasiado restictivo) supone dar un salto muy importante en el acceso al contenido de un documento y debemos ser cautos y comedidos al darlo, ya que se abren muchas posibilidades y ahora sólo nos interesan unos mínimos, aunque claros y suficientes. Esa cautela implica que opte por identificar en el encabezado y/o título del documento una expresión que resulte prototípica de un informe psicopedagógico, como es precisamente ese misma secuencia de palabras. Como veremos, la prueba de esta fórmula resultó adecuada, pero fue necesario corregirla para evitar falsos negativos, ya que un número relativamente importante de informes tienen un título ligeramente diferente: Informe de Evaluación Psicopedagógica, por lo que fue necesario ampliar los referentes a buscar. Este es el script:



import os
import docx
import re
import unicodedata
from pathlib import Path

def normalizar(texto):
    """Elimina tildes y pasa a minúsculas para evitar fallos de coincidencia."""
    texto = unicodedata.normalize('NFD', texto)
    return "".join(c for c in texto if unicodedata.category(c) != 'Mn').lower()

def procesar_archivos(ruta_raiz):
    patrones_nombre = ['inf', 'info', 'informe']
    # Expresión regular: 'informe' seguido de cualquier cosa (.*?) y luego 'psicopedagogic'
    # El 'ic' al final cubre psicopedagógico, psicopedagógica, psicopedagógicos...
    patron_contenido = r"informe.*?psicopedagogic"
    
    conteo_por_carpeta = {}
    
    print(f"Buscando informes en: {ruta_raiz}\n")

    for raiz, dirs, archivos in os.walk(ruta_raiz):
        for nombre in archivos:
            if not nombre.lower().endswith('.docx'):
                continue
            
            # 1. Filtro por nombre de archivo
            if any(p in nombre.lower() for p in patrones_nombre):
                try:
                    doc = docx.Document(os.path.join(raiz, nombre))
                    # Unimos los primeros 10 párrafos (título/encabezado) en un solo bloque
                    texto_inicio = " ".join([p.text for p in doc.paragraphs[:10]])
                    texto_ready = normalizar(texto_inicio)
                    
                    # 2. Filtro por contenido (incluye 'de evaluación' automáticamente)
                    if re.search(patron_contenido, texto_ready):
                        print(f"[OK] ENCONTRADO: {nombre}")
                        if raiz not in conteo_por_carpeta:
                            conteo_por_carpeta[raiz] = []
                        conteo_por_carpeta[raiz].append(nombre)
                except:
                    print(f"[!] Error leyendo: {nombre}")

    # Escribir el informe TXT final
    ruta_txt = Path(__file__).parent / "carpetas_con_dos_informes.txt"
    with open(ruta_txt, "w", encoding="utf-8") as f:
        f.write("LISTADO DE CARPETAS CON EXACTAMENTE 2 INFORMES\n")
        f.write("="*50 + "\n")
        for carpeta, lista in conteo_por_carpeta.items():
            if len(lista) == 2:
                f.write(f"Directorio: {carpeta}\n")
                f.write(f"Archivos: {', '.join(lista)}\n\n")

    print(f"\nProceso finalizado. Informe guardado en: {ruta_txt}")

if __name__ == "__main__":
    ruta = input("Introduce la ruta absoluta de los archivos: ").strip().replace('"', '')
    procesar_archivos(ruta)


Primero aseguramos que se cumple el criterio 1: que el nombre del archivo indique que puede tratarse de un informe. Para ello analizamos si esos términos patrones_nombre = ['inf', 'info', 'informe'] forman parte del nombre del archivo...

... y condicionamos if any(p in nombre.lower() for p in patrones_nombre) el análisis de la segunda condición al cumplimiento de la primera. Esta segunda condición (que el texto contenga determinadas palabras) requiere de instrucciones más complejas...


# Unimos los primeros 10 párrafos (título/encabezado) en un solo bloque
texto_inicio = " ".join([p.text for p in doc.paragraphs[:10]])
texto_ready = normalizar(texto_inicio)

# 2. Filtro por contenido (incluye 'de evaluación' automáticamente)
if re.search(patron_contenido, texto_ready):

... lo que da muestra de la complejidad que conlleva el estudio del contenidos de un documento no estructurado; pero esta es otra cuestión: para lo que ahora nos intereresa, la solución encontrada es suficiente.

Suficiente y funcional, ya que nos permite localizar todos los archivos que contienen una expresión competible con que sean un informe psicopedagógico y confirmar que efectivamente lo son, o que no lo son. El resultado en términos de expedientes es el esperado: sólo cuatro de los 38 pueden ser considerados expedientes-incipientes; tres son identificados por el script de automatización y uno resulta del análisis visual de una coincidencia que indica el listado de los archivos potenciales candidatos a informe.

Aunque es posible que los datos no sean totalmenten correctos, principalmente por quedar pendiente el análisis de los archivos .pdf, el procedimiento seguido, apoyado en Python sí lo es y los resultados obtenidos se aproximan mucho a los esperados:

Concepto Valores Porcentajes
Directorios con dos archivos 64 100%
Expedientes fallidos 26 40.6%
No-expedientes 34 53.1%
Expedientes-incipientes 4 6.3

En cierto modo sorprende la importancia cuantitativa del grupo de expedientes fallidos (cerca del 40%), debido en gran medida al peso de los archivos .pdf, lo que sugiere que puede ser necesario analizarlos de forma específica (pero no en estos momentos), pero sí es ajustado a pronóstico la escasa incidencia de los expedientes incipientes (6% del total), lo que confirma como correcta la consideración inicial de este bloque de directorios como no-expedientes y su aislamiento (cuanto menos para un análisis diferencial) del conjunto de potenciales expedientes SEO. Toda esta coherencia se basa en el criterio cuantitativo: es necesario un mínimo para que un expedientes sea algo más que una colección de documentos; a partir de ese mínimo, además, deben cumplirse criterios de tipología y relevancia para que se confirmen como tales expedientes; pero ese mínimo es fundamental.

Precisamente en grupo de no-expdientes que nos falta por analizar es el que se encuentra en el límite del criterio cuantitativo. Esperemos a ver qué resulta de su análisis en una próxima entrada.

domingo, 22 de marzo de 2026

Expedientes

No-Expedientes (I)

Voy a llamar colecciones de archivos a este conjunto de carpetas que contienen archivos (documentos) de alumnos pero que no podemos considerar expedientes SEO. Las razones para ello fueron explicadas (al menos parcialmente) en esta entrada, pero posiblemente sólo en parte. Empezaré por completar esta cuestión.

Cuando un directorio contiene un número muy pequeño de archivos no es posible considerarlo de utilidad para facilitar la información necesaria para el seguimiento del alumno. Eso es lo que sucede obviamente cuando el directorio sólo contiene un archivo y este es el caso de un total de 99 directorios o carpetas inicialmente consideradas parte del conjunto de directorios de nuestra muestra.

Respecto a este amplio grupo de "carpetas" cabe decir que muy posiblemente su existencia no tiene nada que ver con ningún intento deliberado de generar algo parecido a un expediente SEO; posiblemente tan sólo está ahí casualmente o como mero recuerdo de que el ordenador es para el SEO, primero, una compleja máquina de escribir. Al menos así fue en un principio, momento en el que el procesador de texto se convirtió en el principal servicio y la creación de documentos con esa herramienta simplemente el sustituto de la máquina de escribir. Los documentos se imprimian en papel y algunos en vez de perderse quedaban almacenados, facilitando de paso un futuro uso a modo de "modelo-para-el siguiente".

Aunque lo anterior no sea ni del todo cierto ni la única o principal causa del fenómeno que acumulativamente se nos presenta ahora en forma de 99 carpetas con nombres de alumno, lo cierto es que su presencia abultada y peso mucho, distorsionando de forma importante los datos que resultan necesarios para nuestro análisis. Empezando por los estadisticos de centralidad, como la moda, que se ubica precisamente en este grupo, y la media, queda rebajada a un límite extremadamente próximo al mínimo de utilidad para el objetivo pretendido (recuerda, 3,68 archivos por expediente)

El segundo grupo que entraría dentro de este bloque es el formado por las carpetas que contienen dos archivos. Tampoco con dos archivos podemos garantizar un mínimo suficiente para el seguimiento del alumnado, aunque es cierto que en condiciones extraordinarias esa afirmación podría ser puesta en duda.

Podría darse el caso (siempre extraordinario) de que dos archivos condensen la información necesaria para que de su estudio pudiera derivarse información suficiente para el seguimiento del alumno, pero no lo sería con el tipo de documentos que se incluyen en estos expedientes, salvo que lo que deramos decir es que hacemos de la necesidad virtud y nos conformamos con lo que tenemos; pero no es esa la cuestión.

Podemos imaginar un teórico escenario en el que los dos archivos son dos informes psicopedagógicos entre los que han transcurrido algunos cursos (para que puedan aportar información diferenciada y de evolución), o un informe psicopedagógico del inicio de la intervención y un informe de seguimiento que se prolonga satisfactoriamente en el tiempo. Es cierto que en esos casos, idealmente, podríamos hablar de documentación suficiente para facilitar el seguimiento del alumno, pero sólo idealmente.

Puede que en estos extraordinarios casos nos encontráramos en el umbral de lo que es "suficiente", pero más en el plano teórico que en el real: empezando por que un informe psicopedagógico no tiene por objetivo recopilar información de seguimiento del alumno, sino identificar sus necesidades educativas y orientar la intervención, así que no es de esperar que disponer de dos informes (sin más) sea ni suficiente ni satisfactorio. Y de serlo no es posible considerarlo como causa para aceptar las carpetas de dos documentos como expedientes.

El segundo caso (informe + informe+de+seguimiento) es absolutamente extraordinario y es preciso profundizar en el contenido del segundo de esos dos documentos para aceptarlo como expediente. Desde luego no podemos hacerlo sólo por el título de los documentos, así que tampoco en este caso es aceptable como pasaporte para la consideración de expediente SEO.

Y lo que no se puede admitir sin análisis (en general incluso con análisis) como expediente, especialmente si su peso cuantitativo es relevante, debe ser eliminado de la muestra por las mismas razones que lo fueron las carpetas de un documento: para que no distrosiones la muestra. Este es, de nuevo, el caso de las carpetas de dos documentos: son muchas y afectan a la moda y a la media. Como ejemplo: si eliminamos de la muestra las carpetas con un documento, son las carpetas de dos documentos las que determinan la moda de la distribución.

Por último, tenemos las carpetas con tres archivos, el conjunto que mayores dudas suscita precisamente por encontrarse en el límite del mínimo suficiente. En este caso no me atrevo a decir que su descuento pueda ser una medida exenta de dudas, ya que vimos antes que existen casos en los que teóricamente dos archivos podrían constituir ese mínimo. Si añadimos un tercer archivo tenemos, idealmente, ese mínimo suficiente. Lo malo es que sólo en teoría, así que toca analizar carpeta (de tres) por carpeta para comprobar que efectivamente se cumple la condición mínima de dos informes psicopedagógicos + un informe-de-seguimiento. Pueden aceptarse otras combinaciones, pero es necesario un estudio que va más allá de la identificación del documento, cosa que en el momento actual del análisis no nos podemos permitir.

Así que, si bien en este caso partimos de la duda como planteamiento de base y debemos someter a cada una de las carpetas a un análisis de contenido para reubicarlas como expdientes SEO o mantenerlas al margen, inicialmente debemos considerarlas como parte de este bloque de no-suficientes-para-expedientes, ya que tampoco es aceptable su incorporación a la espera de expulsión: es preferible operar al contrario, que es como aquí se ha planteado el procedimiento.

La consecuencia de todo lo dicho hasta este momento es la diferenciación del conjunto muestral inicial en dos bloques: el de las carpetas-no-expdientes y el de los expedientes SEO. El primero está formado por los conjuntos directorio-de-un-archivo + directorio-de-dos-archivos y directorio-de-tres-archivos (pendiente de estudio del contenido). En el segundo estarán todos los directorios que cuentan al menos con cuatro archivos, no por estar garantizado que su contenido es ciertamente el apropiado para el seguimiento, sino porque de la cantidad de documentos de que constan es posible detraer información suficiente para generar datos de utilidad para el seguimiento. Y con eso, al menos inicialmente, nos debe ser suficiente para poder avanzar. Tiempo tendremos de matizar las cuestiones los suficiente para alcanzar consensos mínimos de lo que es una composición y una organización que satisface criterios para hacer de los expedientes SEO herramientas de trabajo útiles.

Y hablando de utilidad, antes de entrar en el análisis que justifica esta entrada, decir que la no pertenencia de determinados conjuntos de carpetas al colectivo expedientes SEO no implica que sus archivos sea irrelevantes para otros análisis. En tiempos en los que la documentación y los datos son un tesoro, ningún archivo es desdeñable. Pero su utilidad no lo es en tanto que expediente, sino en tanto que archivo, por su tipología, por los datos que contiene, por el motivo por el que se creo o por el momento en que se hizo... por la razón que sea, pero como archivo. Ese es otro nivel de análisis, no el que toca aquí y ahora

Empecemos por recordar que en la entrada anterior habíamos creado herramientas basadas en Python que nos permitieron construir un documento csv donde constaban los directorios y el número de archivos que contenía cada uno de ellos. Gracias a esta base de datos nos fue posible observar el peso de cada conjunto de directorios, incluyendo la identificación de aquellos que contenían de 1 a 3 archivos (los que aquí consideramos). Pudimos crear una gráfica de frecuencias (aun no explicamos cómo, ni todavía toca), la cual nos permitió visulizar el peso de las carpetas con menos archivos en nuestra muestra, con las consecuencias que antes expliqué: no podemos permitir que ese gran peso de las carpetas-no-expedientes distorsionen el análisis que nos planeamos realizar respecto al potencial de lo que denominamos expedientes SEO si es que queremos convertirlos realmente en una herramienta de trabajo.

Mediante el simple acceso al archivo csv desde LO-Calc ha sido posible dividir la base de datos original en dos bases de datos, la primera con las carpetas de las que ahora nos ocupamos, la segunda con los que consideramos expedientes SEO. Convertimos la primera en documento Calc y trabajamos esos datos para obtener la información cuantitativa básica que te muestro a continuación.

Gráfico y tabla describen la misma realidad en lo que a "carpetas" se refiere: el grupo de un archivo representa cerca de la mitad del total de carpetas y el de dos archivos cerca de un tercio del total; pero si lo analizamos en términos de archivos, se invierten los pesos. De momento nos vamos a centrar en el peso de las carpetas, ya que estamos hablando de "expedientes", no de su contenido.

Pero estos datos poco más nos pueden aportar, y nada de ello es relevante para el segundo objetivo de este primer análisis: identificar el contenido para conocer sus características y, en el caso de las carpetas con 3 archivos, ver cuales de ellas podrían ser consideradas expedientes SEO

Para ello necesitamos algo más que acceso a una base de datos csv, necesitamos acceso a las carpetas físicas para generar agrupaciones en función del número de archivos de que constan para poder listarlos de forma diferenciada. No nos salimos (aun) de los límites de los archivos como fuente de datos, pero tenemos que avanzar en esa línea si queremos conocer las características específicas de esos no-expedientes o aun-no-expdedientes. Y eso no es algo que podamos hacer mediante OOo Basic. Sí mediante Python



#Librerías necesarias ----------------------------------------------------------

from pathlib import Path
import shutil

#Función -------------------------------------------------------------------------

def procesar_directorios(ruta,archivos,directorio):

    base_path = Path(ruta)
    destino_path = base_path / directorio

    if not base_path.is_dir():	    # Validar que la ruta existe
        print(f"Error: La ruta {ruta} no es un directorio válido.")
        return

    if not destino_path.exists():	  # Crear directorio de destino si no existe
        destino_path.mkdir(parents=True)

# Analizar contenido del directorio

    for item in base_path.iterdir():
        if item.is_dir() and item.name != directorio:   # Solo procesamos carpetas y evitamos la propia carpeta de destino
            # Conteo en profundidad (recursivo). f.is_file() asegura que solo contamos archivos
            conteo = sum(1 for f in item.rglob('*') if f.is_file())
            if conteo == archivos:	  # Criterio de traslado
                print(f"Moviendo '{item.name}' ({conteo} archivos)...")
                try:
                    shutil.move(str(item), str(destino_path / item.name)) # Se mueve la carpeta completa con todo su contenido
                except Exception as e:
                    print(f"No se pudo mover {item.name}: {e}")

#Ejecución de la función------------------------------------------------------------

if __name__ == "__main__":

#Primero introduce los datos necesarios
    # 1. Introduce aquí la ruta completa de la carpeta a analizar
    ruta_origen = r'Aquí_Tu_ruta' 
    # 2. Número de archivos para que una carpeta sea movida
    num_archivos =  #Aquí_número_archivos
    # 3. Nombre de la carpeta donde se trasladarán los resultados
    destino = 'Aquí_Nombre_directorio_para_carpetas'

#Despues llama a la función
    procesar_directorios(ruta_origen,num_archivos,destino)

    print("Proceso completado.")


Mediante este script obtenemos los "expedientes" que contienen un determinado número de archivos. Por ejemplo, las carpetas con uno, dos o tres archivos (con independencia de que existan subdirectorios internos) y los trasladamos íntegramente a un directorio de destino, específico para ese grupo de "expedientes". La isntrucción if conteo == archivos: es fundamental para ello.

Debes tener en cuenta que los directorios de destino se crean en el mismo directorio donde se buscan las carpetas-expedientes, así que debes poner nombres de directoriosq que resulten fáciles de identificar y que expliciten el contenido, por ejemplo Dir_2_archivos.

Gracias a este script podemos disponer de diferentes directorios donde se guardan los no-expedientes (y los expedientes), diferenciándolos por le criterio número-de-archivos. Concretamente he creado cuatro (sub)directorios para los no-expedientes (uno, dos u tres archivos) y un (sub)directorio para los expedientes (4 y más archivos). De este modo me puedo centrar en el análisis de cada uno de los conjuntos de datos.

Empezando por el subconjunto no-expedientes de un archivo, me interesa obtener un listado de todos los archivos que contiene (no de los directorios, que ya está disponible) para conocer detalles relevantes de los mismos que puedan servir para realizar análisis pertinentes de los subconjuntos restantes, además de para conocer las características de los de este subconjunto.

Para obtener este listado ejecutaré el siguiente script:

 

#Librería -------------------------------------------------------------

from pathlib import Path

#Función ---------------------------------------------------------------

def listar_y_guardar_archivos(ruta_base, nombre_txt):
    """
    Lista archivos de forma recursiva, los muestra por pantalla y los guarda
    en el subdirectorio 'documentos' ya existente en la raíz del script.
    """
    # Definición de rutas según estructura solicitada
    directorio_script = Path(__file__).parent
    ruta_txt_salida = directorio_script / "documentos" / nombre_txt
    directorio_explorar = Path(ruta_base)

    # Validación de la ruta de origen
    if not directorio_explorar.exists() or not directorio_explorar.is_dir():
        print(f"Error: La ruta '{ruta_base}' no es válida.")
        return

    # Obtención de archivos en profundidad
    archivos = [archivo for archivo in directorio_explorar.rglob("*") if archivo.is_file()]

    if archivos:
        # Escritura en el archivo .txt dentro de /documentos/
        with open(ruta_txt_salida, "w", encoding="utf-8") as f:
            for a in archivos:
                nombre_solo = a.name
                print(nombre_solo)          # Mostrar solo nombre por pantalla
                f.write(nombre_solo + "\n") # Guardar solo nombre en el archivo
    else:
        print("No se encontraron archivos.")

#Script de llamada a la función -------------------------------------------------------------------------

if __name__ == "__main__":
    # Variables locales pasadas como parámetros
    ruta_a_explorar = "Aqui_Tu_Ruta"
    archivo_reporte = "Mi_Lista.txt"
    
    listar_y_guardar_archivos(ruta_a_explorar, archivo_reporte)


Gracias a este script podemos crear los listados de archivos (únicamente de archivos) que deseemos en función del directorio raiz (ruta) en que se encuentren los no-expedientes o los expedientes (o cualquier otra configuración de archivos). Esta lista se exporta como documento .txt y queda disponoble para un posterior manejo mediante procedimiento de acceso a documentos de datos no estructurados, aunque el formato de organización del contendio (como lista simple) facilita el acceso y el recorrido del citado .txt, lo que no evita que necesitemos recurrir a procedimientos específicos de acceso a este tipo de archivos, como es el caso.

Con fines didácticos más que por necesidad voy a proceder ahora como si de dos fases diferenciadas dentro del análisis se tratara y generaré un script para acceder al contenido del archivo-lista recien creado para ejecutar sobre ella un procedimiento simple de análisis que consiste en estudiar los tipos de archivos a fin de responder a una cuestión que ha estado en la base del análisis planteado hasta el momento respecto al colectivo documental de no-expdientes de un elemento: la ausencia de interés por generar un expediente SEO como motivación para la creación de estas carpetas.

En sentido estricto es esta una suposición difícil de comprobar ya que implica un conocimiento de intenciones del que no podemos disponer, pero sí podemos estudiarlo de forma indirecta en el siguiente supuesto (que ya se expuso en otro momento): son documentos que reflejan fundamentalmente el uso del medio informático como mero procesador-de-texto o máquina-de-escribir. De ser esto cierto, los documentos deben tener extensiones propias de estos servicios y la presencia de extensiones propias de servicios que implican cierto interés por el almacenamiento en formato pdf y más aun por documentos basados en hojas de cálculo deben estar ausentes o mínimamente representados. Veamos hasta qué punto se cumple esta hipótesis en el bloque de documentos en que con más facilidad y razón de ser se pruee cumplir.

Para ello crearemos un script que accede al listado, lo carga en memoria, analiza en cada línea (entrada o registro) la extensión del documento y realiza un recuento de tipología, lo expresa mediante una tabla simple y genera una gráfica de barras con esos datos.




#Bibliotecas ----------------------------------------------------------------------------------

import pandas as pd
import matplotlib.pyplot as plt
import os

#Función ---------------------------------------------------------------------------------------

def analizar_extensiones_txt(ruta_archivo):

# 1. Validación de existencia
    if not os.path.exists(ruta_archivo):
        print(f"⚠️ Error: No se localizó el archivo en: {ruta_archivo}")
        return pd.DataFrame(), None

    try:
        with open(ruta_archivo, 'r', encoding='utf-8') as f:
            lineas = f.read().splitlines()
        
# 2. Extraer extensiones
        extensiones = [os.path.splitext(doc.strip())[1].lower() or 'Sin extensión' 
                       for doc in lineas if doc.strip()]

        if not extensiones:
            print("pst... El archivo está vacío.")
            return pd.DataFrame(), None

# 3. Crear Tabla de Datos
        df = pd.DataFrame(extensiones, columns=['Extensión'])
        tabla_frecuencia = df['Extensión'].value_counts().reset_index()
        tabla_frecuencia.columns = ['Extensión', 'Frecuencia']

# 4. Configurar el gráfico (sin mostrarlo)
        fig, ax = plt.subplots(figsize=(10, 6))
        ax.bar(tabla_frecuencia['Extensión'], tabla_frecuencia['Frecuencia'], color='teal')
        ax.set_title('Análisis de Extensiones')
        ax.set_ylabel('Cantidad')
        
        return tabla_frecuencia, fig

    except Exception as e:
        print(f"Ocurrió un error inesperado: {e}")
        return pd.DataFrame(), None

# --- Script ----------------------------------------------------------------------------------------------------------------

mi_ruta_archivo = r"C:/Users/alons/proyectos_python/proyecto_expedientes/documentos/listado_uno.txt"

# Ejecución con validación
resultado_tabla, grafico = analizar_extensiones_txt(mi_ruta_archivo)

if not resultado_tabla.empty:
    print("### TABLA DE RESULTADOS ###")
    print(resultado_tabla)
    if grafico:
        plt.show()
else:
    print("No se pudieron generar resultados. Revisa la ruta o el contenido del archivo.")


Este gráfico muestra la distribución de la tipología de archivos. Predominan claramente los archivos .doc, creados con el procesador de textos Word. Además tienen cierta antigüedad, ya que las versiones actuales de Word tienen .docx por extensión. Este dato confirma nuestra hipótesis, que se ve reforzada por el sumatorio que resulta de añadir a ese grupo los documentos de las extensiones .docx (3 archivos) y .odt (5 archivos); esto hace un total de 90 archivos, el 90,9% del total.

Tipo de archivos Número Porcentaje
Documento de texto 90 90,9%
PDF 5 5%
Hoja de cálculo 4 4%
Esta tabla recoge ese sumatorio y facilita la observación de que el resto (9 archivos) se dividen casi a partes iguales entre .pdf (5 archivos) y otros 4 sobre hojas de cálculo. Estos 9 archivos (el 9,1% del total) podemos considerarlos como contrarios a la hipótesis inicial, lo que no impide que ésta se confirme, pero la matiza: el 9% de los no-expedientes de un archivo se pueden considerar expedientes fallidos, aunque el 90% encaja perfectamente con la interpretación desarrollada respecto a considerar los 99 directorios que constan de un único archivo como mera supervivencia de las formas iniciales de uso de los medios informáticos por parte de los SEO, que generaban y mantenian expdientes SEO analógicos, aunque se servían de los medios informáticos a modo de "máquina de escribir", sin más pretensión.

Finalizo aquí esta primera parte del análisis de los no-expedientes, en la que me he centrado en el estudio del primer subconjunto de este bloque. Para su desarrollo he empleado funciones de LO-Calc, aunque predominaron los script basados en Python

En una próxima entrada trabajaré con los directorios de dos archivos, ya que interesa observar en ese grupo el peso de determinadas configuraciones de archivos en relación a la distinción observada en este primer bloque (entre no-expedientes y expedientes fallidos), pero también para apreciar la incidencia de un tipo concreto de archivos (documentos): los informes psicopedagógicos.