#!/usr/bin/env python3
"""
Blogger to Gemtext Converter
Descarga todo un sitio de Blogger y lo convierte a formato Gemtext
Usa solo librerías estándar de Python
"""
import xml.etree.ElementTree as ET
import html
import urllib.request
import urllib.parse
import urllib.error
import os
import re
import sys
import time
import subprocess
from datetime import datetime
from html.parser import HTMLParser
def normalize_hashtag(tag):
"""Convertir hashtags a una sola palabra en minúsculas sin espacios ni caracteres especiales"""
if not tag.strip():
return ""
# Convertir a minúsculas
tag = tag.lower().strip()
# Reemplazar tildes y caracteres especiales
replacements = {
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ú': 'u',
'ñ': 'n', 'ü': 'u',
'à': 'a', 'è': 'e', 'ì': 'i', 'ò': 'o', 'ù': 'u',
'â': 'a', 'ê': 'e', 'î': 'i', 'ô': 'o', 'û': 'u'
}
for old, new in replacements.items():
tag = tag.replace(old, new)
# Eliminar TODOS los caracteres que no sean letras o números
normalized = re.sub(r'[^a-z0-9]', '', tag)
return normalized
class HTMLToGemtextParser(HTMLParser):
"""Parser para convertir HTML a Gemtext correctamente"""
def __init__(self):
super().__init__()
self.lines = []
self.current_text = ""
self.in_anchor = False
self.anchor_href = ""
self.anchor_text = ""
self.downloaded_images = []
self.current_tag = ""
self.ignore_until_tag = None
self.in_list_item = False
self.list_item_has_link = False
self.list_item_text = ""
self.video_urls = []
self.last_was_heading = False
self.last_was_list = False
self.consecutive_br = 0
self.pending_links = [] # Almacenar enlaces para poner después del párrafo
self.in_paragraph = False # Para saber si estamos en un párrafo normal
def set_downloaded_images(self, images):
"""Establecer la lista de imágenes descargadas"""
self.downloaded_images = images
def _extract_youtube_video(self, tag, attrs):
"""Extraer URLs de video de YouTube"""
video_url = None
for attr, value in attrs:
if attr in ['src', 'movie', 'data', 'value']:
if 'youtube.com' in value or 'youtu.be' in value:
video_url = value
break
if video_url:
# Convertir URL embed a URL normal de YouTube
if '/v/' in video_url:
video_id = video_url.split('/v/')[1].split('&')[0].split('?')[0]
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
self.video_urls.append(youtube_url)
elif 'youtube.com/embed/' in video_url:
video_id = video_url.split('/embed/')[1].split('&')[0].split('?')[0]
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
self.video_urls.append(youtube_url)
def _extract_blogger_video(self, tag, attrs):
"""Extraer videos de Blogger/YouTube por contentid"""
content_id = None
for attr, value in attrs:
if attr == 'contentid':
content_id = value
break
elif attr == 'class' and 'BLOG_video_class' in value:
# Es un objeto de video de Blogger
for attr2, value2 in attrs:
if attr2 == 'contentid':
content_id = value2
break
if content_id:
# Crear URL de video de Blogger/YouTube aproximada
youtube_url = f"https://www.youtube.com/watch?v={content_id[:11]}"
self.video_urls.append(youtube_url)
return True
return False
def handle_starttag(self, tag, attrs):
# Spans no deben hacer flush (son inline)
if tag not in ['br', 'img', 'span']:
self._flush_text()
self.current_tag = tag
attrs_dict = dict(attrs)
# Si estamos ignorando contenido, solo procesar tags de cierre
if self.ignore_until_tag:
return
# Extraer videos de YouTube
if tag in ['object', 'embed', 'param']:
if not self._extract_blogger_video(tag, attrs):
self._extract_youtube_video(tag, attrs)
# Ignorar completamente estos tags y su contenido
if tag in ['script', 'style']:
self.ignore_until_tag = tag
return
if tag == 'a':
self.in_anchor = True
self.anchor_href = attrs_dict.get('href', '')
self.anchor_text = ""
if self.in_list_item:
self.list_item_has_link = True
elif tag == 'img':
# Buscar src en los atributos
img_src = None
img_alt = ""
for attr, value in attrs:
if attr == 'src':
img_src = value
elif attr == 'alt':
img_alt = value
if img_src and img_src.startswith('http'):
# Buscar si esta imagen fue descargada
local_name = None
success = False
for img_url, safe_name, downloaded in self.downloaded_images:
if img_url == img_src or img_src in img_url:
local_name = safe_name
success = downloaded
break
if local_name and success:
display_text = img_alt if img_alt else 'Imagen'
self.lines.append(f"=> ../img/{local_name} {display_text}")
else:
# Usar placeholder para imagen fallida
self.lines.append("=> ../img/placeholder.png Imagen no disponible")
elif tag in ['li']:
self.in_list_item = True
self.list_item_has_link = False
self.list_item_text = ""
elif tag in ['ul', 'ol']:
# Añadir línea en blanco antes de listas si no hay ya una
if self.lines and self.lines[-1] != "":
self.lines.append("")
self.last_was_list = True
elif tag == 'br':
# CORRECCIÓN: Procesar br inmediatamente
self.consecutive_br += 1
if self.consecutive_br >= 2:
# Dos o más br consecutivos = nuevo párrafo
self._flush_text()
if self.lines and self.lines[-1] != "":
self.lines.append("")
else:
# Un solo br = espacio simple
self.current_text += " "
elif tag in ['p', 'div', 'section', 'article']:
self._flush_text()
self.in_paragraph = True
# Añadir línea en blanco antes de párrafos si no hay ya una
if self.lines and self.lines[-1] != "" and not self.last_was_heading:
self.lines.append("")
elif tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
self._flush_text()
# Añadir línea en blanco antes de encabezados si no hay ya una
if self.lines and self.lines[-1] != "":
self.lines.append("")
self.last_was_heading = True
def handle_endtag(self, tag):
# Reset contador de br para cualquier endtag excepto br
if tag != 'br':
self.consecutive_br = 0
# Si encontramos el tag de cierre del que estábamos ignorando
if self.ignore_until_tag and tag == self.ignore_until_tag:
self.ignore_until_tag = None
self.current_tag = ""
return
# Si estamos ignorando contenido, no procesar otros tags
if self.ignore_until_tag:
return
if tag == 'a' and self.in_anchor:
self.in_anchor = False
if self.anchor_href and self.anchor_text.strip():
clean_text = self._clean_text(self.anchor_text)
if clean_text:
if self.in_list_item:
# En listas: comportamiento normal (texto en la lista)
self.list_item_text += clean_text + " "
self.list_item_has_link = True
else:
# CORRECCIÓN: En párrafos normales - NO añadir texto al párrafo
# Solo guardar el enlace para poner después
self.pending_links.append((self.anchor_href, clean_text))
self.anchor_text = ""
self.anchor_href = ""
elif tag in ['li']:
self.in_list_item = False
if self.list_item_text.strip():
clean_text = self._clean_text(self.list_item_text).strip()
if clean_text:
if self.list_item_has_link:
# Lista de enlaces: usar formato =>
self.lines.append(f"=> {self.anchor_href} {clean_text}")
else:
# Lista normal: usar formato *
self.lines.append(f"* {clean_text}")
self.list_item_text = ""
self.list_item_has_link = False
elif tag in ['p', 'div', 'section', 'article']:
self._flush_text()
self.lines.append("") # Línea en blanco después del párrafo
self.in_paragraph = False
self.last_was_heading = False
elif tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
self._flush_text()
self.lines.append("")
self.last_was_heading = True
elif tag in ['ul', 'ol']:
# Añadir línea en blanco después de listas
if self.lines and self.lines[-1] != "":
self.lines.append("")
self.last_was_list = False
self.last_was_heading = False
elif tag == 'span':
# Los spans son inline, no hacer flush del texto
pass
elif tag == 'br':
# CORRECCIÓN: Ya se procesó en starttag, no hacer nada aquí
pass
self.current_tag = ""
def handle_data(self, data):
# Ignorar datos si estamos ignorando contenido
if self.ignore_until_tag:
return
# No ignorar datos dentro de object/param/embed completamente
if self.current_tag in ['script', 'style']:
return
if self.in_anchor:
self.anchor_text += data
elif self.in_list_item:
self.list_item_text += data
else:
clean_data = self._clean_text(data)
if clean_data:
self.current_text += clean_data + " "
def _clean_text(self, text):
"""Limpiar texto de HTML, JavaScript y código residual"""
if not text.strip():
return ""
# Decodificar entidades HTML
text = html.unescape(text)
# Remover código JavaScript y HTML residual
text = re.sub(r'<\/?script[^&]*>', '', text)
text = re.sub(r'<\/?script[^>]*>', '', text)
text = re.sub(r'<\/?object[^&]*>', '', text)
text = re.sub(r'<\/?param[^&]*>', '', text)
text = re.sub(r'<\/?embed[^&]*>', '', text)
text = re.sub(r'<\/?object[^>]*>', '', text)
text = re.sub(r'<\/?param[^>]*>', '', text)
text = re.sub(r'<\/?embed[^>]*>', '', text)
text = re.sub(r'<\/?div[^>]*>', '', text)
text = re.sub(r'<\/?span[^>]*>', '', text)
# Remover atributos HTML residuales
text = re.sub(r'\"[^\"]*\"', '', text)
text = re.sub(r"\'[^\']*\'", '', text)
# Remover entidades HTML mal formadas
text = re.sub(r'&[^;\s]{1,10};', '', text)
# Remover espacios múltiples y limpiar
text = re.sub(r'\s+', ' ', text)
text = text.strip()
return text
def _flush_text(self):
"""Volcar texto acumulado a líneas - PÁRRAFOS COMPLETOS en una línea"""
if self.current_text.strip():
clean_text = self._clean_text(self.current_text)
if clean_text:
# Dividir en párrafos basados en dobles espacios
paragraphs = re.split(r'\s\s+', clean_text)
for paragraph in paragraphs:
paragraph = paragraph.strip()
if paragraph:
# Un párrafo completo en una línea
self.lines.append(paragraph)
# Añadir enlaces pendientes después del párrafo
if self.pending_links:
for href, text in self.pending_links:
self.lines.append(f"=> {href} {text}")
self.pending_links = []
self.current_text = ""
def get_gemtext(self):
"""Obtener el Gemtext procesado"""
# Procesar cualquier texto pendiente
self._flush_text()
# Añadir cualquier enlace pendiente que no se haya procesado
if self.pending_links:
if self.lines and self.lines[-1] != "":
self.lines.append("")
for href, text in self.pending_links:
self.lines.append(f"=> {href} {text}")
self.pending_links = []
# Añadir videos de YouTube al final del contenido
if self.video_urls:
if self.lines and self.lines[-1] != "":
self.lines.append("")
self.lines.append("Videos relacionados:")
for video_url in set(self.video_urls): # Remover duplicados
self.lines.append(f"=> {video_url} Ver video")
self.lines.append("")
# Unir líneas y limpiar saltos múltiples
result = '\n'.join(self.lines)
result = re.sub(r'\n\s*\n', '\n\n', result)
result = result.strip()
# Asegurar que no termina con múltiples saltos
if result:
result += '\n'
return result
class BloggerToGemtext:
def __init__(self, blog_url, tag):
self.blog_url = blog_url.rstrip('/')
self.tag = tag
self.img_dir = "img"
self.output_file = f"blog.{tag}"
self.used_ids = set() # Para evitar IDs duplicados
# Crear directorios necesarios
os.makedirs(self.img_dir, exist_ok=True)
# Crear imagen placeholder
self.create_placeholder()
def create_placeholder(self):
"""Crear imagen placeholder usando ImageMagick"""
placeholder_path = os.path.join(self.img_dir, "placeholder.png")
if not os.path.exists(placeholder_path):
print("🖼️ Creando imagen placeholder...")
try:
# Comando ImageMagick para crear una imagen 200x200 con texto
cmd = [
'convert',
'-size', '200x200',
'xc', '#f0f0f0', # Fondo gris claro
'-gravity', 'center',
'-pointsize', '12',
'-fill', '#666666', # Texto gris
'-annotate', '0', 'Imagen no disponible',
placeholder_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print("✅ Placeholder creado: placeholder.png")
else:
print("❌ Error creando placeholder, creando archivo vacío...")
# Crear un archivo vacío como fallback
with open(placeholder_path, 'wb') as f:
f.write(b'')
except Exception as e:
print(f"❌ ImageMagick no disponible: {e}")
print("📝 Creando archivo placeholder vacío...")
with open(placeholder_path, 'wb') as f:
f.write(b'')
def get_feed_url(self):
"""Obtener la URL del feed Atom del blog"""
if '.blogspot.com' in self.blog_url:
domain = self.blog_url.replace('https://', '').split('/')[0]
return f"https://{domain}/feeds/posts/default"
else:
return f"{self.blog_url}/feeds/posts/default"
def download_url(self, url, retries=3):
"""Descargar URL con reintentos"""
for attempt in range(retries):
try:
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (compatible; Blogger-to-Gemtext/1.0)'
}
)
with urllib.request.urlopen(req, timeout=30) as response:
return response.read().decode('utf-8')
except Exception as e:
if attempt == retries - 1:
raise e
time.sleep(1)
return None
def download_image(self, img_url):
"""Descargar una imagen individual"""
try:
# Limpiar URL
clean_url = img_url.split('?')[0] # Remover parámetros URL
parsed_url = urllib.parse.urlparse(clean_url)
img_name = os.path.basename(parsed_url.path)
if not img_name or '.' not in img_name:
img_name = f"image_{abs(hash(img_url))}.jpg"
safe_img_name = re.sub(r'[^\w\.-]', '_', img_name)
img_path = os.path.join(self.img_dir, safe_img_name)
if not os.path.exists(img_path):
print(f" 📷 Descargando: {safe_img_name}")
req = urllib.request.Request(
clean_url,
headers={'User-Agent': 'Mozilla/5.0'}
)
with urllib.request.urlopen(req, timeout=30) as response:
img_data = response.read()
# Verificar que es una imagen válida
if len(img_data) > 100: # Mínimo 100 bytes
with open(img_path, 'wb') as f:
f.write(img_data)
return safe_img_name, True
else:
print(f" ⚠️ Imagen demasiado pequeña, usando placeholder")
return safe_img_name, False
return safe_img_name, True
except Exception as e:
print(f" ❌ Error descargando {img_url}: {e}")
safe_name = re.sub(r'[^\w\.-]', '_', os.path.basename(img_url)) if '.' in img_url else f"image_{abs(hash(img_url))}"
return safe_name, False
def extract_images_from_html(self, html_content):
"""Extraer solo las URLs de imágenes reales del contenido HTML"""
if not html_content:
return []
# Buscar solo tags
con atributo src
img_pattern = r'
]*src="([^"]+)"[^>]*>'
images = re.findall(img_pattern, html_content)
# Filtrar solo URLs HTTP/HTTPS válidas
valid_images = [img for img in images if img.startswith('http')]
return valid_images
def download_all_posts(self):
"""Descargar todos los posts del blog usando Atom XML"""
print("📥 Descargando contenido del blog...")
feed_url = self.get_feed_url()
all_posts = []
start_index = 1
max_results = 500
while True:
paginated_url = f"{feed_url}?start-index={start_index}&max-results={max_results}"
print(f"📦 Descargando lote desde índice {start_index}...")
try:
xml_content = self.download_url(paginated_url)
if not xml_content:
break
# Parsear XML
root = ET.fromstring(xml_content)
# Namespace de Atom
ns = {'atom': 'http://www.w3.org/2005/Atom'}
entries = root.findall('atom:entry', ns)
if not entries:
break
print(f" ✅ Descargados {len(entries)} posts")
# Convertir entradas XML a diccionarios simples
for entry in entries:
post_data = self.parse_atom_entry(entry, ns)
if post_data:
all_posts.append(post_data)
# Verificar si hay más posts
if len(entries) < max_results:
break
start_index += max_results
time.sleep(1)
except Exception as e:
print(f"❌ Error descargando lote: {e}")
break
print(f"📊 Total de posts descargados: {len(all_posts)}")
return all_posts
def parse_atom_entry(self, entry, ns):
"""Parsear una entrada Atom XML"""
try:
post_data = {}
# Título
title_elem = entry.find('atom:title', ns)
if title_elem is not None:
post_data['title'] = title_elem.text or ''
# Fecha de publicación
published_elem = entry.find('atom:published', ns)
if published_elem is not None:
post_data['published'] = published_elem.text or ''
# Fecha de actualización
updated_elem = entry.find('atom:updated', ns)
if updated_elem is not None:
post_data['updated'] = updated_elem.text or ''
# Contenido
content_elem = entry.find('atom:content', ns)
if content_elem is not None:
post_data['content'] = content_elem.text or ''
# Categorías
categories = []
for category_elem in entry.findall('atom:category', ns):
term = category_elem.get('term')
if term:
categories.append(term)
post_data['categories'] = categories
return post_data
except Exception as e:
print(f"❌ Error parseando entrada: {e}")
return None
def process_post_images(self, html_content):
"""Procesar y descargar imágenes de un post"""
if not html_content:
return []
# Extraer URLs de imágenes reales
image_urls = self.extract_images_from_html(html_content)
print(f" 🔍 Encontradas {len(image_urls)} imágenes")
downloaded_images = []
for img_url in image_urls:
safe_img_name, success = self.download_image(img_url)
downloaded_images.append((img_url, safe_img_name, success))
if success:
print(f" ✅ {safe_img_name}")
else:
print(f" 🖼️ Placeholder para: {safe_img_name}")
return downloaded_images
def html_to_gemtext(self, html_content, downloaded_images):
"""Convertir HTML a Gemtext correctamente"""
if not html_content:
return ""
# Decodificar entidades HTML
content = html.unescape(html_content)
# Usar nuestro parser personalizado
parser = HTMLToGemtextParser()
parser.set_downloaded_images(downloaded_images)
parser.feed(content)
gemtext = parser.get_gemtext()
return gemtext
def get_entry_date(self, post_data):
"""Obtener la fecha del post"""
date_str = post_data.get('published') or post_data.get('updated') or ''
if date_str:
try:
date_part = date_str.split('T')[0]
return date_part
except:
pass
return "0000-00-00"
def generate_entry_id(self, title, entry_count):
"""Generar un ID único para la entrada - EVITAR DUPLICADOS"""
if title:
# Crear ID base
entry_id = re.sub(r'[^a-z0-9]', '_', title.lower())
entry_id = re.sub(r'_+', '_', entry_id)
entry_id = entry_id.strip('_')[:25] # Más corto para dejar espacio a números
if not entry_id:
entry_id = f"entrada_{entry_count}"
# Verificar si ya existe y añadir número si es necesario
base_id = entry_id
counter = 1
while entry_id in self.used_ids:
entry_id = f"{base_id}_{counter}"
counter += 1
self.used_ids.add(entry_id)
return entry_id
entry_id = f"entrada_{entry_count}"
self.used_ids.add(entry_id)
return entry_id
def normalize_categories(self, categories):
"""Normalizar categorías para formato hashtag"""
normalized_tags = []
for category in categories:
if category.strip():
normalized_tag = normalize_hashtag(category)
if normalized_tag:
normalized_tags.append(f"#{normalized_tag}")
return ' '.join(normalized_tags)
def process_posts(self, posts):
"""Procesar todos los posts y generar archivo Gemtext"""
print("🔄 Procesando posts y generando Gemtext...")
with open(self.output_file, 'w', encoding='utf-8') as f:
for i, post in enumerate(posts):
try:
title = post.get('title', '').strip()
if not title:
continue
print(f" 📝 [{i+1}/{len(posts)}] Procesando: {title[:60]}...")
# Obtener contenido
content = post.get('content', '')
# Procesar imágenes del post
downloaded_images = self.process_post_images(content)
# Convertir a Gemtext
gemtext_content = self.html_to_gemtext(content, downloaded_images)
# Obtener fecha
date = self.get_entry_date(post)
# Generar ID único
entry_id = self.generate_entry_id(title, i + 1)
# Obtener y normalizar categorías
categories = post.get('categories', [])
normalized_hashtags = self.normalize_categories(categories)
# Escribir entrada en formato blog.txt
f.write(f"' Entrada descargada de {self.blog_url}\n")
f.write(f"{date}\n")
f.write(f"{entry_id}\n")
f.write(f"[{self.tag}] {title}\n")
# Escribir categorías normalizadas como hashtags
if normalized_hashtags:
f.write(f"{normalized_hashtags}\n")
else:
f.write("#blogger\n")
f.write(f"{gemtext_content}\n")
f.write("\n\n")
# Contar imágenes exitosas vs placeholders
success_count = sum(1 for _, _, success in downloaded_images if success)
placeholder_count = len(downloaded_images) - success_count
print(f" ✅ Procesada - {success_count} imágenes, {placeholder_count} placeholders")
print(f" 🆔 ID: {entry_id}")
except Exception as e:
print(f"❌ Error procesando post {i + 1}: {e}")
continue
def run(self):
"""Ejecutar el proceso completo"""
print(f"🚀 Convirtiendo blog: {self.blog_url}")
print(f"🏷️ Tag: {self.tag}")
try:
# Descargar todos los posts
posts = self.download_all_posts()
if not posts:
print("❌ No se encontraron posts para procesar")
return False
# Procesar posts y generar Gemtext
self.process_posts(posts)
print(f"\n🎉 ¡Proceso completado!")
print(f"📄 Archivo generado: {self.output_file}")
print(f"🖼️ Imágenes descargadas en: {self.img_dir}/")
print(f"📊 Total de posts procesados: {len(posts)}")
return True
except Exception as e:
print(f"❌ Error en el proceso: {e}")
return False
def main():
if len(sys.argv) != 3:
print("Uso: python3 blogger_to_gemtext.py ")
print("Ejemplo: python3 blogger_to_gemtext.py https://nintenfreaks.blogspot.com NFRK")
sys.exit(1)
blog_url = sys.argv[1]
tag = sys.argv[2]
converter = BloggerToGemtext(blog_url, tag)
success = converter.run()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()