Crea tu propio lector de feeds tipo Feedly con IA
Un lector de feeds parece la aplicación más aburrida del mundo. Te suscribes a unos cuantos blogs, algo los descarga cada rato, tú los lees en una lista.
Tres frases. ¿Qué puede salir mal?
Pues resulta que dentro de esas tres frases viven un identificador que la mitad de los feeds no te da, cuatro formatos con dos estándares de fecha que no se hablan entre sí, un bucle de descarga que puede convertirse en una denegación de servicio a cámara lenta contra el blog de alguien, y HTML escrito por desconocidos que vas a inyectar tal cual dentro de tu página.
Y una cosa más: tu servidor va a hacer peticiones HTTP a URLs que teclea otra persona. Ahí es donde se cae la mitad de las réplicas caseras, y por eso ocupa la última pieza del post.
Por eso un lector de feeds es el proyecto perfecto para el fin de semana. No porque sea fácil, sino porque parece fácil y no lo es, y esa distancia es justo donde se aprende.
Hay además un motivo sentimental. En marzo de 2013 Google anunció que cerraba Reader el 1 de julio, y en las semanas siguientes Feedly absorbió tres millones de usuarios huérfanos, según contó TechCrunch en aquel momento. Toda esta categoría de producto existe porque un día alguien apagó un servidor. No es mal argumento para tener el tuyo.
Este post no repite el método. La receta general —cómo eliges qué reconstruir, el prompt cero, los criterios de aceptación, los prompts de rescate— la tienes en cómo crear tu propio X con IA. Y si vienes de los hermanos mayores de este proyecto, crear tu propio Calendly con IA, crear tu propio YNAB con IA o crear tu propia status page con IA, reconocerás la estructura. Aquí vamos a lo concreto de un agregador:
- Un prompt único para arrancar ahora mismo, si no quieres leer más
- Qué entra y qué no entra en un lector de feeds que se termina
- Las seis piezas donde se juega el partido, con el prompt de cada una
- El agujero de correctitud que tu agente no va a detectar solo
- El OPML, el trozo que todo el mundo olvida y el único que te saca de Feedly
- Dónde mirar cuando te atasques, con Miniflux como referencia viva
- Cómo generar el checklist que te dice si esto está de verdad terminado
Vamos al lío.
El prompt único, si quieres empezar ahora mismo ¶
Hay gente que ha llegado hasta aquí con el portátil abierto y el agente esperando. Para ti va esto.
Copia el prompt, pégalo y dale al enter.
Vas a construir "Bandeja", un lector de feeds pequeño a propósito, al
estilo de Feedly, para una sola persona con unas cien suscripciones.
Quiero una aplicación que funcione de verdad, no una maqueta.
Usa el stack que veas en el repositorio; si está vacío, propón uno
habitual y pregúntame antes de instalar nada exótico.
ANTES DE ESCRIBIR CÓDIGO
Devuélveme un plan corto con rutas, endpoints, tablas y decisiones.
Espera a que yo lo apruebe.
MODELO DE DATOS
Feed (url del feed, url del sitio, título, carpeta, etag,
last_modified, last_fetched_at, next_fetch_at, fallos_consecutivos,
activo), Entry (feed, hash, guid original, url, título, autor,
contenido, published_at, fetched_at, sort_at), EntryState (entrada,
leída, guardada, leída_en) y Folder. Nada más.
LAS TRES COSAS QUE NO PUEDES HACER MAL
1. La identidad de una entrada NO es su URL ni su título. Calcula un
hash estable con esta cascada: guid/atom:id/id del JSON Feed si
existe; si no, la URL normalizada (sin utm_*, sin fragmento,
esquema y host en minúsculas); si no, título más fecha. NUNCA metas
el contenido en el hash. Restricción de unicidad sobre
(feed_id, hash).
2. El HTML del feed lo escribe un desconocido. Sanea SIEMPRE con lista
blanca explícita de etiquetas y atributos permitidos, nunca con
expresiones regulares ni con lista negra. Fuera script, atributos
on*, hrefs javascript:, form, style, base y object. Los iframes,
solo de una lista corta de dominios de confianza.
3. Refrescar un feed usa petición condicional. Guarda el ETag y el
Last-Modified de cada respuesta y mándalos en If-None-Match e
If-Modified-Since. Si vuelve un 304, no descargues ni parsees nada.
User-Agent identificable con una URL de contacto. Respeta 429 y la
cabecera Retry-After.
FORMATOS
RSS 2.0, RSS 1.0/RDF, Atom 1.0 y JSON Feed 1.1. Fechas en RFC 822 para
RSS y RFC 3339 para Atom y JSON Feed. Guarda todo en UTC.
PANTALLAS
Lista de no leídos (por carpeta, por feed y todo junto), vista de un
artículo, gestión de feeds y carpetas, e importación de un fichero
OPML. Cada pantalla declara su acción principal, su estado de carga,
su estado vacío y su error recuperable.
FUERA DE ALCANCE
Multiusuario, aplicaciones móviles nativas, compatibilidad con la API
de Google Reader o de Fever, resúmenes con IA, búsqueda de texto
completo, extracción del artículo entero desde la web, reglas de
filtrado y suscripción a newsletters por correo. Si crees que hace
falta algo de esto, pregúntame.
TESTS
Unitarios con estos casos: un feed sin guid que se refresca dos veces
y no duplica, un feed que cambia el guid en cada regeneración, una
fecha con año 2077, una fecha inválida, una respuesta 304 que no toca
la base de datos, y una entrada con <img src=x onerror=alert(1)> que
sale saneada.
ENTREGA
Migraciones, datos de ejemplo, .env.example comentado y un README que
me permita levantar esto desde cero dentro de seis meses.
Cuando termines, dime qué has dejado a medias y qué decisiones tomaste
sin preguntarme.
Con eso tienes una primera versión que se abre en el navegador y te enseña artículos de verdad.
Ahora la parte honesta: eso es una primera versión, no un producto.
No aguanta un feed que tarda un minuto, no sabe qué hacer con un feed que se ha quedado sin publicar durante tres días, permite que alguien apunte tu servidor a donde le apetezca y marca como leídos artículos que nunca has visto. El prompt cubre tres de las seis piezas difíciles. Las otras tres son las que más tiempo te van a comer.
Así que vuelve por aquí cuando lo tengas en marcha. El resto del post es justo eso.
El nivel de vibe coding al que apunta este post ¶
Conviene situarlo antes de empezar, porque hay tres formas de construir esto y solo una encaja aquí.
La primera es el vibe coding básico: pides “hazme un lector de RSS”, aceptas lo que salga y lo despliegas. Vale para una demo. No vale para dejarlo corriendo seis meses contra trescientos blogs ajenos. Si estás en ese punto, empieza por cómo programar con IA tu primer proyecto.
La tercera es la metodología completa: especificación, artefactos versionados, criterios de aceptación formales. Es lo que conviene en un proyecto que va a durar, y lo tienes en la guía de Spec Driven Development. Para un fin de semana es matar moscas a cañonazos.
Este post vive en la segunda: vibe coding con criterio. Sigues conversando con el agente, sigues sin escribir specs formales, pero sabes de antemano dónde se va a equivocar el modelo y llegas a ese punto con el prompt preparado. Si quieres el marco de cuánta garantía pedir según lo que te juegas, lo desarrollo en cómo hacer bien vibe coding.
🔑 Un lector de feeds es la única aplicación cuyo input lo escriben cientos de desconocidos que no te conocen, no te deben nada y no van a arreglar su XML porque tú se lo pidas. Todo lo que construyas aquí se mide contra eso, no contra el feed bonito con el que hiciste la demo.
Sobre la herramienta da bastante igual: cualquier agente de terminal con acceso a ficheros te sirve. Si todavía no has elegido, tienes la comparativa de agentes de IA en terminal. Los prompts que vienen funcionan igual en todos.
El tercer nivel, para cuando el lector deje de ser un juguete
El ciclo completo de Spec Driven Development con OpenSpec en modo asistido sobre un proyecto pequeño: proposal, spec, diseño, tareas y archivar. Lo ves entero antes de decidir si el tuyo necesita esa marcha.
Abrir el curso gratis →Qué entra y qué no entra en tu lector de feeds ¶
El alcance es la decisión más importante del proyecto y se toma antes del primer prompt. En este proyecto más que en ningún otro, porque un agregador es la aplicación con más funcionalidades tentadoras por metro cuadrado que existe.
Un lector de feeds propio que se termina cubre esto:
- Suscripciones organizadas en carpetas, con su URL de feed y su URL de sitio.
- Un refrescador que descarga con petición condicional y respeta al servidor de enfrente.
- Entradas con identidad estable, sin duplicados y sin resurrecciones.
- Estado de lectura por entrada, con marcado masivo que no cuesta un minuto.
- Lectura segura del contenido: HTML saneado y enlaces resueltos.
- OPML de entrada y de salida, que es lo que convierte esto en algo usable.
Y deja fuera esto, a propósito:
- Multiusuario, con sus permisos y su registro
- Compatibilidad con la API de Google Reader o la de Fever para apps móviles
- Resúmenes, etiquetado o recomendaciones con IA
- Extracción del artículo completo desde la página original
- Reglas de filtrado, búsqueda de texto completo y newsletters por correo
Esa lista de exclusiones no es pereza: es la parte que convierte un fin de semana en un trimestre. Escríbela en tu fichero de contexto y el agente dejará de proponerte cosas que no vas a construir.
Las seis piezas donde se juega el partido ¶
Aquí está el mapa. Cada pieza tiene su trampa concreta, y ninguna se resuelve sola por muy bien que escribas el prompt inicial.
| Pieza | Por qué es difícil | Cómo sabes que está bien |
|---|---|---|
| La identidad de una entrada | El identificador es opcional, inestable y a veces mentira | Refrescar el mismo feed veinte veces no crea duplicados |
| Las fechas | Dos estándares, cuatro etiquetas y feeds que viajan al futuro | Un feed con fechas de 2077 no secuestra tu portada |
| El refresco | Sin petición condicional eres un problema para el de enfrente | Un feed sin cambios devuelve 304 y no cuesta nada |
| El HTML del contenido | Estás inyectando código ajeno en tu propia página | Un onerror dentro de un artículo no ejecuta nada |
| El estado de lectura | Marcar 4.000 artículos no pueden ser 4.000 escrituras | “Marcar todo” no marca lo que llegó mientras leías |
| El bucle de descarga | Tu servidor pide URLs que escribe otra persona | Suscribirse a http://localhost:6379 no hace nada |
Las tres primeras son de datos. Las tres siguientes son de seguridad y de sistemas, y son las que de verdad separan un juguete de una herramienta que puedes dejar corriendo.
Saber de antemano dónde está la trampa de cada pieza se aprende mirando proyectos ajenos. Cada domingo seleccionamos 12 recursos sobre programar con IA y los +6.700 que la leemos aportamos lo que vamos rompiendo por el camino.
Apúntate gratis →Pieza 1: la identidad de una entrada no es su URL ¶
Antes de entrar en materia, mira cómo se ve un lector de feeds que lleva años funcionando. Esta es la vista de artículos de Miniflux, uno de los agregadores de código abierto más sólidos que hay:

