Archivos de texto
Acceso a contenidos específicos
No tengo muy claro si esta entrada debe situarse en esta subsección (Acceso a datos) o si debería ubicarse en la que sigue (Limpieza de datos), entendida en sentido amplio, pero posiblemente esto no es lo más importante. Lo que interesa es el concepto que se trata en ella y lo que implica. Antes de decir algo más al respecto, es necesario advertir que, en cualquier caso, ahora sólo trataremos estas cuestiones en términos muy básicos y limitados, ya que será necesario retomar el tema cuando hablemos de organización y limpieza de los datos no estructurados y, como no puede ser de otro modo, también en la subsección Uso de datos.
En las entradas anteriores hemos estado tratando cuestiones relativas al acceso a los datos de los textos como un todo, diferenciando si este contenido se encuentra ubicado en tablas o si conforma párrafos más o menos extensos. Esta diferención lo es también de datos vs. textos, o si se preferie (y yo sí lo prefiero) entre datos semi-estructurados frente a datos no estructurados. En sentido estricto, ningun documento contiene datos estructurados, así que lo más parecido a éstos son los datos semi-estructurados que encontramos en las tablas y en las tablas-formulario de los documentos. Pero ahora nos hemos alejado de estos entornos y nos hemos enfrentado al texto en sentido estricto, lo que equivale a decir a los datos no estructurados; y ante estos el mero acceso (y "captura") sólo puede ser una primera (y muy necesaria) fase de un proceso considerablemente complejo que requiere varios subprocesos. Lo que ahora abordamos sería un intento de concretar una opción de posible segunda fase.
Y es que de poco sirve capturar un texto si no obtenemos información de él. Pasa lo mismo cuando hablamos del tratamiento analógico de los textos, pero es mucho más definitorio (y difícil de llevar a cabo) cuando el tratamiento es digital. Podemos concretarlo de muchas formas, así que la que ahora voy a proponer es sólo una de ellas, y no precisamente de las de mayor complejidad.
En el marco del acceso al contenido de los textos de una colección de informes psicopedagógicos, deseamos identificar si en ellos se hace alguna mención al uso del test (batería) WISC. Esto implica interés por saber si este recurso se ha empleado, con qué peso cuantitativo en la muestra documental y qué información se asocia con ese uso.
El objetivo simple, identificar uso vs. no-uso del WISC, no parece ofrecer mayor dificultad, pero acceder al contenido textual asociado a esta etiqueta conlleva cierto nivel de complejidad, que nos proponemos abordar de la forma más directa y simple posible, lo que no garantiza que sea la mejor, aunque según cual sea nuestro objetivo puede ser suficiente. Sí lo es para el objetivo que nos planteamos en esta entrada.
import os
import csv
from odf.opendocument import load
from odf import text
# --- Función secundaria 1: Extracción lineal de párrafos ignorando tablas. ---
def extraer_parrafos_no_tablas(ruta_archivo):
try:
doc = load(ruta_archivo)
parrafos_limpios = []
for p in doc.getElementsByType(text.P):
parent = p.parentNode # Filtro de tablas por ancestros
dentro_de_tabla = False
while parent is not None:
if parent.tagName == "table:table":
dentro_de_tabla = True
break
parent = parent.parentNode
if not dentro_de_tabla:
contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3) # Unimos todos los nodos de texto
if contenido.strip():
parrafos_limpios.append(contenido.strip())
return parrafos_limpios
except Exception as e:
print(f" [!] Error de lectura en {os.path.basename(ruta_archivo)}: {e}")
return []
# --- Función secundaria 2: Captura el párrafo del hallazgo y los N siguientes para asegurar el contexto. . ---
def extraer_segmento_contiguo(lista_parrafos, termino, num_posteriores=4):
segmentos_completos = []
indices_ya_incluidos = set()
for i, texto in enumerate(lista_parrafos):
if termino.lower() in texto.lower() and i not in indices_ya_incluidos:
rango_fin = min(i + num_posteriores + 1, len(lista_parrafos)) # Iniciamos bloque
bloque = lista_parrafos[i:rango_fin]
for j in range(i, rango_fin): # Marcamos para no repetir si el término aparece en los párrafos de este bloque
indices_ya_incluidos.add(j)
segmentos_completos.append("\n\n".join(bloque))
return segmentos_completos
# --- Función principal ---
def procesar_informes(dir_entrada, csv_salida, palabra_clave):
if not os.path.exists(dir_entrada):
print(f"La ruta {dir_entrada} no existe.")
return
archivos = [f for f in os.listdir(dir_entrada) if f.lower().endswith('.odt')]
resultados = []
print(f"Procesando {len(archivos)} archivos...\n")
for nombre in archivos:
ruta = os.path.join(dir_entrada, nombre)
parrafos = extraer_parrafos_no_tablas(ruta) # Llamada a función secundaria 1
hallazgos = extraer_segmento_contiguo(parrafos, palabra_clave, num_posteriores=4) # Llamada a función secundaria 2
estado = "PRESENTE" if hallazgos else "AUSENTE"
print(f"[{estado}] {nombre}")
resultados.append({
'Archivo': nombre,
'Hallazgo': estado,
'Texto': " |SECCIÓN SIGUIENTE| ".join(hallazgos) if hallazgos else "N/A"
})
try:
with open(csv_salida, mode='w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['Archivo', 'Hallazgo', 'Texto'])
writer.writeheader()
writer.writerows(resultados)
print(f"\nCSV generado con éxito en: {csv_salida}")
except Exception as e:
print(f"Error al escribir CSV: {e}")
# --- Llamada a función ---
if __name__ == "__main__":
RUTA_ARCHIVOS = r"ruta_de_directiro_de_documentos" # Ruta de acceso a la colección de documento
RUTA_CSV = r"ruta_destino_y_nombre_archivo.csv" # Ruta para ubicación de csv e identificador del archivo
TERMINO = "WISC" # Término objeto de estudio
procesar_informes(RUTA_ARCHIVOS, RUTA_CSV, TERMINO)
Lo que hacemos con este script es, en primer lugar prescindir de las tablas del documento y de los datos que éstas contienen para centrarnos en la extracción de los textos (párrafos). Dentro de ellos, buscamos la aparición del término-diana (aquí WISC) y lo relacionamos con su contexto (texto inmediatamente próximo al término-diana) para capturar el contenido que se asocia al mismo- Esto nos permite acceder no sólo a información sobre la presencia vs. ausencia del término-diana en el documento, sino también al contenido asociado a dicho término, lo que supone una mayor complejidad del procedmiento de automatización, pero también que el script es capaz de facilitar información que incrementa significativamente nuestra capacidad de análisis de los documentos. No estamos en condiciones para asegurar que esto sea suficiente para objetivos complejos, pero sí para muchos otros más simples. En todo caso sólo estamos ante un procedimiento básico. En otras subsecciones trataremos cómo desarrollar otras.
Presento también el primer script de una serie de tres (el anterior es el tercero) como punto de referencia para comprender la línea de desarrollo que se pretende explictar y que se concreta fundamentalmente como incremento del contexto textual que rodea al término-diana.
import os
import csv
from odf.opendocument import load
from odf import text
#--- Función secundaria. Filtar tablas ---
def extraer_parrafos_no_tablas(ruta_archivo):
try:
doc = load(ruta_archivo)
parrafos_validos = []
for p in doc.getElementsByType(text.P):
dentro_de_tabla = False
parent = p.parentNode
while parent is not None:
if parent.tagName == "table:table":
dentro_de_tabla = True
break
parent = parent.parentNode
if not dentro_de_tabla:
contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3)
if contenido.strip():
parrafos_validos.append(contenido.strip())
return parrafos_validos
except Exception as e:
print(f"Error al leer el archivo {ruta_archivo}: {e}")
return []
# --- Función principal ---
def procesar_coleccion_odt(directorio_entrada, ruta_csv, termino):
print(f"Buscando en: {directorio_entrada}...")
if not os.path.exists(directorio_entrada):
print(f"ERROR: La carpeta no existe: {directorio_entrada}")
return
archivos = [f for f in os.listdir(directorio_entrada) if f.lower().endswith('.odt')]
print(f"Se han encontrado {len(archivos)} archivos .odt")
if len(archivos) == 0:
print("No hay nada que procesar. Revisa la extensión de los archivos.")
return
resultados = []
for nombre_archivo in archivos:
ruta_completa = os.path.join(directorio_entrada, nombre_archivo)
parrafos = extraer_parrafos_no_tablas(ruta_completa)
hallazgos = [p for p in parrafos if termino.lower() in p.lower()]
presencia = "PRESENTE" if hallazgos else "AUSENTE"
print(f"Procesado: {nombre_archivo} -> {presencia}")
resultados.append({
'Nombre del Archivo': nombre_archivo,
'Presencia Termino': presencia,
'Contenido Extraído': " | ".join(hallazgos) if hallazgos else "N/A"
})
try:
with open(ruta_csv, mode='w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['Nombre del Archivo', 'Presencia Termino', 'Contenido Extraído'])
writer.writeheader()
writer.writerows(resultados)
print(f"\nÉXITO: Archivo creado en {ruta_csv}")
except Exception as e:
print(f"ERROR al crear el CSV: {e}")
# --- LLAMADA AL SCRIPT ---
if __name__ == "__main__":
RUTA_DOCS = r"ruta_archivos_a_procesar" # Definición de variables de configuración
RUTA_OUT = r"ruta_del_archivo.csv"
OBJETIVO = "WISC"
procesar_coleccion_odt(RUTA_DOCS, RUTA_OUT, OBJETIVO)
Para comprender lo que conseguimios con estos archivos, nada mejor que un breve estudio de resultados, aunque sin ninguna pretensión de representatividad; tan sólo a nivel de indicio. Analizo a continuación los 14 primeros archivos de una colección significativamente más amplia, pero que ahora no interesa ya que en esta entrada no pretendo realizar ningún estudio (cosa que puede quedar pendiente para otro momento).
De estos 14 archivos, sólo en tres se identifica el término-diana (WISC), lo que ahora es prácticamente irrelevante, pero que dejaría de serlo en un estudio orientado a valorar el grado de uso de un determinado recurso como es la escala WISC en sus diferentes versiones. Lo que sí es relevante ahora es comprender las diferencias que se aprecian en términos de recuperación de la información asociada al término-diana entre el primero y el tercero de los script que se desarrollaron para logar esta meta. De paso, es necesario destacar que existe una diferencia importante entre este modelo de trabajo y lo que sería la mera identificación de la presencia del término. Veamos esa evolución:
Vemos que existen notables diferencias entre los tres archivo, las cuales se deben fundamentalmente al peso real de los datos asociados al término-diana en cada uno de los documento (otra parte se puede entender asociada al mayor o menos éxito del procedimiento de obtención de datos); pero lo que resulta más importante es la evolución entre el primer y el tercer script, que en este gráfico se presenta en términos absolutos (número de palabras por segmento textual y versión del script.
Si lo expresamos en términos porcentuales, algunos datos pueden contribuir a la mejor comprensión del resultado:
- Salvo en el primer documento, en los otros dos el incremento obedece al esquema [Tv1 menor que Tv2 menor que Tv3]. En ese primer documento, se aprecia una ligera disminución del número de palabras, pero también es ese documento el que mayor incremento presenta entre el Tv1 y Tv2 (concretamente algo más del 466%)
- Esto quiere decir que el script que mejores resultados ofrece (como cabe esperar) el tercero (salvo para el primer documento, que es el segundo script).
- La ganancia que se obtiene con este tercer script respecto al primero es evidente: más del 400% en el documento de menos impacto, pero por encima del 700% en los otros dos, concretamente el 788% en uno y algo más del 900% en el otro.
Dado que no se trata de un estudio con intención de ser representativo, estos datos no se deben extrapolar, pero son suficientemente reveladores que lo que interesa mostrar aquí: podemos desarrollar procedimientos que facilitan el acceso selectivo a datos relativos a determinado término, aunque faltan datos que permitan identificar con propiedad la calidad de estos resultados. También esta importante cuestión queda para un análisis específico y para el desarrollo de otros procedimientos alternativos.