Validador de feeds RSS

Todas las reglas de RSS 2.0, cada una con su referencia.

Se descarga directamente del origen. Muchos servidores lo bloquean; si el tuyo lo hace, pega el contenido.
Entrada
En esperaPega un documento para comprobarlo. La validación se ejecuta mientras escribes.

Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.

Pega tu feed arriba, o escribe su dirección y pulsa Descargar, y se comprueba contra la especificación RSS 2.0. Cada hallazgo nombra el elemento, cita el valor y referencia la regla. Esto último importa: alrededor de un tercio de las cosas por las que se regaña a un feed son convenciones en las que se apoyan los lectores y no violaciones de la especificación, y aquí se etiquetan como tales.

Probablemente estás aquí porque algo aguas abajo rechazó el feed. Un lector muestra el canal pero ningún episodio. Un servicio de RSS a correo se niega a aceptarlo. Un suscriptor dice que todas las entradas antiguas han reaparecido como nuevas. Cada caso tiene una lista corta de causas probables, y todas se comprueban.

Esta página existe porque la herramienta canónica se está descomponiendo en público. Comprobado en septiembre de 2026, feedvalidator.org servía un certificado TLS caducado, así que los navegadores se niegan a abrirlo, y su réplica en SourceForge tiene copyright de 2002 a 2004 y aún encabeza con Atom 0.3. El validador del W3C funciona, pero es de servidor: tu feed va a su máquina.

Qué exige realmente RSS 2.0

Menos de lo que casi todo el mundo espera. La raíz es <rss> con version="2.0", conteniendo exactamente un <channel>. El canal necesita title, link y description, y el link debe ser una URL absoluta. Los ítems son más laxos: al menos uno de title o description, y nada más es obligatorio.

Lo que la especificación no permite es la repetición. Dos elementos <title> en un canal, o dos <link> en un ítem, son errores, y el segundo se informa con su posición. Una comprobación estructural detecta un fallo fácil: elementos <item> colgando directamente de <rss> en lugar de estar dentro de <channel>. Se analizan sin problema, y ningún lector los mostrará jamás.

  • Canal: title, link y description obligatorios.
  • Ítem: al menos uno de title o description.
  • Los elementos únicos no deben repetirse en un canal ni en un ítem.
  • Los elementos del núcleo de RSS van sin espacio de nombres, así que <atom:link> no es un <link> de RSS.

Las fechas son RFC 822, no ISO 8601

El error que más parece correcto en RSS. pubDate y lastBuildDate deben ser RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. Lo que la mayoría de frameworks emiten por defecto es ISO 8601, 2024-10-02T13:00:00Z, que es lo correcto para Atom y lo incorrecto aquí. Un lector que no puede analizar un pubDate oculta el ítem o lo ordena en el sitio equivocado.

Se comprueban cuatro cosas. Si se analiza siquiera. Si el día de la semana nombrado concuerda con la fecha, ya que un desajuste es la huella de un formateador escrito a mano. Si el año tiene dos dígitos, cosa que RSS permite y los agregadores malinterpretan. Y si hay zona horaria, ya que RFC 822 permite omitirla y entonces cada lector adivina distinto.

Todos los lenguajes tienen una respuesta de una sola llamada, usada en cada ejemplo de abajo: toUTCString, format_datetime, RFC_1123_DATE_TIME, el especificador "r", DATE_RSS.

guid, y por qué los suscriptores ven tus entradas antiguas dos veces

Un guid es la identidad de un ítem. Los lectores lo usan para decidir si algo es nuevo o ya se mostró, así que debe ser estable y único. Los guids duplicados son un error: un lector trata los dos ítems como uno y muestra solo uno de ellos.

La trampa es isPermaLink, cuyo valor por defecto es true, así que un <guid>abc-123</guid> a secas afirma que abc-123 es una URL resoluble. Un identificador opaco necesita isPermaLink="false" escrito explícitamente, y un guid que no es URL sin eso es un error. Un ítem sin guid es una advertencia: los lectores recurren a emparejar por link o por title.

La versión catastrófica es un plugin que regenera los guids en cada build, normalmente a partir de algo volátil como el enlace permanente. Todos los suscriptores reciben el catálogo entero de golpe.

Ampersands, enclosures, y las comprobaciones que no se pueden hacer

La forma más común de romper un feed es un & suelto dentro de una URL, como en p?id=12&sort=asc. En XML un ampersand empieza una referencia a entidad, así que el análisis falla y los lectores rechazan el documento entero en vez de ese único ítem. Obtienes el error de sintaxis con su posición, y una nota de que no se ha podido ejecutar ninguna regla de RSS.

