# utils/html_parser.py import os import re import hashlib from pathlib import Path from bs4 import BeautifulSoup from urllib.parse import urlparse, unquote import requests import shutil import html2text # Añadir esta librería from models.pagina_html import PaginaHTML def procesar_html(ruta_archivo, dir_adjuntos): """ Procesa un archivo HTML y lo convierte a Markdown, descargando las imágenes a la carpeta de adjuntos. """ # Lista de encodings para intentar encodings = ["utf-8", "latin-1", "iso-8859-1", "windows-1252", "cp1252"] # Lista para almacenar las imágenes procesadas imagenes_procesadas = [] for encoding in encodings: try: with open(ruta_archivo, "r", encoding=encoding) as f: contenido = f.read() soup = BeautifulSoup(contenido, "html.parser") # Extraer título titulo = None if soup.title: titulo = soup.title.string elif soup.find("h1"): titulo = soup.find("h1").get_text() # Procesar imágenes: descargarlas y actualizar rutas # Obtener la lista de imágenes procesadas imagenes_procesadas = procesar_imagenes(soup, dir_adjuntos, ruta_archivo) # Procesar tablas procesar_tablas(soup) # Convertir HTML a Markdown markdown = html_a_markdown(soup) # Crear la página HTML con la lista de imágenes return PaginaHTML( ruta_archivo=ruta_archivo, titulo=titulo, contenido=markdown, imagenes=imagenes_procesadas ) except UnicodeDecodeError: # Si falla la codificación, probar con la siguiente continue except Exception as e: print( f"Error procesando archivo HTML {ruta_archivo} con encoding {encoding}: {str(e)}" ) # Continuar con el siguiente encoding si es un error de codificación if isinstance(e, UnicodeError): continue # Para otros errores, devolver página con error return PaginaHTML( ruta_archivo=ruta_archivo, contenido=f"Error al procesar: {str(e)}" ) # Si todos los encodings fallaron return PaginaHTML( ruta_archivo=ruta_archivo, contenido=f"Error al procesar: No se pudo decodificar el archivo con ninguna codificación compatible", ) def procesar_imagenes(soup, dir_adjuntos, ruta_archivo_html): """ Procesa todas las imágenes en el HTML, descargándolas al directorio de adjuntos y actualizando las rutas. """ # Crear directorio si no existe os.makedirs(dir_adjuntos, exist_ok=True) # Directorio base del archivo HTML para resolver rutas relativas dir_base = os.path.dirname(os.path.abspath(ruta_archivo_html)) # Nombre del directorio de adjuntos (solo el nombre, no la ruta completa) adjuntos_nombre = os.path.basename(dir_adjuntos) print(f"Procesando imágenes desde {ruta_archivo_html}") print(f"Directorio base: {dir_base}") print(f"Directorio adjuntos: {dir_adjuntos}") # Lista para recopilar nombres de archivos de imagen procesados lista_imagenes = [] # Contador de imágenes procesadas num_imagenes_procesadas = 0 imagenes_no_encontradas = 0 imagenes_con_error = 0 # Procesar imágenes estándar HTML for img in soup.find_all("img"): src = img.get("src") if not src: continue try: print(f"Procesando imagen HTML: {src}") # Determinar si es URL o ruta local if src.startswith(("http://", "https://")): # Es una URL remota nombre_archivo = os.path.basename(urlparse(src).path) nombre_archivo = unquote(nombre_archivo) # Limpiar nombre de archivo nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) if not nombre_archivo or nombre_archivo == "_": nombre_archivo = ( f"image_{hashlib.md5(src.encode()).hexdigest()[:8]}.jpg" ) # Generar ruta para guardar ruta_img = os.path.join(dir_adjuntos, nombre_archivo) # Descargar imagen try: response = requests.get(src, stream=True, timeout=10) if response.status_code == 200: with open(ruta_img, "wb") as f: response.raw.decode_content = True shutil.copyfileobj(response.raw, f) # Actualizar atributo src con solo el nombre del archivo para Obsidian # y preservar dimensiones originales img["src"] = nombre_archivo # Conservar atributos de dimensiones si existen if not img.get("width") and not img.get("height"): img["width"] = "auto" # Valor por defecto para mantener proporción # Añadir a la lista de imágenes procesadas lista_imagenes.append(nombre_archivo) except requests.RequestException as e: print(f"Error descargando imagen {src}: {str(e)}") else: # Es una ruta local - resolver relativa al archivo HTML ruta_img_original = os.path.normpath(os.path.join(dir_base, src)) if os.path.exists(ruta_img_original): # Copiar imagen a adjuntos nombre_archivo = os.path.basename(ruta_img_original) # Limpiar nombre de archivo nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) ruta_img_destino = os.path.join(dir_adjuntos, nombre_archivo) shutil.copy2(ruta_img_original, ruta_img_destino) # Actualizar atributo src con solo el nombre del archivo para Obsidian img["src"] = nombre_archivo # Conservar atributos de dimensiones si existen if not img.get("width") and not img.get("height"): img["width"] = "auto" # Valor por defecto para mantener proporción # Añadir a la lista de imágenes procesadas lista_imagenes.append(nombre_archivo) else: print(f"Imagen no encontrada: {ruta_img_original}") imagenes_no_encontradas += 1 # Actualizar contador num_imagenes_procesadas += 1 except Exception as e: imagenes_con_error += 1 print(f"Error procesando imagen HTML {src}: {str(e)}") # Procesar imágenes VML (comunes en documentos convertidos desde MS Office) for img_data in soup.find_all("v:imagedata"): src = img_data.get("src") if not src: continue try: print(f"Procesando imagen VML: {src}") # Es una ruta local - resolver relativa al archivo HTML # Decodificar URL encoding (como espacios %20) src_decoded = unquote(src) ruta_img_original = os.path.normpath(os.path.join(dir_base, src_decoded)) print(f"Buscando imagen en: {ruta_img_original}") if os.path.exists(ruta_img_original): # Copiar imagen a adjuntos nombre_archivo = os.path.basename(ruta_img_original) # Limpiar nombre de archivo nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) ruta_img_destino = os.path.join(dir_adjuntos, nombre_archivo) print(f"Copiando imagen de {ruta_img_original} a {ruta_img_destino}") shutil.copy2(ruta_img_original, ruta_img_destino) # Actualizar atributo src en el tag VML con solo el nombre del archivo img_data["src"] = nombre_archivo # Añadir a la lista de imágenes procesadas lista_imagenes.append(nombre_archivo) # También crear un tag img estándar para asegurar que aparezca en Markdown parent = img_data.find_parent("v:shape") if parent: # Obtener dimensiones si están disponibles width = "" height = "" style = parent.get("style", "") if style: width_match = re.search(r"width:(\d+\.?\d*)pt", style) height_match = re.search(r"height:(\d+\.?\d*)pt", style) if width_match: width = f' width="{float(width_match.group(1))/12:.0f}"' if height_match: height = f' height="{float(height_match.group(1))/12:.0f}"' # Crear tag img y colocarlo después del shape (solo con nombre de archivo) img_tag = soup.new_tag("img", src=nombre_archivo) if width: img_tag["width"] = width.strip() if height: img_tag["height"] = height.strip() parent.insert_after(img_tag) num_imagenes_procesadas += 1 else: imagenes_no_encontradas += 1 print(f"⚠️ Imagen VML no encontrada: {ruta_img_original}") # Buscar en subdirectorios comunes para archivos Office posibles_dirs = [ os.path.join(dir_base, os.path.dirname(src_decoded)), os.path.join( dir_base, os.path.splitext(os.path.basename(ruta_archivo_html))[0] + "_archivos", ), os.path.join( dir_base, os.path.splitext(os.path.basename(ruta_archivo_html))[0] + "_files", ), os.path.join(dir_base, "image"), ] found = False for posible_dir in posibles_dirs: posible_ruta = os.path.join( posible_dir, os.path.basename(src_decoded) ) print(f"Intentando ruta alternativa: {posible_ruta}") if os.path.exists(posible_ruta): nombre_archivo = os.path.basename(posible_ruta) nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) ruta_img_destino = os.path.join(dir_adjuntos, nombre_archivo) print( f"✅ Imagen encontrada en ruta alternativa. Copiando a {ruta_img_destino}" ) shutil.copy2(posible_ruta, ruta_img_destino) # Actualizar src con solo el nombre del archivo img_data["src"] = nombre_archivo # Añadir a la lista de imágenes procesadas lista_imagenes.append(nombre_archivo) # Crear tag img estándar parent = img_data.find_parent("v:shape") if parent: img_tag = soup.new_tag("img", src=nombre_archivo) # Asegurarse de que img_tag esté fuera del contexto VML para que # pueda ser encontrado por los selectores normales if parent.parent: parent.parent.append(img_tag) else: soup.body.append(img_tag) num_imagenes_procesadas += 1 imagenes_no_encontradas -= ( 1 # Restamos porque ya no es una imagen no encontrada ) found = True break if not found: print( f"❌ No se pudo encontrar la imagen VML en ningún directorio alternativo" ) except Exception as e: imagenes_con_error += 1 print(f"Error procesando imagen VML {src}: {str(e)}") # Buscar otras posibles fuentes de imágenes (específicas de Office) for shape in soup.find_all("v:shape"): # Algunos documentos Office utilizan v:shape sin v:imagedata if not shape.find("v:imagedata") and "style" in shape.attrs: style = shape["style"] # Buscar referencias a imágenes en estilo img_refs = re.findall(r'url\(["\']?([^"\']+)["\']?\)', style) for img_ref in img_refs: try: print(f"Procesando imagen de estilo v:shape: {img_ref}") src_decoded = unquote(img_ref) # Buscar la imagen en rutas relativas ruta_img_original = os.path.normpath( os.path.join(dir_base, src_decoded) ) # Buscar también en directorios comunes de Office if not os.path.exists(ruta_img_original): for posible_dir in posibles_dirs: posible_ruta = os.path.join( posible_dir, os.path.basename(src_decoded) ) if os.path.exists(posible_ruta): ruta_img_original = posible_ruta break if os.path.exists(ruta_img_original): # Copiar imagen a adjuntos nombre_archivo = os.path.basename(ruta_img_original) nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) ruta_img_destino = os.path.join(dir_adjuntos, nombre_archivo) shutil.copy2(ruta_img_original, ruta_img_destino) # Añadir a la lista de imágenes procesadas lista_imagenes.append(nombre_archivo) # Crear un tag de imagen para referencia en Markdown img_tag = soup.new_tag("img", src=nombre_archivo) shape.insert_after(img_tag) num_imagenes_procesadas += 1 except Exception as e: print(f"Error procesando imagen de estilo: {str(e)}") # Mostrar resumen print(f"\nResumen de procesamiento de imágenes:") print(f"- Imágenes procesadas con éxito: {num_imagenes_procesadas}") print(f"- Imágenes no encontradas: {imagenes_no_encontradas}") print(f"- Imágenes con error de procesamiento: {imagenes_con_error}") print( f"- Total de imágenes encontradas en HTML: {num_imagenes_procesadas + imagenes_no_encontradas + imagenes_con_error}" ) print(f"- Nombres de archivos de imágenes procesadas: {lista_imagenes}") # Devolver la lista de imágenes procesadas return lista_imagenes def procesar_tablas(soup): """ Procesa las tablas HTML para prepararlas para conversión a Markdown. """ for table in soup.find_all("table"): # Agregar clase para que se convierta correctamente table["class"] = table.get("class", []) + ["md-table"] # Asegurar que todas las filas tienen el mismo número de celdas rows = table.find_all("tr") if not rows: continue # Encontrar la fila con más celdas max_cols = 0 for row in rows: cols = len(row.find_all(["td", "th"])) max_cols = max(max_cols, cols) # Asegurar que todas las filas tienen max_cols celdas for row in rows: cells = row.find_all(["td", "th"]) missing = max_cols - len(cells) if missing > 0: for _ in range(missing): if cells and cells[0].name == "th": new_cell = soup.new_tag("th") else: new_cell = soup.new_tag("td") new_cell.string = "" row.append(new_cell) def html_a_markdown(soup): """ Convierte HTML a texto Markdown utilizando html2text para una mejor conversión. """ # Guardar una copia del HTML para debugging debug_html = str(soup) # Reemplazar