ParamManagerScripts/backend/script_groups/ImportHTML/utils/html_parser.py

391 lines
15 KiB
Python

# 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