# 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 from models.pagina_html import PaginaHTML # Importar pypandoc para la conversión mejorada try: import pypandoc PANDOC_AVAILABLE = True except ImportError: print("⚠️ Advertencia: pypandoc no está instalado. Se utilizará html2text como fallback.") import html2text PANDOC_AVAILABLE = False 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. Esta función mantiene la misma firma que la versión original para garantizar compatibilidad con el flujo existente. """ # 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) # Preprocesar elementos específicos de Word preprocesar_elementos_word(soup) # Convertir HTML a Markdown if PANDOC_AVAILABLE: # Usar Pandoc para una mejor conversión (especialmente tablas) markdown = html_a_markdown_pandoc(str(soup), dir_adjuntos) else: # Fallback a html2text si Pandoc no está disponible markdown = html_a_markdown_html2text(soup) # Post-procesar el Markdown para formato Obsidian markdown = post_procesar_markdown(markdown, imagenes_procesadas) # 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 preprocesar_elementos_word(soup): """ Preprocesa elementos específicos de Word para mejorar la conversión. """ # Eliminar metadatos y scripts for tag in soup.select('style, script, meta, link'): tag.decompose() # Intentar limpiar elementos Office específicos for tag in soup.find_all(['o:p']): if tag.string: # Preservar solo el texto tag.replace_with(soup.new_string(tag.get_text())) else: tag.decompose() # Convertir elementos VML a elementos HTML estándar for shape in soup.find_all(['v:shape', 'v:imagedata']): # Extraer cualquier imagen dentro del shape img_tags = shape.find_all('img') if img_tags: for img in img_tags: # Mover la imagen fuera del shape if shape.parent: shape.parent.insert_before(img) # Extraer cualquier texto significativo text_content = shape.get_text().strip() if text_content and shape.parent: text_node = soup.new_tag('p') text_node.string = text_content shape.parent.insert_before(text_node) def html_a_markdown_pandoc(html_content, dir_adjuntos): """ Convierte HTML a Markdown usando Pandoc. Pandoc maneja mucho mejor tablas, listas y otros elementos complejos. """ try: # Guardar HTML a un archivo temporal temp_html_path = os.path.join(dir_adjuntos, "temp_conversion.html") with open(temp_html_path, "w", encoding="utf-8") as f: f.write(html_content) # Convertir usando pypandoc (interfaz para Pandoc) # Opciones de Pandoc para mejor manejo de tablas extra_args = [ '--wrap=none', # No wrap lines '--extract-media=' + dir_adjuntos, # Extract media to the specified directory '--standalone' # Process as a standalone document ] # Usar GitHub Flavored Markdown para mejor soporte de tablas markdown = pypandoc.convert_file( temp_html_path, 'gfm', # GitHub Flavored Markdown extra_args=extra_args ) # Limpiar archivo temporal if os.path.exists(temp_html_path): os.remove(temp_html_path) return markdown except Exception as e: print(f"Error en conversión con Pandoc: {str(e)}") # Fallback al método antiguo si Pandoc falla if os.path.exists(temp_html_path): with open(temp_html_path, "r", encoding="utf-8") as f: soup = BeautifulSoup(f.read(), "html.parser") os.remove(temp_html_path) return html_a_markdown_html2text(soup) else: return f"Error en conversión: {str(e)}" def html_a_markdown_html2text(soup): """ Método de fallback: convierte HTML a texto Markdown utilizando html2text. """ # Configurar el conversor html2text h2t = html2text.HTML2Text() h2t.body_width = 0 # No limitar el ancho del cuerpo del texto h2t.ignore_links = False h2t.ignore_images = False h2t.ignore_emphasis = False h2t.ignore_tables = False h2t.bypass_tables = False h2t.mark_code = True h2t.unicode_snob = True # Procesar tablas para prepararlas para conversión for table in soup.find_all("table"): # Agregar clase para mejor reconocimiento table["class"] = table.get("class", []) + ["md-table"] # Verificar si la tabla tiene encabezados rows = table.find_all("tr") if rows and not table.find("th"): # Convertir primera fila a encabezados si no hay th first_row_cells = rows[0].find_all("td") for cell in first_row_cells: new_th = soup.new_tag("th") new_th.string = cell.get_text().strip() cell.replace_with(new_th) # Convertir a Markdown html_content = str(soup) markdown_content = h2t.handle(html_content) return markdown_content def post_procesar_markdown(markdown, imagenes_procesadas): """ Post-procesa el Markdown para adaptarlo a formato Obsidian y corregir problemas comunes de conversión. """ # 1. Convertir referencias de imágenes al formato Obsidian for imagen in imagenes_procesadas: # Buscar referencias de imágenes en formato estándar # y convertirlas al formato Obsidian markdown = re.sub( r'!\[(.*?)\]\((.*?' + re.escape(imagen) + r')\)', f'![[{imagen}]]', markdown ) # 2. Limpiar líneas excesivas markdown = re.sub(r'\n{3,}', '\n\n', markdown) # 3. Arreglar listas mal formadas markdown = re.sub(r'(^|\n)([*\-])([^\s])', r'\1\2 \3', markdown, flags=re.MULTILINE) # 4. Mejorar formato de tablas # Asegurar que hay línea en blanco antes y después de tablas markdown = re.sub(r'([^\n])\n(\|[^\n]+\|)', r'\1\n\n\2', markdown) markdown = re.sub(r'(\|[^\n]+\|)\n([^\n\|])', r'\1\n\n\2', markdown) # 5. Eliminar caracteres de control markdown = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', markdown) # 6. Normalizar líneas horizontales markdown = re.sub(r'(\*{3,}|\-{3,}|_{3,})', '---', markdown) return markdown 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. Versión mejorada con manejo simplificado de rutas y mejor detección de imágenes en documentos Word. """ # Crear directorio si no existe os.makedirs(dir_adjuntos, exist_ok=True) # Directorio base del archivo HTML dir_base = os.path.dirname(os.path.abspath(ruta_archivo_html)) # Lista para almacenar imágenes procesadas imagenes_procesadas = [] # Estadísticas stats = { "procesadas": 0, "no_encontradas": 0, "con_error": 0 } # Función auxiliar para procesar una imagen def procesar_imagen(src, img_tag=None): if not src or src.startswith("data:"): return None try: print(f"Procesando imagen: {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" # Descargar imagen ruta_img = os.path.join(dir_adjuntos, nombre_archivo) 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 src en el tag HTML if img_tag: img_tag["src"] = nombre_archivo return nombre_archivo except Exception as e: print(f"Error descargando imagen {src}: {str(e)}") stats["con_error"] += 1 return None else: # Es una ruta local - intentar múltiples ubicaciones posibles src_decoded = unquote(src) posibles_rutas = [ os.path.join(dir_base, src_decoded), # Ruta completa os.path.join(dir_base, os.path.basename(src_decoded)), # Solo el nombre # Rutas comunes para archivos Word os.path.join( dir_base, os.path.splitext(os.path.basename(ruta_archivo_html))[0] + "_archivos", os.path.basename(src_decoded) ), os.path.join( dir_base, os.path.splitext(os.path.basename(ruta_archivo_html))[0] + "_files", os.path.basename(src_decoded) ), os.path.join(dir_base, "image", os.path.basename(src_decoded)) ] # Probar cada ruta posible for ruta in posibles_rutas: if os.path.exists(ruta): # Copiar imagen a adjuntos nombre_archivo = os.path.basename(ruta) nombre_archivo = re.sub(r'[<>:"/\\|?*]', "_", nombre_archivo) ruta_img_destino = os.path.join(dir_adjuntos, nombre_archivo) shutil.copy2(ruta, ruta_img_destino) # Actualizar src en el tag HTML if img_tag: img_tag["src"] = nombre_archivo return nombre_archivo print(f"Imagen no encontrada en ninguna ruta: {src}") stats["no_encontradas"] += 1 return None except Exception as e: print(f"Error procesando imagen {src}: {str(e)}") stats["con_error"] += 1 return None # 1. Procesar imágenes normales for img in soup.find_all("img"): src = img.get("src") nombre_archivo = procesar_imagen(src, img) if nombre_archivo and nombre_archivo not in imagenes_procesadas: imagenes_procesadas.append(nombre_archivo) stats["procesadas"] += 1 # 2. Procesar imágenes VML (Office) for img_data in soup.find_all("v:imagedata"): src = img_data.get("src") nombre_archivo = procesar_imagen(src) if nombre_archivo and nombre_archivo not in imagenes_procesadas: imagenes_procesadas.append(nombre_archivo) stats["procesadas"] += 1 # Crear un tag img estándar para asegurar que aparezca en Markdown new_img = soup.new_tag("img", src=nombre_archivo) parent = img_data.find_parent() if parent: parent.insert_after(new_img) # 3. Procesar referencias de imágenes en estilos style_tags = soup.find_all(["style", lambda tag: tag.has_attr("style")]) for tag in style_tags: style_content = tag.get("style", "") if tag.name != "style" else tag.string or "" img_refs = re.findall(r'url\(["\']?([^"\']+)["\']?\)', style_content) for img_ref in img_refs: nombre_archivo = procesar_imagen(img_ref) if nombre_archivo and nombre_archivo not in imagenes_procesadas: imagenes_procesadas.append(nombre_archivo) stats["procesadas"] += 1 # Mostrar resumen print(f"\nResumen de procesamiento de imágenes:") print(f"- Imágenes procesadas con éxito: {stats['procesadas']}") print(f"- Imágenes no encontradas: {stats['no_encontradas']}") print(f"- Imágenes con error de procesamiento: {stats['con_error']}") return imagenes_procesadas