551 lines
23 KiB
Python
551 lines
23 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
|
|
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 <hr> 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 "<table" in html_content and "| " not in markdown_content:
|
|
print("Detectada tabla pero no se convirtió correctamente. Aplicando conversión manual...")
|
|
# Volver a procesar tablas manualmente
|
|
table_pattern = re.compile(r'<table.*?>(.*?)</table>', re.DOTALL)
|
|
tables = table_pattern.findall(html_content)
|
|
|
|
for i, table_html in enumerate(tables):
|
|
soup_table = BeautifulSoup(f"<table>{table_html}</table>", "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"(?<!\n)(\*{2,}|\-{2,})(?!\n)", " ", markdown_content)
|
|
|
|
# 7. Arreglar formato de tablas mal formadas (líneas que empiezan con | pero no son tablas completas)
|
|
markdown_content = re.sub(r"^\|[^\n]*\|[^\n]*$", "", markdown_content, flags=re.MULTILINE)
|
|
|
|
# Contar cuántas referencias de imagen hay en el Markdown generado
|
|
obsidian_refs = re.findall(r"!\[\[(.*?)\]\]", markdown_content)
|
|
print(f"Referencias de imagen en Markdown: {len(obsidian_refs)}")
|
|
if obsidian_refs:
|
|
print(f"Referencias: {', '.join(obsidian_refs)}")
|
|
|
|
# Guardar información de debugging
|
|
try:
|
|
with open("debug_html.txt", "w", encoding="utf-8") as f:
|
|
f.write(debug_html)
|
|
with open("debug_markdown.txt", "w", encoding="utf-8") as f:
|
|
f.write(markdown_content)
|
|
print("Archivos de debug guardados")
|
|
except Exception as e:
|
|
print(f"Error guardando archivos de debug: {str(e)}")
|
|
|
|
return markdown_content.strip()
|