diff --git a/backend/script_groups/ImportHTML/README.md b/backend/script_groups/ImportHTML/README.md new file mode 100644 index 0000000..4378a25 --- /dev/null +++ b/backend/script_groups/ImportHTML/README.md @@ -0,0 +1,12 @@ +# HTML to Markdown Conversion Tool + +This script processes HTML files and converts them to Markdown format, extracting images and preserving the document structure. + +## Dependencies + +The script requires the following Python libraries: +- beautifulsoup4 +- requests +- html2text + +Install dependencies using: diff --git a/backend/script_groups/ImportHTML/data.json b/backend/script_groups/ImportHTML/data.json new file mode 100644 index 0000000..c3d5dbf --- /dev/null +++ b/backend/script_groups/ImportHTML/data.json @@ -0,0 +1,4 @@ +{ + "attachments_dir": "adjuntos", + "output_file": "contenidoImportado.md" +} \ No newline at end of file diff --git a/backend/script_groups/ImportHTML/description.json b/backend/script_groups/ImportHTML/description.json new file mode 100644 index 0000000..d991533 --- /dev/null +++ b/backend/script_groups/ImportHTML/description.json @@ -0,0 +1,6 @@ +{ + "name": "Importación de Archivos HTML", + "description": "Este script procesa archivos HTML en un directorio y los convierte en un único archivo Markdown, extrayendo las imágenes a una carpeta de adjuntos y manteniendo los enlaces. También genera un índice al principio del archivo.", + "version": "1.0", + "author": "Miguel" +} diff --git a/backend/script_groups/ImportHTML/esquema_group.json b/backend/script_groups/ImportHTML/esquema_group.json new file mode 100644 index 0000000..d120e1c --- /dev/null +++ b/backend/script_groups/ImportHTML/esquema_group.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "attachments_dir": { + "type": "string", + "title": "Directorio de adjuntos", + "description": "adjuntos" + }, + "output_file": { + "type": "string", + "title": "Nombre del archivo de salida", + "description": "contenido.md" + } + } +} diff --git a/backend/script_groups/ImportHTML/esquema_work.json b/backend/script_groups/ImportHTML/esquema_work.json new file mode 100644 index 0000000..67e14e4 --- /dev/null +++ b/backend/script_groups/ImportHTML/esquema_work.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "output_directory": { + "type": "string", + "format": "directory", + "title": "Directorio donde escribir el archivo de salida", + "description": "Lugar para el archivo de salida markdown" + } + } +} diff --git a/backend/script_groups/ImportHTML/models/__pycache__/pagina_html.cpython-310.pyc b/backend/script_groups/ImportHTML/models/__pycache__/pagina_html.cpython-310.pyc new file mode 100644 index 0000000..03f75d6 Binary files /dev/null and b/backend/script_groups/ImportHTML/models/__pycache__/pagina_html.cpython-310.pyc differ diff --git a/backend/script_groups/ImportHTML/models/pagina_html.py b/backend/script_groups/ImportHTML/models/pagina_html.py new file mode 100644 index 0000000..31bfdbe --- /dev/null +++ b/backend/script_groups/ImportHTML/models/pagina_html.py @@ -0,0 +1,74 @@ +# models/pagina_html.py +import os +import re +import hashlib +from pathlib import Path + + +class PaginaHTML: + def __init__(self, ruta_archivo, titulo=None, contenido=None, imagenes=None): + self.ruta_archivo = ruta_archivo + self.nombre_archivo = Path(ruta_archivo).name + self.titulo = titulo or self._extraer_titulo_de_ruta() + self.contenido = contenido or "" + self.hash = self._generar_hash() + self.imagenes = imagenes or [] # Lista de imágenes asociadas a esta página + + def _extraer_titulo_de_ruta(self): + """Extrae el título a partir del nombre del archivo.""" + nombre = Path(self.ruta_archivo).stem + # Limpia el nombre: reemplaza guiones y underscores por espacios + nombre = re.sub(r"[_-]", " ", nombre) + # Capitaliza cada palabra + nombre = " ".join(word.capitalize() for word in nombre.split()) + return nombre + + def _generar_hash(self): + """Genera un hash único para la página.""" + elementos_hash = [ + self.nombre_archivo, + self.titulo, + ( + self.contenido[:500] if self.contenido else "" + ), # Usar solo los primeros 500 caracteres + ] + + texto_hash = "|".join(elementos_hash) + return hashlib.md5(texto_hash.encode()).hexdigest() + + def to_markdown(self): + """Convierte la página a formato Markdown.""" + # Hash como comentario al principio + hash_line = f"\n\n" + + # Título como encabezado + titulo_line = f"## {self.titulo}\n\n" + + # Nombre del archivo original como referencia + origen_line = f"*Origen: {self.nombre_archivo}*\n\n" + + # Contenido + md = f"{hash_line}{titulo_line}{origen_line}" + + # Limpiar cualquier artefacto de asteriscos extra antes de agregar el contenido + contenido_limpio = re.sub(r"(\*{3,}|\-{3,}|_{3,})", "---", self.contenido) + md += contenido_limpio + "\n\n" + + # Ya no se agregan imágenes al final - se asume que están en el contenido + + md += "---\n\n" + + return md + + def get_index_entry(self): + """Genera una entrada para el índice.""" + # Formatear el título para el enlace de Markdown + slug = re.sub(r"[^\w\s-]", "", self.titulo.lower()) + slug = re.sub(r"\s+", "-", slug) + + return f"- [{self.titulo}](#{slug})" + + def add_imagen(self, imagen): + """Añade una referencia a una imagen asociada a esta página.""" + if imagen not in self.imagenes: + self.imagenes.append(imagen) diff --git a/backend/script_groups/ImportHTML/requirements.txt b/backend/script_groups/ImportHTML/requirements.txt new file mode 100644 index 0000000..9d1ade5 --- /dev/null +++ b/backend/script_groups/ImportHTML/requirements.txt @@ -0,0 +1,3 @@ +beautifulsoup4==4.10.0 +html2text==2020.1.16 +requests==2.28.1 diff --git a/backend/script_groups/ImportHTML/utils/__pycache__/html_parser.cpython-310.pyc b/backend/script_groups/ImportHTML/utils/__pycache__/html_parser.cpython-310.pyc new file mode 100644 index 0000000..2fd27fc Binary files /dev/null and b/backend/script_groups/ImportHTML/utils/__pycache__/html_parser.cpython-310.pyc differ diff --git a/backend/script_groups/ImportHTML/utils/__pycache__/markdown_handler.cpython-310.pyc b/backend/script_groups/ImportHTML/utils/__pycache__/markdown_handler.cpython-310.pyc new file mode 100644 index 0000000..012e8b8 Binary files /dev/null and b/backend/script_groups/ImportHTML/utils/__pycache__/markdown_handler.cpython-310.pyc differ diff --git a/backend/script_groups/ImportHTML/utils/html_parser.py b/backend/script_groups/ImportHTML/utils/html_parser.py new file mode 100644 index 0000000..d6597c0 --- /dev/null +++ b/backend/script_groups/ImportHTML/utils/html_parser.py @@ -0,0 +1,550 @@ +# 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