Un <enclosure> necesita url, length y type. length es el tamaño en bytes como número entero, y 0 es una advertencia porque significa que el generador no pudo hacer stat del archivo. Dos enclosures en un ítem es una advertencia del Best Practices Profile, no de la especificación.

Se comprueba si el canal tiene un <atom:link rel="self">, que no es un elemento de RSS en absoluto pero en el que se apoyan los agregadores. Tres comprobaciones se listan como no ejecutadas, ya que un navegador no puede hacerlas: si rel="self" coincide con dónde se sirve el feed, y si las URLs y los length de los enclosures son correctos.

Comprobar un feed desde código

Merece la pena ejecutarlo en tu pipeline de publicación, porque los modos de fallo de arriba son silenciosos: el feed se sigue construyendo y sirviendo, y te enteras por un suscriptor. Cada ejemplo comprueba la regla del ítem, el formato de fecha y el guid.

// Node 18 or later.  npm i @xmldom/xmldom
import { DOMParser } from '@xmldom/xmldom';

const xml = await (await fetch('https://example.com/feed.xml')).text();
const doc = new DOMParser().parseFromString(xml, 'text/xml');

const channel = doc.getElementsByTagName('channel').item(0);
for (const required of ['title', 'link', 'description']) {
  if (channel.getElementsByTagName(required).length === 0) {
    console.error('channel has no <' + required + '>');
  }
}

// RFC 822, not ISO 8601. Anything with a "T" separator is the wrong format.
const RFC822 = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}(:\d{2})? (GMT|UT|[A-Z]{3}|[+-]\d{4})$/;

const items = channel.getElementsByTagName('item');
for (let i = 0; i < items.length; i++) {
  const item = items.item(i);
  const text = (name) => {
    const el = item.getElementsByTagName(name).item(0);
    return el ? el.textContent.trim() : null;
  };

  if (!text('title') && !text('description')) {
    console.error('item ' + (i + 1) + ' has neither a title nor a description');
  }

  const pubDate = text('pubDate');
  if (pubDate && !RFC822.test(pubDate)) {
    console.error('item ' + (i + 1) + ' pubDate is not RFC 822: ' + pubDate);
  }

  const guid = item.getElementsByTagName('guid').item(0);
  if (guid && guid.getAttribute('isPermaLink') !== 'false'
      && !/^https?:\/\//.test(guid.textContent.trim())) {
    console.error('item ' + (i + 1) + ' guid defaults to isPermaLink="true" but is not a URL');
  }
}

// Producing a correct date is one call:
console.log(new Date().toUTCString());   // Wed, 02 Oct 2024 13:00:00 GMT
# pip install lxml requests
import sys
import requests
from email.utils import format_datetime, parsedate_to_datetime
from datetime import datetime, timezone
from lxml import etree

raw = requests.get('https://example.com/feed.xml', timeout=30).content

# resolve_entities=False and no_network=True are the safe form.
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
root = etree.fromstring(raw, parser)

channel = root.find('channel')
if channel is None:
    sys.exit('No <channel> element')

for required in ('title', 'link', 'description'):
    if channel.find(required) is None:
        print('channel has no <%s>' % required)

for i, item in enumerate(channel.findall('item'), start=1):
    if item.find('title') is None and item.find('description') is None:
        print('item %d has neither a title nor a description' % i)

    pub = item.findtext('pubDate')
    if pub:
        try:
            # parsedate_to_datetime is the RFC 2822 parser, and it rejects
            # ISO 8601 outright, which is exactly what you want here.
            parsedate_to_datetime(pub)
        except (TypeError, ValueError):
            print('item %d pubDate is not RFC 822: %s' % (i, pub))

    guid = item.find('guid')
    if guid is not None and guid.get('isPermaLink', 'true') == 'true':
        if not (guid.text or '').startswith(('http://', 'https://')):
            print('item %d guid is treated as a permalink but is not a URL' % i)

# Producing a correct date:
print(format_datetime(datetime.now(timezone.utc)))
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

byte[] bytes = HttpClient.newHttpClient()
    .send(HttpRequest.newBuilder(URI.create("https://example.com/feed.xml")).build(),
          HttpResponse.BodyHandlers.ofByteArray())
    .body();

DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setXIncludeAware(false);

Document doc = f.newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
Element channel = (Element) doc.getElementsByTagName("channel").item(0);

