primer prototipo. Que pereza escribir commits

This commit is contained in:
2026-01-20 11:42:17 -05:00
parent 2fac2891a9
commit 115e1a862d
3 changed files with 283 additions and 2 deletions

View File

@@ -1,3 +1,70 @@
# opml-to-blogroll # Opml-to-blogroll
Convierte un archivo OPML exportado a una salida BlogRoll segun una plantilla definida para insertar o copiar en un blog Script en python bastante manual basado en lo que vi en el blog de [DanQ](https://danq.me/2026/01/20/blogroll-88x31s/)
Me encantan los iconitos pixelados que pone el generador de el, pero no tengo idea de donde los saca ni he visto a nadie por este ecosistema hacer uso de ellos.
Un script de Python versátil diseñado para convertir archivos de suscripciones RSS (OPML/XML) en formatos listos para publicar en la web. Ideal para crear "blogrolls" o directorios de lectura en WordPress, blogs estáticos o documentación personal.
# Descripción (Español)
Este script permite transformar tus exportaciones de FreshRSS (o cualquier lector RSS) en piezas de contenido visualmente atractivas o estructuradas.
# Funcionalidades
- Entrada Dual: Soporta archivos .opml y .xml.
- Modo Híbrido:
- Ejecución por línea de comandos para automatización.
- Interfaz Gráfica (GUI) con Tkinter para un uso sencillo.
- Formatos de Salida:
- HTML: Genera tarjetas con bordes redondeados y diseño responsivo utilizando CSS Inline (100% compatible con WordPress y otros CMS que bloquean etiquetas \<style\>).
- Markdown: Crea tablas limpias escapando caracteres especiales (como \|) para evitar errores de formato.
- JSON: Exporta una estructura de datos limpia para desarrolladores.
Portabilidad: No requiere librerías externas (solo Python 3.x).
# Uso
- **Gráfico**: Ejecuta python script.py sin argumentos.
- **Comandos**: python script.py archivo.opml -f markdown
# Description (English)
A versatile Python script designed to convert RSS subscription files (OPML/XML) into web-ready formats. Perfect for creating "blogrolls" or reading directories on WordPress, static blogs, or personal documentation.
# Features
- Dual Input: Supports both .opml and .xml files.
- Hybrid Mode:
- Command Line Interface (CLI) for automation.
- Graphical User Interface (GUI) via Tkinter for ease of use.
- Output Formats:
- HTML: Generates responsive, rounded cards using Inline CSS (100% compatible with WordPress and CMS platforms that strip \<style\> tags).
- Markdown: Creates clean tables with auto-escaping for special characters (like |) to ensure formatting stability.
- JSON: Exports a clean data structure for developers.
- Portability: Built using standard Python libraries only (Python 3.x).
# Usage
- **GUI**: Run python script.py without arguments.
- **CLI**: python script.py file.opml -f html
# Instalación / Installation
Clona este repositorio / Clone this repo:
```bash
git clone https://git.interlan.ec/Drk0027/opml-to-blogroll
```
Asegúrate de tener Python instalado / Ensure Python is installed:
```bash
python main.py
```
# Licencia / License
MIT License - Siéntete libre de usarlo y mejorarlo. / Feel free to use and improve it.
# Nota de mejora:
Este script ha sido optimizado para la compatibilidad moderna con WordPress Gutenberg y motores de renderizado de Markdown estrictos, asegurando que los enlaces y estilos se mantengan intactos independientemente de la plataforma de destino.

113
index.html Normal file
View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPML-to-BlogRoll</title>
<style>
body { font-family: -apple-system, sans-serif; background-color: #f4f7f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 15px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); width: 100%; max-width: 450px; text-align: center; }
h1 { color: #2c3e50; font-size: 1.5rem; margin-bottom: 1.5rem; }
.field { margin-bottom: 1rem; text-align: left; }
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
input[type="file"], select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; }
button { background: #0366d6; color: white; border: none; padding: 12px 20px; border-radius: 8px; cursor: pointer; width: 100%; font-size: 1rem; font-weight: bold; margin-top: 10px; transition: background 0.3s; }
button:hover { background: #0255b3; }
footer { margin-top: 1.5rem; font-size: 0.8rem; color: #888; }
</style>
</head>
<body>
<div class="card">
<h1>OPML-to-BlogRoll</h1>
<div class="field">
<label for="fileInput">1. Selecciona archivo (OPML o XML):</label>
<input type="file" id="fileInput" accept=".opml,.xml">
</div>
<div class="field">
<label for="formatSelect">2. Formato de salida:</label>
<select id="formatSelect">
<option value="html">HTML (con CSS Inline para WordPress)</option>
<option value="markdown">Markdown (Tablas escapadas)</option>
<option value="json">JSON (Datos puros)</option>
</select>
</div>
<button onclick="processFile()">Convertir y Descargar</button>
<footer>Versión Web Estática - No necesita instalacion</footer>
</div>
<script>
function escaparMarkdown(text) {
if (!text) return "";
return text.replace(/\|/g, "\\|").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
}
async function processFile() {
const fileInput = document.getElementById('fileInput');
const format = document.getElementById('formatSelect').value;
if (fileInput.files.length === 0) {
alert("Por favor, selecciona un archivo primero.");
return;
}
const file = fileInput.files[0];
const text = await file.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(text, "text/xml");
// Buscar todos los nodos 'outline' que tengan xmlUrl
const outlines = xmlDoc.querySelectorAll("outline[xmlUrl]");
const feeds = Array.from(outlines).map(el => ({
titulo: el.getAttribute("title") || el.getAttribute("text") || "Sin título",
url_web: el.getAttribute("htmlUrl") || "#",
url_rss: el.getAttribute("xmlUrl") || "#"
}));
let output = "";
let filename = file.name.split('.').slice(0, -1).join('.');
if (format === "html") {
output = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; font-family: sans-serif; margin: 20px 0;">';
feeds.forEach(f => {
output += `
<div style="border: 1px solid #e1e4e8; border-radius: 12px; padding: 15px; display: flex; justify-content: space-between; align-items: center; background: #ffffff; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<a href="${f.url_web}" style="font-weight: bold; color: #0366d6; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;" target="_blank">${f.titulo}</a>
<a href="${f.url_rss}" style="background: #f68140; color: #ffffff !important; padding: 5px 10px; border-radius: 6px; font-size: 12px; text-decoration: none; font-weight: bold; margin-left: 12px;" target="_blank">RSS</a>
</div>`;
});
output += '\n</div>';
filename += ".html";
}
else if (format === "markdown") {
output = "| Sitio Web | Feed RSS |\n| :--- | :---: |\n";
feeds.forEach(f => {
output += `| [${escaparMarkdown(f.titulo)}](${f.url_web}) | [RSS](${f.url_rss}) |\n`;
});
filename += ".md";
}
else if (format === "json") {
output = JSON.stringify(feeds, null, 4);
filename += ".json";
}
downloadFile(output, filename);
}
function downloadFile(content, filename) {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
</script>
</body>
</html>

101
main.py Normal file
View File

@@ -0,0 +1,101 @@
import xml.etree.ElementTree as ET
import json
import argparse
import sys
import os
from tkinter import filedialog, ttk, Tk, messagebox
def escapar_markdown(texto):
if not texto: return ""
return texto.replace("|", "\\|").replace("[", "\\[").replace("]", "\\]")
def procesar_feeds(ruta_entrada, formato_salida):
try:
tree = ET.parse(ruta_entrada)
root = tree.getroot()
except Exception as e:
return f"Error: {e}"
feeds = []
for outline in root.findall(".//outline[@xmlUrl]"):
feeds.append({
"titulo": outline.get('title') or outline.get('text') or "Sin título",
"url_web": outline.get('htmlUrl') or "#",
"url_rss": outline.get('xmlUrl') or "#"
})
nombre_base = os.path.splitext(ruta_entrada)[0]
# Con formatos integrados inline para evitar dramas de etiquetas prohibidas.
if formato_salida == 'html':
# Contenedor principal con CSS Inline
output = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; font-family: sans-serif; margin: 20px 0;">'
for f in feeds:
# Estilos directos en cada tarjeta y enlace
card_style = 'border: 1px solid #e1e4e8; border-radius: 12px; padding: 15px; display: flex; justify-content: space-between; align-items: center; background: #ffffff; box-shadow: 0 2px 4px rgba(0,0,0,0.05);'
title_style = 'font-weight: bold; color: #0366d6; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;'
rss_style = 'background: #f68140; color: #ffffff !important; padding: 5px 10px; border-radius: 6px; font-size: 12px; text-decoration: none; font-weight: bold; margin-left: 12px;'
output += f'\n <div style="{card_style}">'
output += f'\n <a href="{f["url_web"]}" style="{title_style}" target="_blank">{f["titulo"]}</a>'
output += f'\n <a href="{f["url_rss"]}" style="{rss_style}" target="_blank">RSS</a>'
output += f'\n </div>'
output += "\n</div>"
ext = ".html"
elif formato_salida == 'markdown':
output = "| Sitio Web | Feed RSS |\n| :--- | :---: |\n"
for f in feeds:
titulo_limpio = escapar_markdown(f['titulo'])
output += f"| [{titulo_limpio}]({f['url_web']}) | [RSS]({f['url_rss']}) |\n"
ext = ".md"
elif formato_salida == 'json':
output = json.dumps(feeds, indent=4, ensure_ascii=False)
ext = ".json"
archivo_final = nombre_base + ext
with open(archivo_final, "w", encoding="utf-8") as f:
f.write(output)
return archivo_final
def iniciar_gui():
root = Tk()
root.title("OPML-to-BlogRoll")
root.geometry("400x250")
archivo_ruta = {"path": ""}
def seleccionar():
path = filedialog.askopenfilename(filetypes=[("Feeds", "*.opml *.xml")])
if path:
archivo_ruta["path"] = path
lbl.config(text=os.path.basename(path))
def ejecutar():
if not archivo_ruta["path"]: return
res = procesar_feeds(archivo_ruta["path"], combo.get().lower())
messagebox.showinfo("Éxito", f"Generado: {res}")
root.destroy()
ttk.Label(root, text="Convertidor OPML/XML", font=("Arial", 12, "bold")).pack(pady=15)
ttk.Button(root, text="Seleccionar Archivo", command=seleccionar).pack()
lbl = ttk.Label(root, text="Ninguno", foreground="gray")
lbl.pack(pady=5)
combo = ttk.Combobox(root, values=["HTML", "Markdown", "JSON"], state="readonly")
combo.set("HTML")
combo.pack(pady=10)
ttk.Button(root, text="Convertir", command=ejecutar).pack(pady=10)
root.mainloop()
if __name__ == "__main__":
if len(sys.argv) > 1:
parser = argparse.ArgumentParser()
parser.add_argument("entrada")
parser.add_argument("-f", "--formato", choices=['html', 'markdown', 'json'], default='html')
args = parser.parse_args()
if os.path.exists(args.entrada):
print(f"Generado: {procesar_feeds(args.entrada, args.formato)}")
else:
iniciar_gui()