From 456c5cc1c7b6a19ef11b6c7b55f90a768b3024f4 Mon Sep 17 00:00:00 2001 From: Miguel Date: Sat, 5 Apr 2025 11:58:44 +0200 Subject: [PATCH] Add HTML to Markdown conversion tool with necessary configurations and dependencies --- backend/script_groups/ImportHTML/README.md | 12 + backend/script_groups/ImportHTML/data.json | 4 + .../script_groups/ImportHTML/description.json | 6 + .../ImportHTML/esquema_group.json | 15 + .../ImportHTML/esquema_work.json | 11 + .../__pycache__/pagina_html.cpython-310.pyc | Bin 0 -> 2520 bytes .../ImportHTML/models/pagina_html.py | 74 +++ .../script_groups/ImportHTML/requirements.txt | 3 + .../__pycache__/html_parser.cpython-310.pyc | Bin 0 -> 10131 bytes .../markdown_handler.cpython-310.pyc | Bin 0 -> 1083 bytes .../ImportHTML/utils/html_parser.py | 550 ++++++++++++++++++ .../ImportHTML/utils/markdown_handler.py | 36 ++ .../script_groups/ImportHTML/work_dir.json | 8 + backend/script_groups/ImportHTML/x1.py | 102 ++++ data/log.txt | 325 ++++++++++- 15 files changed, 1138 insertions(+), 8 deletions(-) create mode 100644 backend/script_groups/ImportHTML/README.md create mode 100644 backend/script_groups/ImportHTML/data.json create mode 100644 backend/script_groups/ImportHTML/description.json create mode 100644 backend/script_groups/ImportHTML/esquema_group.json create mode 100644 backend/script_groups/ImportHTML/esquema_work.json create mode 100644 backend/script_groups/ImportHTML/models/__pycache__/pagina_html.cpython-310.pyc create mode 100644 backend/script_groups/ImportHTML/models/pagina_html.py create mode 100644 backend/script_groups/ImportHTML/requirements.txt create mode 100644 backend/script_groups/ImportHTML/utils/__pycache__/html_parser.cpython-310.pyc create mode 100644 backend/script_groups/ImportHTML/utils/__pycache__/markdown_handler.cpython-310.pyc create mode 100644 backend/script_groups/ImportHTML/utils/html_parser.py create mode 100644 backend/script_groups/ImportHTML/utils/markdown_handler.py create mode 100644 backend/script_groups/ImportHTML/work_dir.json create mode 100644 backend/script_groups/ImportHTML/x1.py 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 0000000000000000000000000000000000000000..03f75d6ed2d6bd1743de1c83246e4fe162e38cad GIT binary patch literal 2520 zcmai0&2QX96rZujyIyCr4gE|j6`4kYy09w*Do7;?9|aX8nno((u-#xycBUJrUfUge zo76ZGsO7|;P*vI-IrM&CKuj-W#X$^B#fc zms@{~euUa@sLVb#C?CO>zXw7PL1WUdOWLPKxB3>8R&0-1pHXsy2wSknM6gFz-x2gS z37p?yE?EtzW^26I9HKV+9Ideet&*T!u-OroupiStSQE};(r2O}nov5z6)h+m!V_~) zHbq;^L+P5ePH1n9kD`Qs{?*MdSBK8bI#{>hyAEIe35beTBqRl`=p(viLEWybtW%LG z+asVkm7SduIHmz@P1EL9EW0?u-j*P6G;39pz|(CtDR{`$a5H+4DqKXfgpZ}}B4)8HB6I2CXq<>d^nPcR9uCdJm1vJNH-dDY|aBm zvs}h^6&_2qP#}|P9>h;Xoq*!6P+S|Vsq~>77HK}XJycOvYd>+u*}{ zG7*E^j0s07on-moi*c5!0+AeyQz7GgkQr>l&0-w)vWMCY!zhW0FvRuqHi!mo(+>6E z?a=bnb1Hg=BZm}59X^N@?%z;?9##OfTZWEZTG&%2sIV%!XYWEeb!yM5=(Mp%J|&On z9^D1<(8DJ0z~s*iw7!h}V*eSU>vKQjs)&>ioA+xfO!a`NUdir+-E~d<|Lh8VGRJQb z#P_hP^(L-0C41y+Ldkvbd03aCQ+q?6MFo$eQU;c`dT(pCoho4{sKo-<(5+?UZ6{MJ z=g=+#VSRU&lvig#dbxM>mg98D<9wxeWFu%AH#6*mBNTG1EhV*`Pc}5$N~1(Oc@{@S zV5ttQ)NjM~OuKV4Wuk z74`_}WK$c6rMp(arcPxcrF#^k%+UEd7`b7*-=8EtdcME^YZ47pB$WFx5WByJVcuJf z{sfOw2&bl1;HoCnX5!%jb~MpK9BpWOEUxN?Oorgjy0s~HL^P6l5!f|K9U059OdwiJ z%=Ntj2uV%-IC67f7i}A_XEvi}E%vwPo}}gCEPAsc`~qeTn0tiT1}KwI2EfL!OoHQ$u6syXw@a)>P;@g zLf6yeK_pcnXJCX#xtppnFH-*|SNBA^o%Bj)`L%A>FB^WhyW)AJy|m<)7F1eSZYh8* zx#pMl70}OI9$fkM-M4nDLDv)^%AmBn-EQfC@Vua*Fget;7=kdY)Oi$`wV>a4X$yG@ z<0z5+xq}2*E2&w~^k{1=`U^*C657UDlpdo;&jD@YgOj`gE94l19UI>Tc-z!v%qq{$ zP&G5*1)?xVAz9baN}Z#o)Z`_gWNL%k0$l}xHn^z{a@7%CNR?cfJ{H5AyE1@#@kZsldpE- zbX%&!aA$cu86C&u#7Gv9uZ7$RaqWj6;)(ERr|k|sZIu^h&^R`9h$tLBSjUhzh`b6F zyhyjm1FGh+oI;-5qZAzaw)p~U@&OF`c>iZEB!n~fmAoqz1YyK|ZW`)K&F6UvZ2$@2 zkry*D4#&cKO*eR!!8LVw%0@l!_&Gf;L|Av-fLZ2;#3PWpVCwKuXbUKAlVS3Hc#a-f2 z*L7R&yt~+TO<&N^R+`s*UW6gKnZaKYWUD&E\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 0000000000000000000000000000000000000000..2fd27fccd9acf602dfd12a0384a464e2db3e0281 GIT binary patch literal 10131 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 literal 0 HcmV?d00001 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
con un marcador específico para evitar que se conviertan en asteriscos + for hr in soup.find_all(['hr']): + hr_tag = soup.new_tag('div') + hr_tag.string = "HORIZONTAL_RULE_MARKER" + hr.replace_with(hr_tag) + + # Pre-procesar tablas para asegurar su correcta conversión + for table in soup.find_all("table"): + # Asegurar que la tabla tiene una clase específica para mejor reconocimiento + table["class"] = table.get("class", []) + ["md-table"] + + # Verificar si la tabla tiene encabezados + has_headers = False + rows = table.find_all("tr") + if rows: + first_row_cells = rows[0].find_all(["th"]) + has_headers = len(first_row_cells) > 0 + + # Si no tiene encabezados pero tiene filas, convertir la primera fila en encabezados + if not has_headers and len(rows) > 0: + 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) + + # 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 # Importante para mantener tablas + h2t.bypass_tables = False # No saltarse las tablas + h2t.mark_code = True + h2t.unicode_snob = True # Preservar caracteres Unicode + h2t.open_quote = '"' + h2t.close_quote = '"' + h2t.use_automatic_links = True # Mejorar manejo de enlaces + + # Personalizar el manejo de imágenes para formato Obsidian + def custom_image_formatter(src, alt, title): + # Obtener solo el nombre del archivo + filename = os.path.basename(src) + # Retornar el formato Obsidian + return f"![[{filename}]]" + + h2t.images_with_size = True # Intentar mantener información de tamaño + h2t.image_link_formatter = custom_image_formatter + + # Preprocesamiento: asegurarse de que las imágenes tienen los atributos correctos + for img in soup.find_all("img"): + src = img.get("src", "") + # Asegurarnos de usar el nombre del archivo como src + img["src"] = os.path.basename(src) + + # Eliminar scripts y estilos + for element in soup(["script", "style"]): + element.decompose() + + # Eliminar elementos VML que ya han sido procesados + for element in soup.find_all(["v:shape", "v:imagedata", "o:p"]): + element.decompose() + + # Convertir a Markdown usando html2text + html_content = str(soup) + markdown_content = h2t.handle(html_content) + + # Post-procesamiento: convertir cualquier sintaxis de imagen estándar que quede al formato Obsidian + markdown_content = re.sub(r"!\[(.*?)\]\((.*?)\)", lambda m: f"![[{os.path.basename(m.group(2))}]]", markdown_content) + + # Solución alternativa para tablas si html2text falla: buscar tablas directamente en el HTML + if "(.*?)', re.DOTALL) + tables = table_pattern.findall(html_content) + + for i, table_html in enumerate(tables): + soup_table = BeautifulSoup(f"{table_html}
", "html.parser") + markdown_table = [] + + rows = soup_table.find_all("tr") + if rows: + # Procesar encabezados + headers = rows[0].find_all(["th", "td"]) + if headers: + header_row = "|" + for header in headers: + header_row += f" {header.get_text().strip()} |" + markdown_table.append(header_row) + + # Separador + separator = "|" + for _ in headers: + separator += " --- |" + markdown_table.append(separator) + + # Procesar filas de datos + for row in rows[1:] if headers else rows: + cells = row.find_all(["td", "th"]) + if cells: + data_row = "|" + for cell in cells: + data_row += f" {cell.get_text().strip()} |" + markdown_table.append(data_row) + + if markdown_table: + table_md = "\n" + "\n".join(markdown_table) + "\n\n" + # Agregar la tabla al markdown con un marcador único + marker = f"TABLE_MARKER_{i}" + markdown_content += f"\n\n{marker}\n\n" + # Guardar la tabla para reemplazar después + markdown_content = markdown_content.replace(marker, table_md) + + # Limpiar artefactos de conversión + + # 1. Reemplazar múltiples asteriscos o guiones (que representen líneas horizontales) con una línea estándar + markdown_content = re.sub(r"(\*{3,}|\-{3,}|_{3,})", "---", markdown_content) + + # 2. Reemplazar el marcador de línea horizontal + markdown_content = markdown_content.replace("HORIZONTAL_RULE_MARKER", "---") + + # 3. Eliminar líneas que solo contengan espacios y asteriscos + markdown_content = re.sub(r"^\s*\*+\s*$", "", markdown_content, flags=re.MULTILINE) + + # 4. Eliminar los asteriscos que estén solos en una línea (común en conversiones) + markdown_content = re.sub(r"^(\s*)\*(\s*)$", "", markdown_content, flags=re.MULTILINE) + + # 5. Reemplazar múltiples líneas horizontales consecutivas con una sola + markdown_content = re.sub(r"(---\s*){2,}", "---\n", markdown_content) + + # 6. Limpieza adicional para eliminar asteriscos o guiones consecutivos que no son líneas horizontales + markdown_content = re.sub(r"(?