for (String required : new String[] { "title", "link", "description" }) {
    if (channel.getElementsByTagName(required).getLength() == 0) {
        System.err.println("channel has no <" + required + ">");
    }
}

NodeList items = channel.getElementsByTagName("item");
for (int i = 0; i < items.getLength(); i++) {
    Element item = (Element) items.item(i);
    boolean hasTitle = item.getElementsByTagName("title").getLength() > 0;
    boolean hasDesc = item.getElementsByTagName("description").getLength() > 0;
    if (!hasTitle && !hasDesc) {
        System.err.println("item " + (i + 1) + " has neither a title nor a description");
    }

    NodeList pub = item.getElementsByTagName("pubDate");
    if (pub.getLength() > 0) {
        String v = pub.item(0).getTextContent().trim();
        try {
            // RFC_1123_DATE_TIME is the RFC 822 profile RSS wants, and it
            // refuses ISO 8601, so a wrong-format date fails loudly here.
            DateTimeFormatter.RFC_1123_DATE_TIME.parse(v);
        } catch (DateTimeParseException e) {
            System.err.println("item " + (i + 1) + " pubDate is not RFC 822: " + v);
        }
    }
}

// Producing a correct date:
System.out.println(DateTimeFormatter.RFC_1123_DATE_TIME
    .format(ZonedDateTime.now(ZoneOffset.UTC)));
using System.Globalization;
using System.Xml;
using System.Xml.Linq;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,  // external entities are never fetched
    XmlResolver = null,
};

var bytes = await new HttpClient().GetByteArrayAsync("https://example.com/feed.xml");
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream, settings);
var doc = XDocument.Load(reader);

// RSS core elements are in no namespace, so plain element names are correct here.
var channel = doc.Root?.Element("channel");
if (channel is null) throw new InvalidOperationException("No <channel> element");

foreach (var required in new[] { "title", "link", "description" })
{
    if (channel.Element(required) is null)
        Console.Error.WriteLine("channel has no <" + required + ">");
}

int n = 0;
foreach (var item in channel.Elements("item"))
{
    n++;
    if (item.Element("title") is null && item.Element("description") is null)
        Console.Error.WriteLine("item " + n + " has neither a title nor a description");

    var pub = (string?)item.Element("pubDate");
    if (pub is not null)
    {
        // .NET has no dedicated RFC 822 parser. TryParse handles the usual
        // spellings but also accepts ISO 8601, which RSS forbids, so reject
        // anything carrying a "T" separator before parsing.
        if (pub.Contains('T') ||
            !DateTimeOffset.TryParse(pub, CultureInfo.InvariantCulture,
                                     DateTimeStyles.None, out _))
        {
            Console.Error.WriteLine("item " + n + " pubDate is not RFC 822: " + pub);
        }
    }

    var guid = item.Element("guid");
    if (guid is not null && (string?)guid.Attribute("isPermaLink") != "false"
        && !guid.Value.StartsWith("http", StringComparison.Ordinal))
    {
        Console.Error.WriteLine("item " + n + " guid is a permalink by default but is not a URL");
    }
}

// Producing a correct date: "r" is the RFC 1123 pattern RSS accepts.
Console.WriteLine(DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture));
<?php
// LIBXML_NONET stops libxml fetching anything the document references.
libxml_use_internal_errors(true);

$raw = file_get_contents('https://example.com/feed.xml');
$feed = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NONET);

if ($feed === false) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "XML error at line %d: %s", $e->line, trim($e->message) . "\n");
    }
    exit(1);
}

foreach (['title', 'link', 'description'] as $required) {
    if (!isset($feed->channel->{$required})) {
        fprintf(STDERR, "channel has no <%s>\n", $required);
    }
}

$n = 0;
foreach ($feed->channel->item as $item) {
    $n++;

    if (!isset($item->title) && !isset($item->description)) {
        fprintf(STDERR, "item %d has neither a title nor a description\n", $n);
    }

    if (isset($item->pubDate)) {
        $raw_date = (string) $item->pubDate;
        // DATE_RSS expects a numeric offset; the second pattern covers "GMT".
        $parsed = DateTimeImmutable::createFromFormat(DATE_RSS, $raw_date)
            ?: DateTimeImmutable::createFromFormat('D, d M Y H:i:s T', $raw_date);
        if ($parsed === false) {
            fprintf(STDERR, "item %d pubDate is not RFC 822: %s\n", $n, $raw_date);
        }
    }

    $guid = $item->guid;
    if ($guid !== null && (string) ($guid['isPermaLink'] ?? 'true') !== 'false'
        && !preg_match('#^https?://#', (string) $guid)) {
        fprintf(STDERR, "item %d guid defaults to isPermaLink=true but is not a URL\n", $n);
    }
}

