391 lines
15 KiB
Python
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 |