Fíjate en lo poco que hay: título, feed de origen, fecha. Esas tres cosas se apoyan en las tres primeras piezas de este post, y la primera es la que lo sostiene todo.
La pregunta parece tonta: ¿cómo sabes que este artículo ya lo tenías?
El agente va a responder con la URL. Es lo natural. Y es un error que se paga a los tres días, cuando abres tu lector y ves cuarenta copias del mismo post.
Un blog añade ?utm_source=rss a los enlaces del feed y lo quita la semana siguiente. Otro migra de http a https. Otro cambia el generador y de repente todas las URLs llevan barra final. Nada de eso es exótico: le pasa a tus suscripciones este mes. Cada uno de esos cambios, si tu identidad es la URL en crudo, te duplica el feed entero.
Y entre los formatos hay una asimetría que conviene tener clara:
- RSS 2.0 lo dice con estas palabras: “todos los elementos de un item son opcionales, pero al menos uno de title o description debe estar presente”. El
<guid>entra en ese saco. Hay feeds que cumplen la norma al pie de la letra y no te dan ningún identificador. - Atom va justo al contrario. La RFC 4287 exige que “los elementos atom:entry DEBEN contener exactamente un elemento atom:id”, y añade que ese identificador “NO DEBE cambiar” cuando el documento se reubica, migra, sindica o reexporta.
- JSON Feed 1.1 también lo exige, y va un paso más allá: dice que los lectores deben descartar cualquier item que no traiga
id.
Así que tienes un formato que garantiza identidad, dos que la exigen y uno, el más extendido, que la deja al gusto del que publica.
La solución es una cascada, y conviene escribirla explícita:
import { createHash } from "node:crypto";
// La identidad es lo más estable que el feed nos dé, en este orden.
// El contenido NUNCA entra en el hash: si alguien corrige una errata
// tendríamos una entrada nueva y el artículo aparecería dos veces.
function entryHash(item: RawItem): string {
if (item.guid) return sha256(item.guid);
if (item.link) return sha256(normalizeUrl(item.link));
return sha256(`${item.title}|${item.publishedAt ?? ""}`);
}
function normalizeUrl(raw: string): string {
const url = new URL(raw);
url.hash = "";
url.protocol = url.protocol.toLowerCase();
url.hostname = url.hostname.toLowerCase();
for (const key of [...url.searchParams.keys()]) {
if (/^(utm_|fbclid$|gclid$|mc_)/.test(key)) url.searchParams.delete(key);
}
return url.toString();
}
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
Dos detalles de esas veinte líneas valen todo el proyecto.
El primero: el contenido no entra en el hash. Suena bien meterlo, “así detecto ediciones”, y es la trampa clásica. El día que un autor corrija una tilde, tu lector considera que es un artículo distinto y te lo enseña otra vez. Las ediciones se detectan comparando el contenido después de haber identificado la entrada, no antes.
El segundo: el hash es único por feed, no global. Dos blogs pueden enlazar el mismo artículo, y son dos entradas legítimas. La restricción va sobre (feed_id, hash).
⚠️ Hay feeds que regeneran el
guiden cada publicación del sitio, porque el CMS usa un identificador interno que cambia al reconstruir. Esos te duplican todo aunque hagas las cosas bien. La única defensa razonable es detectar el patrón —un feed en el que el cien por cien de las entradas son nuevas cada vez— y marcarlo para caer a la URL normalizada.
Implementa la identidad de las entradas:
- Calcula un hash SHA-256 con esta cascada: guid / atom:id / id del
JSON Feed si existe; si no, la URL normalizada; si no, título más
fecha de publicación.
- Normalizar una URL es: quitar el fragmento, pasar esquema y host a
minúsculas y eliminar los parámetros utm_*, fbclid, gclid y mc_*.
- El contenido del artículo NO entra en el hash bajo ningún concepto.
- Restricción de unicidad en base de datos sobre (feed_id, hash), y la
inserción usa "insertar o ignorar", no un SELECT previo.
- Guarda también el guid original en crudo, para poder diagnosticar
después qué feed nos está mintiendo.
Está terminado cuando refrescar el mismo feed veinte veces seguidas
deja exactamente el mismo número de entradas, y hay un test que
recorre un feed de ejemplo sin guid y otro con guid cambiante.
Pieza 2: las fechas mienten, y tu portada depende de ellas ¶
Aquí está el error más silencioso del proyecto, porque no rompe nada: te deja una lista mal ordenada y tú te acostumbras.
El punto de partida es que hay dos estándares y no se hablan. RSS 2.0 exige que “todas las fechas en RSS se ajusten a la especificación de fecha y hora de la RFC 822”, esa que produce cosas como Sun, 19 May 2002 15:21:36 GMT. Atom y JSON Feed usan RFC 3339, que produce 2010-02-07T14:04:00-05:00. Y por el camino te vas a encontrar pubDate, dc:date, atom:published y atom:updated, a veces tres de ellos en el mismo documento y con valores distintos.
Eso es lo esperable. Lo que hunde tu portada es lo otro:
- Feeds con la fecha en el futuro, casi siempre por una zona horaria mal aplicada o por un artículo programado. Ese artículo se queda clavado arriba del todo durante meses.
- Feeds sin fecha ninguna, porque el elemento es opcional.
- Feeds que, al regenerarse, ponen la fecha de hoy a todos sus artículos. Publicaciones de 2019 encabezando tu lista de esta mañana.
El arreglo son dos fechas guardadas y una tercera para ordenar.
interface StoredEntry {
publishedAt: Date | null; // lo que dice el feed, tal cual, puede ser mentira
fetchedAt: Date; // cuándo lo vimos nosotros, esto no miente
sortAt: Date; // por lo que ordenamos la lista
}
// Nada puede haberse publicado después de que nosotros lo viéramos.
// Esa única línea mata las fechas futuras, las inválidas y las que
// faltan, sin tratar cada caso por separado.
function sortAt(publishedAt: Date | null, fetchedAt: Date): Date {
if (!publishedAt || Number.isNaN(publishedAt.getTime())) return fetchedAt;
return publishedAt < fetchedAt ? publishedAt : fetchedAt;
}
Es una línea y resuelve tres problemas. La fecha del feed la sigues guardando y la sigues mostrando, porque es la que el autor considera correcta. Pero el orden de tu lista lo decide un dato que tú controlas.
Queda el detalle de la zona horaria, que en este proyecto tiene menos miga que en un Calendly pero se puede estropear igual: guarda todo en UTC y convierte solo al pintar. Si guardas hora local, el día que cambies de servidor tu historial se mueve una hora y los agrupamientos por día dejan de cuadrar.
Revisa el tratamiento de fechas:
- Parsea RFC 822 para RSS y RFC 3339 para Atom y JSON Feed. Acepta
también dc:date y atom:published, con este orden de preferencia:
published > pubDate > dc:date > updated.
- Guarda published_at (lo que dice el feed, puede ser nulo), fetched_at
(nuestro) y sort_at = min(published_at, fetched_at), con caída a
fetched_at si la fecha falta o no parsea.
- Todo en UTC en base de datos. La conversión a zona local pasa solo en
la capa de presentación.
- Nunca descartes una entrada por tener la fecha mal: guárdala con
published_at nulo.
Escribe tests con estos casos: una fecha RFC 822 sin zona horaria, una
fecha con año 2077, una cadena que no parsea, un item sin fecha
ninguna y un feed que reescribe todas las fechas al refrescar.
Ese último caso es el que te enseña por qué sort_at se calcula una vez, al insertar, y no se recalcula en cada refresco. Si lo recalculas, el feed que reescribe fechas vuelve a secuestrarte la portada cada hora.
Pieza 3: refrescar sin ser un problema para el de enfrente ¶
Tu refrescador hace una petición cada quince minutos a cada feed y se descarga el XML. Funciona.
Ahora multiplica: doscientos feeds, cada quince minutos, veinticuatro horas. Son diecinueve mil peticiones al día contra blogs de gente que no te ha invitado, la inmensa mayoría de ellas para descargarse un fichero que no ha cambiado desde ayer.
Un lector de feeds mal educado es una denegación de servicio a cámara lenta. Y como cada petición individual es inofensiva, nadie se entera hasta que alguien te bloquea por IP.
La buena noticia es que HTTP lleva resuelto esto desde los noventa y el arreglo cabe en cuatro cabeceras.
async function fetchFeed(feed: Feed): Promise<FetchResult> {
const headers: Record<string, string> = {
// Identifícate y deja una URL de contacto: el día que hagas algo mal,
// el otro lado prefiere escribirte a bloquearte.
"User-Agent": "Bandeja/1.0 (+https://tudominio.com/bandeja)",
"Accept-Encoding": "gzip",
};
if (feed.etag) headers["If-None-Match"] = feed.etag;
if (feed.lastModified) headers["If-Modified-Since"] = feed.lastModified;
const res = await fetch(feed.url, { headers, redirect: "manual" });
// 304: el feed no ha cambiado. Ni descargamos cuerpo ni parseamos nada.
if (res.status === 304) return { kind: "unchanged" };
if (res.status === 429) {
return { kind: "backoff", retryAfter: res.headers.get("Retry-After") };
}
if (res.status === 410) return { kind: "gone" }; // el feed ya no existe: desactívalo
return {
kind: "changed",
body: await res.text(),
etag: res.headers.get("ETag"),
lastModified: res.headers.get("Last-Modified"),
};
}
Un 304 Not Modified son unos cientos de bytes de cabeceras frente a los doscientos kilobytes del XML completo. Con doscientos feeds tranquilos, la diferencia entre implementarlo y no implementarlo es la diferencia entre unos megas al día y varios gigas al mes.
Además de la caché condicional hay tres reglas de convivencia que el agente no aplicará solo:
- Intervalo adaptativo. Un blog que publica una vez al mes no necesita quince minutos. Miniflux lo resuelve con dos estrategias configurables,
round_robinyentry_frequency; la segunda calcula el intervalo de cada feed a partir de su frecuencia media de publicación de la última semana, con un mínimo de cinco minutos y un máximo de veinticuatro horas por defecto, según su documentación de configuración. - Jitter. Si programas todos los refrescos a la hora en punto, lanzas doscientas peticiones de golpe cada hora. Reparte con un desplazamiento aleatorio.
- Retroceso exponencial y desactivación. Un feed que falla se reintenta cada vez más tarde, y a partir de cierto número de fallos consecutivos se marca como roto y se avisa al usuario en lugar de seguir golpeando en el vacío.
💡 Los códigos de redirección merecen atención propia. Un
301significa que el feed se ha mudado: actualiza la URL guardada, no sigas pidiendo la vieja para siempre. Un302es temporal: sigue la redirección pero no toques nada. Un410significa que el feed ha muerto: desactívalo y dilo en la interfaz.
Implementa el refresco de feeds con estas garantías:
- Petición condicional: guarda ETag y Last-Modified de cada respuesta y
mándalos como If-None-Match e If-Modified-Since. Un 304 no toca la
base de datos ni parsea nada.
- User-Agent identificable con URL de contacto, y Accept-Encoding gzip.
- 429 con Retry-After respetado. 301 actualiza la URL guardada. 410
desactiva el feed y lo marca como muerto en la interfaz.
- Intervalo por feed calculado a partir de su frecuencia de publicación
reciente, con mínimo de 15 minutos y máximo de 24 horas, más un
desplazamiento aleatorio para no agrupar todas las peticiones.
- Retroceso exponencial tras cada fallo consecutivo, y desactivación
automática tras diez fallos seguidos con aviso al usuario.
Está terminado cuando un test comprueba que un feed que responde 304 no
genera ninguna escritura, y que dos feeds programados a la misma hora no
salen en el mismo segundo.
Pieza 4: estás inyectando HTML de desconocidos en tu página ¶
Este es el punto donde un lector de feeds deja de parecerse a un CRUD y empieza a parecerse a un navegador.
El contenido de un artículo viene en HTML. Lo escribe alguien a quien no conoces, en un sitio que no controlas, y tú lo vas a pintar dentro de tu página, en tu dominio, con tu sesión abierta. Eso es código de terceros ejecutándose en tu contexto, solo que con otro nombre.
Pide “sanea el HTML” y hay una probabilidad altísima de que el agente te devuelva una expresión regular que quita <script>. Eso no es saneado, es un placebo. La lista de vectores que se cuelan por debajo es larga y aburridamente conocida: atributos onerror y onload en cualquier etiqueta, href="javascript:...", <iframe> apuntando donde sea, <form> que roba credenciales, <style> con selectores que tapan tu interfaz, <base> que reescribe todos los enlaces relativos de la página, SVG con scripts dentro.
La única estrategia que funciona es la lista blanca. No enumerar lo prohibido, que es infinito, sino enumerar lo permitido, que cabe en una pantalla.
// Lista blanca explícita: etiqueta -> atributos permitidos.
// Todo lo que no esté aquí se elimina. Sin excepciones, sin "por ahora".
const ALLOWED: Record<string, string[]> = {
p: [],
br: [],
strong: [],
em: [],
code: [],
pre: [],
blockquote: ["cite"],
ul: [],
ol: ["start"],
li: [],
h2: [],
h3: [],
h4: [],
a: ["href", "title"],
img: ["src", "alt", "title", "width", "height", "srcset"],
figure: [],
figcaption: [],
table: [],
thead: [],
tbody: [],
tr: [],
th: ["colspan", "rowspan"],
td: ["colspan", "rowspan"],
};
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:"]);
Con esa tabla delante, el resto del saneado son cuatro reglas:
- Los esquemas de URL van en lista blanca también.
javascript:,data:yvbscript:fuera. Y ojo condata:, porque undata:text/htmles una página entera con permiso de residencia. - Las URLs relativas hay que resolverlas contra la URL del artículo. Si no lo haces, todos los enlaces internos de todos los artículos apuntan a rutas de tu propio dominio que no existen.
- Los enlaces externos salen con
rel="noopener noreferrer"yreferrerpolicy="no-referrer". Es lo que hace Miniflux, cuyo saneador es un mapa de etiquetas permitidas con sus atributos permitidos, y nada más. - Los iframes, solo de una lista corta de dominios de confianza (YouTube, Vimeo, Spotify y poco más) y siempre con
sandbox. Miniflux hace justo esto: valida el dominio contra una lista fija y añade restricciones de sandbox al iframe aprobado.
Y por encima de todo eso, una Content Security Policy en la respuesta. El saneado es la primera línea; la CSP es la que te salva el día que el saneado tenga un fallo, que lo tendrá.
🛡️ Hay un problema de privacidad que no es de seguridad y casi nadie mira: cada imagen remota de un artículo le cuenta al servidor del publicador tu IP, tu navegador y a qué hora exacta abriste su post. Un lector de feeds es, sin querer, una máquina de generar píxeles de seguimiento. La solución razonable es un proxy propio de imágenes, y el mínimo decente es contarlo en la interfaz.
Implementa el saneado del contenido de las entradas:
- Lista blanca explícita de etiquetas y, para cada una, de atributos
permitidos. Todo lo demás se elimina. Nada de expresiones regulares
ni de listas negras.
- Lista blanca de esquemas de URL: http, https y mailto. Fuera
javascript:, data: y vbscript:.
- Resuelve las URLs relativas de href y src contra la URL del artículo.
- Los enlaces externos salen con rel="noopener noreferrer" y
referrerpolicy="no-referrer".
- Los iframes solo se permiten desde una lista de dominios de confianza
configurable, y siempre con atributo sandbox.
- Añade una cabecera Content-Security-Policy a las respuestas que
pintan contenido de feeds, y documenta qué permite y por qué.
Está terminado cuando pasa un test con estos casos hostiles:
<img src=x onerror=alert(1)>, <a href="javascript:alert(1)">,
<iframe src="https://evil.example">, <base href="https://evil.example">,
<svg><script>alert(1)</script></svg> y una URL relativa que tiene que
acabar apuntando al dominio del artículo, no al mío.
Ese bloque de casos hostiles cópialo tal cual. Es la parte del proyecto donde un test que falla te ahorra un incidente de verdad, y a diferencia del resto de las piezas, aquí no hay grados: o el saneado aguanta o no aguanta.
La skill que escribe menos código
La disciplina de no construir la funcionalidad que te apetece
Un agregador es la aplicación con más funcionalidades tentadoras por metro cuadrado que existe. Destripamos Ponytail entera y la pasamos por un benchmark real para ver si de verdad consigue que el agente escriba menos código del que le apetece.
Ver la masterclass →Masterclass premium · incluye autodiagnóstico y benchmark
Pieza 5: marcar como leído no puede ser cuatro mil escrituras ¶
Los estados de lectura parecen el trozo aburrido y son donde tu lector se vuelve lento.
Cien feeds a cinco publicaciones diarias son quinientas entradas al día. En un mes son quince mil, y todas ellas tienen que responder a tres preguntas cada vez que pintas una pantalla: ¿está leída?, ¿está guardada?, ¿cuántas no leídas tiene su carpeta?
Hay dos trampas y son distintas.
La primera es de rendimiento. Cuando el usuario pulsa “marcar todo como leído” en una carpeta con cuatro mil entradas pendientes, la implementación ingenua manda cuatro mil actualizaciones. Marcar como leído es una operación de rango, no un bucle: una sola sentencia con un WHERE sobre el feed o la carpeta y un corte por fecha.
La segunda es de correctitud, es más sutil y no la va a ver el agente.
// El corte lo fija la vista, no el reloj del servidor.
// Si no, marcas como leído lo que llegó entre que pintaste la lista
// y el usuario pulsó el botón: artículos que nunca vio.
interface MarkAllReadCommand {
folderId: string;
upToSortAt: Date; // el sort_at de la entrada más nueva que la vista mostró
}
Piénsalo con el reloj en la mano. El usuario abre la carpeta a las 10:00 y ve doscientas entradas. Se va a por un café. A las 10:07 el refrescador mete nueve entradas nuevas. A las 10:08 el usuario vuelve y pulsa “marcar todo como leído”.
Si tu comando dice “marca todo lo no leído de esta carpeta”, acabas de enterrar nueve artículos que esa persona no ha visto jamás. Y no se va a enterar nunca, que es lo peor: es una pérdida de datos silenciosa, del tipo que solo notas seis meses después cuando alguien te comenta un post que juras que no salió.
El arreglo es una línea: el comando lleva el corte que vio la interfaz. Si llegó algo después, se queda sin leer.
Queda el contador de no leídas, que parece trivial y no lo es cuando aparece en cada carpeta de la barra lateral en cada render. Tienes dos opciones defendibles: un índice parcial sobre las no leídas —la mayoría de las bases de datos lo soportan y es la opción perezosa correcta para cien feeds— o una tabla de contadores actualizada en la misma transacción que el cambio de estado. A la primera le sobra para el tamaño de este proyecto; la segunda es la que necesitarás si algún día esto crece.
Implementa el estado de lectura:
- "Marcar todo como leído" es una única sentencia UPDATE con filtro por
feed o carpeta y un corte sort_at <= el valor que envía el cliente.
Nunca un bucle de actualizaciones.
- Ese corte lo fija la vista: el sort_at de la entrada más reciente que
se mostró. Lo que llegue después de pintar la lista se queda sin leer.
- La acción es reversible durante unos segundos con un "deshacer".
- El contador de no leídas por carpeta se resuelve con un índice
parcial sobre las entradas no leídas. Propónme la alternativa con
tabla de contadores y sus contras antes de implementar nada.
Está terminado cuando un test simula esta secuencia: se pinta la lista,
se insertan tres entradas nuevas, se marca todo como leído, y esas tres
siguen sin leer.
Ese “propónme la alternativa y sus contras” es un patrón que uso mucho: pedir opciones antes que soluciones. La elección de diseño es tu trabajo, no el suyo.
Pieza 6: tu servidor pide URLs que escribe otra persona ¶
Y llegamos al clímax, que es donde se cae la práctica totalidad de las réplicas caseras.
Repite la frase despacio: en un lector de feeds, un usuario escribe una URL en un formulario y tu servidor hace una petición HTTP a esa URL. Desde dentro de tu red. Con tus credenciales de red. Sin que ningún navegador y ninguna política de mismo origen se interponga.
Eso tiene nombre y es un clásico del OWASP Top 10: falsificación de petición del lado del servidor, SSRF por sus siglas en inglés. Y en un agregador no es una funcionalidad rara escondida en un rincón: es la funcionalidad principal.
Los objetivos son los de siempre y no hace falta ser muy imaginativo: http://169.254.169.254/latest/meta-data/ para pedirle credenciales al servicio de metadatos de tu proveedor de nube, http://localhost:6379/ para hablar con tu Redis, http://10.0.0.5/admin para llegar al panel interno que nunca expusiste a internet. El error que devuelve el parser, si lo enseñas en la interfaz, ya es un canal de filtración.
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
// Se valida la IP RESUELTA, no el nombre de host: un dominio bajo
// control del atacante puede apuntar a 127.0.0.1 tranquilamente.
async function assertPublicUrl(raw: string): Promise<void> {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Esquema no permitido");
}
const { address } = await lookup(url.hostname);
const range = ipaddr.parse(address).range();
// unicast es el único rango público. Todo lo demás es red interna,
// loopback, enlace local o multicast.
if (range !== "unicast") throw new Error("Destino no público");
}
Y hay un segundo filo que casi nadie cubre: validar antes de la petición no basta. Entre que resuelves el DNS y haces la petición, el registro puede cambiar de respuesta —el ataque se llama DNS rebinding—, y una redirección 302 te lleva a un destino que nunca validaste. Por eso la validación se repite en cada salto de redirección, y las redirecciones se limitan a tres o cuatro como mucho.
En la misma pieza viven los otros tres límites del bucle de descarga, que son los que evitan que un feed cualquiera te tire el proceso:
- Tiempo máximo por petición. Sin él, un servidor que acepta la conexión y no responde nunca te ocupa un trabajador para siempre. Diez segundos para conectar, treinta para el cuerpo entero.
- Tamaño máximo de respuesta. Un feed de cuarenta megas existe, y lo peor que puede hacer tu lector es intentar parsearlo entero en memoria. Corta a un par de megas y marca el feed como problemático.
- Concurrencia acotada y por host. Un grupo fijo de trabajadores para los trescientos feeds, y como mucho una o dos peticiones simultáneas al mismo dominio. Un feed lento no puede llevarse por delante a los otros doscientos noventa y nueve, y tampoco puedes abrir veinte conexiones contra el mismo blog.
Endurece el bucle de descarga:
- Antes de cada petición, valida la URL: solo http y https, y la IP
RESUELTA del host tiene que estar en rango público. Bloquea
loopback, enlace local, multicast y los rangos privados.
- Repite esa validación en CADA salto de redirección, con un máximo de
tres saltos.
- Tiempo máximo: 10 segundos para conectar, 30 para el cuerpo completo.
- Tamaño máximo de respuesta: 2 MB. Al superarlo se corta la descarga y
se marca el feed como problemático.
- Grupo de trabajadores acotado para todo el ciclo, con un máximo de
dos peticiones simultáneas por dominio.
- Los errores de un feed no pueden abortar el ciclo del resto: cada uno
se captura, se registra y se sigue.
- Los mensajes de error que ve el usuario no incluyen la respuesta
cruda del servidor remoto.
Está terminado cuando un test comprueba que suscribirse a
http://127.0.0.1:6379 y a http://169.254.169.254 falla con un error
genérico, y que un feed que tarda 60 segundos no retrasa la
actualización de los demás.
Esa última línea del prompt —no devolver la respuesta cruda— es la que convierte un SSRF que solo abre conexiones en uno que además te lee cosas. Se olvida siempre.
La diferencia entre un proyecto que funciona en tu portátil y uno que aguanta seis meses contra internet suele estar en cuatro límites como estos. En la newsletter de los domingos compartimos los sustos y lo que sacamos de ellos. Gratis, desde 2018.
Quiero esa dinamita 🧨El agujero que no verás hasta dentro de tres meses ¶
Aquí va la parte que no aparece en ningún tutorial de este tipo y que a mí me parece la más interesante de toda la aplicación.
Un feed no es un archivo. Es una ventana.
Un feed típico trae los últimos diez, veinte o cincuenta artículos. Lo que quedó fuera de ese borde no está, y nadie te avisa de que no está. Si tu refrescador se para tres días, porque desplegaste mal o porque estabas de vacaciones y apagaste la máquina, y en esos tres días un sitio activo publicó cuarenta cosas, las veinte primeras se han caído por el borde de la ventana y no van a volver jamás.
Tu lector no lo va a notar. Va a hacer su petición, va a encontrar veinte entradas nuevas, las va a guardar y va a considerar que todo ha ido bien.
La RFC 5005 define paginación y archivo de feeds para resolver esto, con enlaces rel="next" y rel="prev-archive" que te dejan recorrer hacia atrás. La realidad es que casi nadie la publica. Así que lo honesto no es resolverlo: es detectarlo y decirlo. Si en un refresco el cien por cien de las entradas del feed son nuevas para ti, lo más probable es que te hayas perdido cosas. Eso se puede marcar en la interfaz con una frase de una línea.
Y hay un segundo filo en el mismo agujero, que aparece justo el día que decides poner orden.
Un lector de feeds es la única aplicación que crece para siempre sin que nadie borre nada. Cien feeds a cinco artículos diarios son ciento ochenta mil entradas al año. Antes o después escribes una política de retención: borrar lo leído de hace más de seis meses.
Y entonces resucitan los muertos.
Porque el blog que publica una vez al trimestre todavía tiene en la ventana de su feed ese artículo de hace ocho meses que tú acabas de borrar. En el siguiente refresco, tu lector no encuentra el hash, concluye que es nuevo, y te lo pone arriba del todo sin leer.
La solución es una lápida: una tabla mínima de hashes vistos, con su fecha, que sobrevive al borrado de la entrada completa. Ocupa una fracción de lo que ocupa el artículo con su HTML, y es lo que impide que el borrado sea circular.
Revisa cómo se comporta el sistema con la ventana del feed y con la
retención:
1. Si el refrescador se para tres días y un feed activo publica más
artículos de los que caben en su ventana, ¿qué ocurre? ¿Se detecta
de alguna forma que ha habido un hueco?
2. ¿El sistema distingue "este feed no ha publicado nada" de "no hemos
podido comprobar este feed"? ¿Se ve la diferencia en la interfaz?
3. Si borro entradas leídas de hace seis meses, ¿pueden volver a
aparecer como nuevas en el siguiente refresco? Compruébalo de
verdad, no lo supongas.
4. ¿Hay algún contador o agregado guardado en base de datos que pueda
quedar desincronizado con las entradas reales?
Propón cómo garantizar que nada resucita y que los huecos son visibles.
No implementes nada todavía, solo el diagnóstico.
Y ese prompt merece una sesión nueva, con el contexto limpio. Un agente que acaba de escribir la política de retención lleva encima todas las razones por las que le pareció buena idea borrar la fila entera. No es un revisor: es el autor defendiendo su obra.
El trozo que todo el mundo olvida: el OPML ¶
Tienes el lector funcionando. Refresca bien, no duplica, sanea el HTML y no se deja apuntar a tu Redis.
Y está vacío.
Porque tus doscientas suscripciones siguen en Feedly, y añadirlas a mano es el tipo de tarea que nadie termina. Sin importación, tu proyecto de fin de semana muere el lunes.
El OPML es la puerta de entrada, y también la de salida. Es un formato XML de 2000, feo y sin cambios desde entonces, en el que cada suscripción es un elemento outline con su xmlUrl, y las carpetas son elementos outline anidados. Feedly, Inoreader, NetNewsWire, Miniflux y FreshRSS exportan e importan el mismo fichero.
<outline text="Programación">
<outline type="rss"
text="Web Reactiva"
title="Web Reactiva"
xmlUrl="https://www.webreactiva.com/rss.xml"
htmlUrl="https://www.webreactiva.com" />
</outline>
Parece media hora de trabajo y son dos, por cuatro detalles que el agente no anticipa:
- El anidamiento es arbitrario. Hay exportaciones con carpetas dentro de carpetas dentro de carpetas. Si tu modelo tiene un solo nivel, decide explícitamente qué haces: aplanar concatenando nombres es la respuesta perezosa correcta, siempre que la digas en pantalla.
- La importación tiene que ser idempotente. Importar el mismo fichero dos veces no puede darte cuatrocientas suscripciones. La clave es la
xmlUrlnormalizada, la misma normalización de la pieza 1. - No puedes validar trescientos feeds en la petición web. Un fichero de Feedly con trescientas entradas son trescientas peticiones HTTP. Importa primero contra el fichero, marca los feeds como pendientes de comprobar y valida en segundo plano.
- Muchos feeds del fichero estarán muertos. Es normal: una exportación de Feedly tiene años de sedimento. Impórtalos igual, márcalos como rotos tras los primeros fallos y deja que el usuario decida.
Y ya que estás con el XML abierto, hay una hermana pequeña que multiplica el uso diario del lector: el descubrimiento de feeds. Cuando alguien pega https://unblog.com en tu formulario, lo que espera es suscribirse, no recibir un error por no haber encontrado el .xml a mano. Se resuelve descargando el HTML y buscando el <link rel="alternate" type="application/rss+xml"> de la cabecera, con un par de rutas habituales como respaldo.
Añade importación y exportación OPML.
Requisitos:
- Importar acepta un fichero OPML de Feedly, Inoreader o cualquier otro
lector, con carpetas anidadas a cualquier profundidad. Aplana a un
solo nivel concatenando los nombres, y dilo en la pantalla de
resultado.
- La importación es idempotente: la clave es la xmlUrl normalizada.
Importar el mismo fichero dos veces no duplica ninguna suscripción.
- La importación no valida los feeds en la petición: los crea como
pendientes y los comprueba en segundo plano, con un resumen posterior
de cuántos responden y cuántos están muertos.
- Exportar genera un OPML 2.0 con carpetas, xmlUrl y htmlUrl, que otro
lector pueda importar sin tocarlo.
- Añade descubrimiento de feeds: si pego la URL de una página, busca el
link rel="alternate" con tipo application/rss+xml, application/atom+xml
o application/feed+json, y ofrece los que encuentre.
Está terminado cuando exporto mis suscripciones, borro la base de
datos, importo el fichero y tengo exactamente las mismas suscripciones
en las mismas carpetas.
Ese criterio de cierre —exportar, borrar, importar, comparar— es la mejor prueba de todo el proyecto, porque recorre el sistema entero de una punta a la otra.
Dónde mirar cuando te atasques: Miniflux como referencia viva ¶
Cuando quieras ver cómo resuelve todo esto gente que lleva una década comiendo XML malformado, tienes una referencia excelente y con las puertas abiertas: Miniflux, un lector de feeds que se define a sí mismo como “minimalista y con opiniones”.
Los datos, consultados en su repositorio en agosto de 2026: licencia Apache-2.0, unas 9.600 estrellas y 923 forks, escrito en Go y distribuido como un único binario estático sin dependencias externas, con PostgreSQL como única base de datos soportada. Lee Atom 0.3 y 1.0, RSS 1.0 y 2.0, y JSON Feed 1.0 y 1.1.
Lo que a ti te importa es que tres de las seis piezas de este post están resueltas en su código y puedes leerlas esta tarde:
- Su cascada de identidad es la de la pieza 1: SHA-256 del
guidsi existe, si no del enlace, y solo cuando no hay ni una cosa ni la otra, del título más el contenido. Fíjate en que ellos sí admiten el contenido, pero solo en ese último escalón, cuando el feed no te ha dado nada más con lo que trabajar. - Su saneador es una lista blanca explícita de etiquetas con sus atributos permitidos, con los iframes limitados a una lista fija de dominios de confianza y con
sandbox, y conrel="noopener noreferrer"yreferrerpolicy="no-referrer"en los enlaces externos. - Su planificador tiene dos estrategias,
round_robinyentry_frequency, con intervalos configurables entre cinco minutos y veinticuatro horas y lotes de cien feeds por ciclo. La de round robin, además, tiene en cuenta las cabecerasCache-Control: max-ageyExpiresdel feed.
🛡️ Mirar el código de un proyecto maduro cuando te atascas no es hacer trampa. Es lo contrario de copiar el prompt gigante: tú ya has intentado resolver la pieza, tienes el problema en la cabeza y por eso entiendes por qué ellos lo resolvieron así.
Sobre la licencia, aquí hay buenas noticias respecto a otros proyectos de esta serie: Apache-2.0 es permisiva. Puedes leer, adaptar y construir encima sin obligaciones de publicar tus cambios.
La otra referencia obligada juega en otra liga de funcionalidad y en otra de licencia. FreshRSS es AGPL-3.0, con unas 15.900 estrellas y 1.300 forks según su repositorio en agosto de 2026, escrito en PHP 8.1 o superior y capaz de funcionar sobre PostgreSQL, SQLite, MariaDB o MySQL.