// Producing a correct date: DATE_RSS is RFC 822.
echo (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_RSS), "\n";

Todos los ejemplos desactivan la resolución de entidades externas, porque un feed es un documento que descargas de otro sitio y que no escribiste tú. En Java y en PHP el comportamiento inseguro es el predeterminado.

Preguntas frecuentes

Mi feed funciona en un lector y en otro no. ¿Qué le pasa?

Normalmente una fecha, un guid o la codificación. Los lectores difieren enormemente en lo que perdonan, así que un feed que Feedly renderiza tan contento puede ser rechazado por un cliente más estricto.

Repásalo en orden. Comprueba que el documento sea XML bien formado siquiera, ya que un ampersand suelto rompe todo lo que viene después. Luego comprueba el pubDate de cada ítem, porque una fecha inanalizable hace que los ítems desaparezcan u ordenen de forma extraña. Después comprueba que los guids sean únicos. Si el feed está limpio aquí y aun así un lector se porta mal, la causa es el transporte: un Content-Type equivocado, una cadena de redirecciones o la caché.

¿Sigue siendo feedvalidator.org la herramienta que debería usar?

Fue la respuesta canónica durante veinte años, pero a septiembre de 2026 sirve un certificado TLS caducado, así que los navegadores lo bloquean. Su réplica en SourceForge tiene copyright de 2002 a 2004 y todavía anuncia Atom 0.3, que el RFC 4287 sustituyó en 2005.

El Feed Validation Service del W3C se mantiene y su catálogo de errores es minucioso. Sus límites son la edad y la arquitectura: mensajes escuetos, y tu feed enviado a su servidor. Esta página reproduce las comprobaciones de ese catálogo que se corresponden con roturas reales y las ejecuta en local.

¿Qué formato de fecha necesita RSS?

RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. Un desplazamiento numérico como +0000 es igual de válido en lugar del nombre de la zona. El día de la semana es opcional, pero si lo incluyes tiene que ser el correcto.

ISO 8601, es decir 2024-10-02T13:00:00Z, no es válido en RSS. Sí lo es en Atom, y por eso tantos generadores lo emiten: alguien copió un ayudante de fechas de una plantilla de Atom. Evita los años de dos dígitos, e incluye siempre una zona horaria.

¿Por qué todas mis entradas antiguas han reaparecido como nuevas para los suscriptores?

Algo cambió el guid de todos los ítems. Un lector guarda una lista de guids que ya ha mostrado, y cualquier cosa que no esté en ella es nueva. Cámbialos todos de golpe y cada suscriptor recibe el archivo otra vez, lo que en un pódcast significa cada episodio descargándose en cada dispositivo.

La causa es un guid derivado de algo impermanente: uno construido a partir del enlace permanente se rompe cuando pasas a https, uno construido a partir del título se rompe cuando corriges una errata. Usa una clave de base de datos, un UUID o una tag URI, con isPermaLink="false".

¿Se envía mi feed a un servidor?

No. El analizador y todas las reglas corren en esta pestaña como JavaScript. No hay backend al que enviar nada ni analítica con acceso al editor. Abre tu panel de red mientras validas y míralo seguir vacío.

Parece poco importante para un formato tan público como RSS, hasta que piensas en qué feeds pega la gente en un validador: programas que no se han lanzado, feeds privados de pódcast cuyas URLs de enclosure llevan un token por suscriptor, feeds de staging llenos de borradores.

¿Valida feeds de pódcast y etiquetas de iTunes?

Valida la capa RSS 2.0, que es donde empieza buena parte de los rechazos de pódcast: un pubDate mal formado, un guid duplicado, un enclosure al que le falta length o type. Esas reglas importan especialmente aquí, ya que el enclosure es el episodio.

Lo que no comprueba es el espacio de nombres itunes: itunes:image y sus reglas de dimensiones, itunes:category, itunes:explicit, itunes:duration. Se dejan en paz salvo que su prefijo no esté declarado. Merece la pena repetir una regla de Apple: cada episodio necesita un identificador globalmente único que no cambie nunca, que es la comprobación de guid de arriba.

Herramientas relacionadas

Lecturas de referencia

Errores que esto resuelve