De RSS a Telegram y X: cómo automatizar un flujo de publicación sin perder control editorial
Tutorial para automatizar la publicación de contenido desde RSS a Telegram y X, con validación humana y control editorial real.

Tengo un canal de Telegram y una cuenta de X donde publico contenido técnico curado. Durante un tiempo lo hacía todo a mano: leía artículos, seleccionaba los que me parecían interesantes, escribía un breve comentario y los publicaba. Funcionaba, pero me comía entre 30 y 45 minutos cada día solo en eso. Y lo peor no era el tiempo: era la inconsistencia. Los días que no tenía ganas, no publicaba. Y un canal que publica de forma irregular pierde audiencia rápido.
Así que monté un sistema automatizado. No para que publicara solo, sino para que me preparara todo y yo solo tuviera que aprobar o descartar. Esa diferencia es importante: no busco un bot que spamee enlaces. Busco un asistente que me ahorre la parte mecánica y me deje centrarme en el criterio editorial.
Este artículo explica el flujo completo que uso, basado en lo que construí para Rolsfera. Si gestionas un canal de contenido técnico y quieres automatizar sin perder el control, esto te va a ahorrar bastantes horas de prueba y error.
El flujo completo
Antes de entrar en cada pieza, este es el flujo de principio a fin:
RSS Feeds → Parseo → Filtrado → Resumen (IA) → Cola de revisión → Aprobación humana → Formateo → Publicación (Telegram / X)Parece simple. Y conceptualmente lo es. La complejidad real está en los detalles de cada paso: cómo filtras, cómo resumes, cómo formateas para cada plataforma, cómo gestionas errores y duplicados.
Vamos pieza por pieza.
Paso 1: Recoger contenido desde RSS
El punto de entrada son feeds RSS. Tengo una lista de unas 40 fuentes que sigo: blogs técnicos, medios especializados, newsletters que publican vía RSS, repos de GitHub con releases feed y algún subreddit vía RSS.
# feeds.py - Lista de fuentes con metadatos
FEEDS = [
{
"url": "https://blog.pragmaticengineer.com/rss/",
"name": "Pragmatic Engineer",
"category": "engineering",
"priority": "high",
},
{
"url": "https://martinfowler.com/feed.atom",
"name": "Martin Fowler",
"category": "architecture",
"priority": "high",
},
{
"url": "https://news.ycombinator.com/rss",
"name": "Hacker News",
"category": "general",
"priority": "medium",
},
# ... 37 fuentes más
]Cada fuente tiene una categoría y una prioridad. La prioridad no es arbitraria: la ajusto según el histórico de artículos que acabo aprobando de cada fuente. Si el 80% de lo que publica Pragmatic Engineer me parece publicable, es high. Si de Hacker News solo publico un 10%, es medium.
El parseo lo hago con feedparser en Python. n8n dispara el proceso cada 30 minutos a través de un cron trigger que llama a un endpoint HTTP de mi servicio.
import feedparser
from datetime import datetime, timedelta
def fetch_new_articles(feed_url: str, since_hours: int = 2) -> list[dict]:
feed = feedparser.parse(feed_url)
cutoff = datetime.utcnow() - timedelta(hours=since_hours)
articles = []
for entry in feed.entries:
published = entry.get("published_parsed")
if published:
pub_date = datetime(*published[:6])
if pub_date < cutoff:
continue
articles.append({
"title": entry.get("title", "").strip(),
"url": entry.get("link", ""),
"summary": entry.get("summary", ""),
"published": entry.get("published", ""),
})
return articlesPaso 2: Filtrado y deduplicación
No todo lo que entra vale la pena procesarlo. El filtrado tiene dos niveles:
Deduplicación. Si el mismo artículo ya está en la base de datos (por URL o por hash de contenido), se descarta. Esto es crítico porque muchos feeds comparten las mismas noticias.
Filtrado por relevancia básica. Antes de gastar tokens de IA, aplico filtros simples:
# Palabras clave que indican contenido relevante para mi audiencia
INCLUDE_KEYWORDS = [
"python", "backend", "api", "architecture", "kubernetes",
"database", "automation", "scraping", "llm", "self-hosted",
"devops", "data engineering", "microservices",
]
# Contenido que normalmente descarto
EXCLUDE_PATTERNS = [
"sponsored", "advertisement", "podcast episode",
"weekly roundup", # demasiado genérico
]
def passes_basic_filter(article: dict) -> bool:
text = f"{article['title']} {article['summary']}".lower()
for pattern in EXCLUDE_PATTERNS:
if pattern in text:
return False
for keyword in INCLUDE_KEYWORDS:
if keyword in text:
return True
return False # si no coincide con nada relevante, no pasaEste filtrado es tosco. Lo sé. Pero reduce el volumen de artículos que llegan al paso de IA en un 60-70%, lo que tiene un impacto directo en coste y tiempo de procesamiento.
Paso 3: Resumen con IA
Los artículos que pasan el filtro se envían a un LLM para generar un resumen corto y una clasificación. El prompt está diseñado para obtener exactamente lo que necesito para decidir si publicar:
def generate_summary(article: dict) -> dict:
prompt = f"""Eres un editor técnico. Analiza este artículo y responde en JSON:
Título: {article['title']}
Contenido: {article['content'][:2500]}
Responde con:
{{
"summary": "Resumen de 2-3 frases, directo, sin relleno",
"topic": "Tema principal (ej: Python, DevOps, arquitectura)",
"is_actionable": true/false, // ¿aporta algo práctico?
"suggested_comment": "Frase de 1 línea que usaría al compartirlo"
}}
NO incluyas frases genéricas tipo 'Este artículo explora...'
Sé directo y concreto."""
response = call_llm(prompt, model="gpt-4o-mini")
return json.loads(response)Uso gpt-4o-mini para esta tarea porque no necesito el modelo más potente. Un resumen de 2-3 frases y una clasificación es algo que los modelos pequeños hacen bien. Reservo modelos más grandes para cuando necesito generar contenido original o hacer análisis más complejos.
El prompt importa más de lo que parece. Añadir “NO incluyas frases genéricas” fue la diferencia entre obtener resúmenes útiles y obtener relleno tipo “Este interesante artículo examina…”.
Paso 4: Cola de revisión
Los artículos procesados llegan a una cola donde los reviso. En la práctica es una tabla en PostgreSQL con una interfaz web mínima encima:
SELECT
a.title,
a.url,
a.ai_metadata->>'summary' AS summary,
a.ai_metadata->>'suggested_comment' AS comment,
a.ai_metadata->>'topic' AS topic,
a.source_name,
a.created_at
FROM articles a
WHERE a.status = 'pending'
ORDER BY
CASE
WHEN a.source_priority = 'high' THEN 1
WHEN a.source_priority = 'medium' THEN 2
ELSE 3
END,
a.created_at DESC;La revisión me lleva entre 5 y 10 minutos al día. Los artículos de fuentes de alta prioridad aparecen primero. Para cada uno, hago una de tres cosas: aprobar (a veces editando el comentario sugerido por la IA), descartar o guardar para más tarde.
Paso 5: Formateo por plataforma
Cada plataforma tiene sus reglas. Lo que funciona en Telegram no funciona en X y viceversa.
Telegram permite mensajes largos, Markdown, emojis y enlaces con preview. Mi formato típico:
def format_for_telegram(article: dict) -> str:
comment = article["ai_metadata"]["suggested_comment"]
title = article["title"]
url = article["url"]
topic = article["ai_metadata"]["topic"]
return f"""🔗 *{title}*
{comment}
📌 Tema: {topic}
👉 [Leer artículo]({url})"""X (Twitter) tiene límite de 280 caracteres. El formato es más compacto:
def format_for_x(article: dict) -> str:
comment = article["ai_metadata"]["suggested_comment"]
url = article["url"]
topic = article["ai_metadata"]["topic"]
# X cuenta caracteres, los URLs ocupan ~23
max_comment_len = 280 - 23 - len(f" #{topic}") - 5
if len(comment) > max_comment_len:
comment = comment[:max_comment_len - 3] + "..."
return f"{comment} #{topic} {url}"Paso 6: Publicación
La publicación la gestiona n8n. Cuando marco un artículo como aprobado, su estado cambia en la base de datos. Un workflow de n8n que corre cada 5 minutos detecta artículos aprobados y los publica.
El flujo en n8n es simple:
- Trigger cron cada 5 minutos
- HTTP Request al endpoint que devuelve artículos aprobados
- Split In Batches para no publicar todo de golpe
- Telegram Node para enviar al canal
- HTTP Request a la API de X para publicar el tweet
- HTTP Request para actualizar el estado a
published
La parte de “no publicar todo de golpe” es importante. Si apruebo 8 artículos por la mañana y se publican todos a la vez, la experiencia para los seguidores es mala. Uso un sistema de slots temporales: máximo 2 publicaciones por hora, con un mínimo de 20 minutos entre ellas.
# Lógica de scheduling simplificada
from datetime import datetime, timedelta
def get_next_publish_slot(last_published_at: datetime) -> datetime:
min_gap = timedelta(minutes=20)
next_slot = last_published_at + min_gap
# No publicar entre las 23:00 y las 08:00
if next_slot.hour >= 23 or next_slot.hour < 8:
next_slot = next_slot.replace(hour=8, minute=0, second=0)
if next_slot <= last_published_at:
next_slot += timedelta(days=1)
return next_slotDónde usar n8n y dónde Python
Una duda que me surgió al principio fue hasta dónde llevar la lógica en n8n y cuándo moverla a Python. Después de varias iteraciones, mi regla es esta:
| Tarea | Herramienta | Por qué |
|---|---|---|
| Orquestación (triggers, scheduling) | n8n | Interfaz visual, fácil de modificar |
| Llamadas a APIs externas simples | n8n | Nodos nativos para Telegram, HTTP |
| Parseo de RSS | Python | feedparser es más robusto que el nodo RSS de n8n |
| Filtrado y deduplicación | Python | Lógica compleja con acceso a BD |
| Procesamiento con IA | Python | Control fino sobre prompts y respuestas |
| Formateo de contenido | Python | Lógica de plantillas y truncado |
| Monitorización y alertas | n8n | Error handling visual, fácil de depurar |
La regla general: n8n para orquestar, Python para procesar. Cuando intentas meter lógica de procesamiento compleja en nodos de n8n, acabas con un workflow ilegible. Y cuando intentas replicar la orquestación visual de n8n en código, acabas reinventando un scheduler.
Errores comunes (y cómo los gestiono)
Rate limits
Tanto la API de Telegram como la de X tienen límites de peticiones. Telegram es bastante permisivo con bots, pero X es estricto. Mi solución: un sistema de cola con reintentos exponenciales.
import time
def publish_with_retry(publish_fn, content, max_retries=3):
for attempt in range(max_retries):
try:
return publish_fn(content)
except RateLimitError as e:
wait_time = (2 ** attempt) * 30 # 30s, 60s, 120s
print(f"Rate limit. Reintentando en {wait_time}s...")
time.sleep(wait_time)
raise PublishError(f"Fallo tras {max_retries} intentos")Duplicados que se cuelan
A veces el mismo artículo aparece en varios feeds con URLs ligeramente distintas (con parámetros UTM, por ejemplo). La solución es normalizar URLs antes de comparar:
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
def normalize_url(url: str) -> str:
parsed = urlparse(url)
# Eliminar parámetros de tracking
params = parse_qs(parsed.query)
clean_params = {
k: v for k, v in params.items()
if not k.startswith("utm_")
}
clean_query = urlencode(clean_params, doseq=True)
return urlunparse(parsed._replace(query=clean_query, fragment=""))Formato roto en publicaciones
Markdown que se renderiza mal en Telegram, tweets que exceden el límite de caracteres, enlaces que no generan preview. Esto lo descubrí con el tiempo. La solución fue añadir validaciones antes de publicar:
def validate_telegram_message(text: str) -> bool:
if len(text) > 4096:
return False
# Verificar que el Markdown está balanceado
if text.count("*") % 2 != 0:
return False
if text.count("_") % 2 != 0:
return False
return TrueBots baneados
Me pasó una vez con X. Publicaba demasiado rápido y la cuenta fue suspendida temporalmente. Desde entonces: máximo 10 publicaciones al día, con distribución horaria, y nunca publico exactamente en intervalos regulares (añado un jitter aleatorio de 1-5 minutos para no parecer un bot).
La revisión humana: por qué no lo hago todo automático
Podría quitar el paso de revisión y dejar que el sistema publique todo lo que pase los filtros. Técnicamente es trivial. Pero no lo hago por tres razones:
La IA se equivoca. No siempre, pero lo suficiente como para que un canal sin supervisión acabe publicando contenido irrelevante o mal clasificado. Un resumen que parece correcto puede estar sacando de contexto la conclusión del artículo.
El criterio editorial es lo que diferencia el canal. Cualquiera puede montar un bot que publique todo lo que sale en Hacker News. Lo que hace valioso un canal de contenido es que alguien con criterio ha decidido que eso merece la pena.
Me mantiene conectado con el contenido. Si automatizo todo, pierdo contacto con lo que se está publicando en mi sector. La revisión diaria de 10 minutos es también mi sesión de lectura rápida.
Automatizar no es eliminar al humano. Es liberar al humano de las tareas mecánicas para que se concentre en lo que aporta valor: el criterio.
Costes reales del sistema
Para que no quede todo en abstracto:
| Concepto | Coste mensual aprox. |
|---|---|
| VPS (n8n + servicios) | ~10€ |
| API LLM (resúmenes, clasificación) | ~15-25€ |
| API de X (tier básico) | 0€ (free tier) |
| Telegram Bot API | 0€ |
| PostgreSQL (self-hosted en VPS) | 0€ (incluido en VPS) |
| Total | ~25-35€/mes |
No es gratis, pero es menos de lo que cuesta una suscripción a cualquier herramienta SaaS de gestión de contenido. Y el sistema es mío, lo controlo y lo puedo modificar sin depender de que una empresa cambie sus precios o cierre su API.
Conclusión
Montar este flujo me llevó unas dos semanas de trabajo intermitente. La primera versión era mucho más simple: un script de Python que leía RSS, generaba un resumen y lo mandaba a Telegram. Todo automático, sin revisión. El resultado era mediocre: publicaciones irrelevantes, duplicados y un tono genérico que no representaba mi criterio.
La versión actual es mejor porque acepta que la automatización total no es el objetivo. El objetivo es reducir el trabajo mecánico al mínimo y dejar que la decisión editorial siga siendo humana.
Si estás pensando en montar algo similar, mi consejo es que empieces por el paso más simple (RSS → Telegram, sin IA, sin filtrado complejo) y vayas añadiendo capas según necesites. La complejidad de este sistema no vino de un diseño inicial brillante, sino de ir resolviendo problemas reales una semana tras otra.