Es la opción interesante si lo tuyo es alojamiento compartido, y trae dos cosas que este post ha dejado fuera de alcance a propósito y que tú vas a querer algún día: soporte de WebSub, el protocolo del W3C que le permite al publicador avisar a tu lector cuando hay algo nuevo en lugar de que tú preguntes cada cuarto de hora, y compatibilidad con las APIs de Google Reader y Fever, que es lo que le permite hablar con las aplicaciones móviles que ya existen.
Ese último punto merece un párrafo. Implementar la API de Google Reader es de lejos la funcionalidad con mejor relación entre esfuerzo y valor de todo el proyecto, porque el día que la tienes dejas de necesitar escribir una aplicación móvil: usas cualquiera de las que ya hay.
Y si lo que quieres es la comparación con el SaaS que estás replicando: el plan gratuito de Feedly se queda en cien fuentes y tres carpetas, el plan Pro cuesta unos 6 dólares al mes en facturación anual y sube el límite a mil fuentes, y el Pro+ ronda los 8,25 al mes anual o 12,99 al mes suelto, según el desglose de planes publicado por Readless en 2026. Ese salto entre cien y mil fuentes es, casi siempre, la razón por la que alguien acaba leyendo un post como este.
Cómo se genera el checklist de “esto está terminado” ¶
Has llegado hasta aquí siguiendo el post. Tienes seis piezas construidas y, repartidos entre ellas, seis criterios de “está terminado cuando…”. El problema es que están sueltos, en seis conversaciones que ya has cerrado.
Toca juntarlos en un solo sitio. Hay una forma buena de hacerlo y una mala.
La mala es pedirle al agente “dime si el proyecto está terminado”. Te va a decir que sí. Siempre dice que sí.
Los tres tipos de comprobación ¶
Cualquier checklist útil de este proyecto tiene tres cajones, y confundirlos es lo que hace que la gente acabe con listas de treinta puntos que nadie repasa.
Lo que se comprueba solo. Los tests: la cascada de identidad, el feed sin guid que se refresca dos veces, la fecha del año 2077, la respuesta 304 que no escribe nada, los seis casos hostiles del saneador, el marcado masivo que respeta el corte, la URL privada que se rechaza. Se ejecutan con un comando y devuelven verde o rojo.
Lo que se comprueba a mano en diez minutos. Exportar el OPML, borrar la base de datos, importarlo y comparar. Suscribirse a un blog pegando la URL de la página en vez de la del feed. Dejar el refrescador parado una noche y mirar qué cuenta la interfaz por la mañana.
Lo que solo se contesta con criterio. ¿Cada cuánto se refresca un feed que publica una vez al mes? ¿Qué haces con las carpetas anidadas de un OPML? ¿Enseñas las imágenes remotas directamente o las pasas por un proxy? Aquí el agente no puede ayudarte: son decisiones de producto y las tomaste tú.
El prompt que lo genera ¶
Con ese reparto claro, el prompt tiene sentido. Fíjate en que no le pido una lista: le pido un fichero verificable.
Recorre el repositorio y genera un fichero VERIFY.md con el checklist
de verificación de este proyecto antes de darlo por terminado.
Reglas para construirlo:
- Cada punto describe CÓMO se comprueba, no solo qué se comprueba. "El
saneado funciona" no vale. "Ejecutar el test X y ver que pasa" sí
vale.
- Agrupa los puntos en tres secciones: automático (un comando), manual
(menos de diez minutos) y decisión de producto (una pregunta que solo
puedo responder yo).
- Los puntos automáticos citan el fichero y el nombre del test que ya
existe. Si un criterio no tiene test que lo respalde, NO te lo
inventes: márcalo como hueco y ponlo en una lista aparte al final.
- Los puntos manuales incluyen el dato de prueba concreto: qué URL
usar, qué fichero OPML importar, qué esperar ver en pantalla.
- Las decisiones de producto van formuladas como pregunta cerrada, con
el valor implementado ahora mismo entre paréntesis.
Al final, una sección de huecos: criterios que aparecen en el código o
en los comentarios pero que nadie está comprobando.
No arregles nada. Solo el fichero.
Las dos líneas que hacen todo el trabajo son la del “cómo se comprueba” y la de “si no hay test, márcalo como hueco”. Sin la primera te devuelve una lista de deseos. Sin la segunda se inventa tests que no existen y los da por pasados.
La regla que impide que el checklist mienta ¶
Un checklist se corrompe siempre por el mismo sitio: alguien marca una casilla sin comprobarla.
Con un agente de por medio pasa a los dos minutos. Le pides que repase el fichero y te devuelve todo marcado, porque marcar es lo más parecido a completar la tarea que le has pedido.
La regla es sencilla: cada punto se cierra con una evidencia pegada al lado. La salida del comando, la captura de pantalla, la fecha en que lo probaste. Sin evidencia, la casilla sigue vacía aunque el agente jure que funciona.
Repasa VERIFY.md y ejecuta solo los puntos de la sección automática.
Para cada uno, pega debajo la salida real del comando: la de verdad, no
un resumen tuyo.
Los puntos manuales y las decisiones de producto los dejas sin tocar:
esos los cierro yo.
Si un punto falla, no lo arregles. Anótalo y sigue con el siguiente.
Ese “no lo arregles, anótalo y sigue” evita el problema clásico: el agente se atasca con el primer fallo, se le va el contexto y nunca llegas a saber si los otros diez puntos pasaban.
Las tres señales de que puedes cerrar el portátil ¶
Con el fichero cerrado, quedan tres preguntas que ningún checklist responde.
La primera: lo usas de verdad. Has cancelado la suscripción o has dejado de abrir la otra pestaña, y este es el sitio donde lees por las mañanas.
La segunda: ha sobrevivido a un mes de internet real. Un mes es tiempo suficiente para que un feed se mude, otro muera, otro cambie de formato y otro empiece a devolver fechas raras. Si a los treinta días tu lista sigue ordenada y sin duplicados, has ganado.
La tercera, y es la que más cuesta: la siguiente funcionalidad que se te ocurre está en la lista de exclusiones. Cuando te sorprendas pensando “sería fácil añadir resúmenes con IA”, ese es el momento de cerrar el portátil.
Guarda el VERIFY.md en el repositorio y repásalo antes de cada despliegue. Es lo único de todo el proyecto que te va a seguir sirviendo dentro de seis meses, cuando vuelvas a tocarlo y no recuerdes por dónde empezaba.
Y el día que Feedly cambie de precio, de dueño o de idea, tú vas a estar leyendo tus doscientos blogs igual que el día anterior. Que es, al final, de lo que iba todo esto.
Preguntas frecuentes ¶
¿Qué es un lector de feeds y para qué sirve?
Es una aplicación que se suscribe a los feeds RSS, Atom o JSON de varios sitios, descarga sus publicaciones periódicamente y te las muestra en una sola lista con estado de leído. Sirve para seguir muchas fuentes sin depender del algoritmo de ninguna red social ni del correo.
¿Cuánto se tarda en construir un lector de feeds propio con IA?
Una versión mínima con suscripciones, refrescador, entradas sin duplicados y lista de lectura cabe en unas seis horas de trabajo real. Añadir el saneado con lista blanca, la protección contra SSRF, el marcado masivo correcto y el OPML se va a un par de días.
¿Cuál es la parte más difícil de un lector de feeds?
Que tu servidor hace peticiones HTTP a URLs que escribe otra persona. Eso es un SSRF de manual: hay que validar la IP resuelta del destino, repetir la validación en cada redirección y limitar tiempo, tamaño y concurrencia de cada descarga.
¿Cómo se evita que un lector de feeds duplique artículos?
Con una identidad estable calculada por cascada: el guid de RSS, el atom:id o el id de JSON Feed si existen; si no, la URL normalizada sin parámetros de seguimiento; y como último recurso el título más la fecha. El contenido nunca entra en el hash, y la unicidad va sobre la pareja feed más hash.
¿Es obligatorio el guid en RSS?
No. La especificación de RSS 2.0 dice que todos los elementos de un item son opcionales salvo que esté presente al menos el título o la descripción. Atom, en cambio, exige exactamente un atom:id por entrada, y JSON Feed 1.1 exige un id en cada item.
¿Cada cuánto debo refrescar un feed sin molestar al servidor?
Depende de lo que publique cada sitio, y lo razonable es un intervalo adaptativo entre quince minutos y veinticuatro horas. Lo que no es opcional es la petición condicional con ETag e If-Modified-Since: si el feed no ha cambiado responde un 304 y no descargas nada.
¿Cómo se sanea de forma segura el HTML que viene en un feed?
Con lista blanca explícita de etiquetas y atributos permitidos, nunca con expresiones regulares ni listas negras. Además hay que limitar los esquemas de URL a http, https y mailto, resolver las URLs relativas contra la del artículo y añadir una Content-Security-Policy como segunda capa.
¿Qué es un fichero OPML y por qué lo necesito?
Es un XML donde cada suscripción es un elemento outline con su xmlUrl, y las carpetas son elementos anidados. Todos los lectores lo importan y lo exportan, así que es lo que te permite traerte tus feeds de Feedly y, el día de mañana, llevártelos de tu propio lector a otro sitio.
¿Merece la pena si ya existen Miniflux o FreshRSS?
Depende del objetivo. Si necesitas una herramienta para usar mañana, instala cualquiera de los dos: Miniflux es Apache-2.0 y un único binario en Go, y FreshRSS es AGPL-3.0 sobre PHP. Si lo que quieres es aprender, reconstruir un producto cuyo comportamiento ya conoces te convierte en el mejor tester posible de tu propio código.
¿Puedo dejar de pagar Feedly con esto?
Técnicamente sí, y ese suele ser el detonante: el plan gratuito se queda en cien fuentes y el Pro cuesta unos 6 dólares al mes en facturación anual. Ahora bien, mantener el tuyo tiene un coste en tiempo y en servidor, así que trátalo como un proyecto de aprendizaje que además te ahorra una suscripción, no al revés.
Fuentes ¶
- Especificación de RSS 2.0, RSS Advisory Board. Elementos opcionales del
item,guidconisPermaLink, formato RFC 822 de las fechas y elementottl. - RFC 4287, The Atom Syndication Format. Obligatoriedad y permanencia de
atom:iden cada entrada. - JSON Feed, versión 1.1. Campo
idobligatorio, estabilidad del identificador y formato RFC 3339 de las fechas. - RFC 5005, Feed Paging and Archiving. Paginación y archivo de feeds para recuperar lo que se cae de la ventana.
- Miniflux, repositorio oficial en GitHub. Licencia, stack, formatos soportados, cascada de identidad y saneador con lista blanca.
- Miniflux, parámetros de configuración. Estrategias de planificación, intervalos mínimo y máximo y tamaño de lote.
- FreshRSS, repositorio oficial en GitHub. Licencia, requisitos, WebSub y compatibilidad con las APIs de Google Reader y Fever.
- TechCrunch, “Now With 3 Million New Users, Google Reader’s Heir Apparent Feedly…”. Migración de usuarios tras el cierre de Google Reader.
- Readless, “Feedly Pricing 2026: Free vs Pro Limits”. Límites de fuentes y carpetas por plan y precios de Pro y Pro+.
🧨 Última oportunidad para recibir la dinamita que mereces sobre programación con IA el próximo domingo: Suscríbete gratis a Web Reactiva en https://webreactiva.com/newsletter
12 recursos para developers cada domingo en tu bandeja de entrada
Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.