Validador de feeds Atom

RFC 4287 comprobado en serio, incluida la herencia de author.

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 un feed Atom arriba, o descárgalo por URL, y se comprueba contra el RFC 4287. Cada hallazgo cita la sección de la que procede. Los documentos de entrada Atom sueltos, donde la raíz es <entry> en lugar de <feed>, se reconocen y se comprueban con las reglas de entrada.

Atom es más estricto que RSS, y ese es su sentido: casi todo lo que RSS dejó a la interpretación, Atom lo decidió. Exactamente un id, un title y un updated en el feed y en cada entrada. Un solo formato de fecha, sin variantes. Cada construcción de texto declara si es texto, HTML o XHTML.

Aquí hay una regla implementada como es debido que la mayoría de herramientas se salta: la herencia de author. Una entrada no necesita autor propio si tiene un <atom:source> que lleve uno, o si el feed tiene uno. Comprobar solo entry/author produce una página de errores falsos en un feed válido; no comprobar nada deja pasar un fallo real.

Por qué existe Atom, y en qué se diferencia de RSS

RSS 2.0 se congeló en 2002 y sus ambigüedades se congelaron con él. La mayor era <description>: la especificación nunca dijo si contiene texto plano o HTML, así que cada lector adivinaba. La identidad del ítem era opcional. Las fechas seguían el RFC 822, que permite años de dos dígitos y ninguna zona horaria en absoluto.

Atom se escribió en el IETF como respuesta y se publicó como RFC 4287 en diciembre de 2005. El texto lleva un type explícito. Cada feed y cada entrada tienen un id obligatorio y permanente. Las fechas son RFC 3339 con zona obligatoria. Todo vive en un único espacio de nombres, así que una extensión no puede chocar con el vocabulario del núcleo.

  • RSS: description puede ser texto o HTML, nadie sabe cuál. Atom: el type está declarado.
  • RSS: guid opcional y a menudo inestable. Atom: id obligatorio, IRI absoluta, permanente.
  • RSS: fechas RFC 822, zona opcional. Atom: RFC 3339, zona obligatoria.

Qué debe contener un feed y sus entradas

Exactamente un <id>, un <title> y un <updated> en el feed, y esos mismos tres en cada entrada. No uno o más: exactamente uno, así que un duplicado también es error. El fallo más común es un feed con título y entradas pero sin id.

Una entrada sin <content> debe llevar al menos un <link rel="alternate">: tiene que ofrecer al lector o la cosa misma o una forma de llegar a ella. Si <content> tiene un atributo src, el elemento debe estar vacío y la entrada necesita un <summary>, lo cual también se aplica al contenido en base64.

Cada <link> necesita un href, y nada puede llevar dos enlaces rel="alternate" que compartan type y hreflang, ya que un lector no podría elegir entre ellos. Un rel ausente equivale a alternate. Un feed además debería llevar un <link rel="self">, que el RFC 4287 expresa como un SHOULD, así que es una advertencia etiquetada.

La regla de herencia de author

El RFC 4287 exige que se pueda resolver un autor para cada entrada, pero no que el elemento esté en la entrada. La regla es una cadena de recursos: el <author> propio de la entrada, o un <author> dentro de su <atom:source>, o un <author> en el <feed>. El caso de source existe para agregadores que republican entradas recogidas en otro sitio.

Casi ningún validador gratuito implementa los tres pasos. Los que solo comprueban entry/author informan de un error en cada entrada de un feed de blog de un solo autor perfectamente válido, que es la forma más común de feed Atom que existe. Esta herramienta recorre la cadena e informa de error solo cuando fallan los tres.

Un documento de entrada Atom suelto no tiene feed del que heredar, así que debe llevar su propio autor. Toda construcción de persona necesita además un <name>: un <author> con solo una dirección de correo es un error según la sección 3.2.1.

Fechas e ids, exactamente como los escribe el RFC

Las marcas de tiempo de Atom son RFC 3339, y la sección 3.3 añade dos requisitos. El separador debe ser una T mayúscula, y sin desplazamiento numérico la zona debe ser una Z mayúscula. Así que 2026-03-14T09:30:00Z es válido y 2026-03-14t09:30:00z no lo es, aunque cualquier biblioteca de fechas lo analizaría. Las minúsculas tienen su propio mensaje. Una fecha a secas tampoco vale.

Un id debe ser una IRI absoluta, así que necesita esquema: https:, tag: y urn: valen todos; una ruta relativa o una cadena suelta no. Los ids duplicados entre entradas son un error, ya que los lectores deduplican por id.

Los ids que difieren solo en mayúsculas, en una barra final o en un puerto por defecto producen una advertencia, porque los ids se comparan carácter a carácter: /p/1 y /p/1/ son dos entradas. El esquema tag, el RFC 4151, evita eso, y tag:example.com,2026:post-4192 sobrevive a una mudanza de dominio.

Producir y comprobar Atom desde código

Las cuatro reglas que merece la pena automatizar son las que un esquema no puede expresar: las mayúsculas de RFC 3339, la unicidad de los ids, la herencia de author, y la disyuntiva content o enlace alternate. Cada ejemplo comprueba esas, en la forma segura de análisis.

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

const ATOM = 'http://www.w3.org/2005/Atom';

// RFC 3339 as RFC 4287 section 3.3 requires it: uppercase T, uppercase Z.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

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

const kids = (el, name) => Array.from(el.getElementsByTagNameNS(ATOM, name))
  .filter((n) => n.parentNode === el);

for (const required of ['id', 'title', 'updated']) {
  if (kids(feed, required).length !== 1) {
    console.error('feed must have exactly one <' + required + '>');
  }
}

const feedHasAuthor = kids(feed, 'author').length > 0;
const seen = new Map();

for (const [i, entry] of kids(feed, 'entry').entries()) {
  const n = i + 1;

  const updated = kids(entry, 'updated')[0];
  if (updated && !RFC3339.test(updated.textContent.trim())) {
    console.error('entry ' + n + ' updated is not RFC 3339: ' + updated.textContent.trim());
  }

  const id = kids(entry, 'id')[0];
  const value = id ? id.textContent.trim() : '';
  if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
    console.error('entry ' + n + ' id is not an absolute IRI: ' + value);
  } else if (seen.has(value)) {
    console.error('entry ' + n + ' repeats the id of entry ' + seen.get(value));
  } else {
    seen.set(value, n);
  }

  // The inheritance chain: entry/author, else entry/source/author, else feed/author.
  const source = kids(entry, 'source')[0];
  const hasAuthor = kids(entry, 'author').length > 0
    || (source ? kids(source, 'author').length > 0 : false)
    || feedHasAuthor;
  if (!hasAuthor) console.error('entry ' + n + ' has no resolvable author');

  const hasAlternate = kids(entry, 'link')
    .some((l) => (l.getAttribute('rel') || 'alternate') === 'alternate');
  if (kids(entry, 'content').length === 0 && !hasAlternate) {
    console.error('entry ' + n + ' has neither <content> nor a <link rel="alternate">');
  }
}

// Producing a correct timestamp:
console.log(new Date().toISOString());   // 2026-03-14T09:30:00.000Z
# pip install lxml requests
import re
import sys
import requests
from datetime import datetime, timezone
from lxml import etree

ATOM = 'http://www.w3.org/2005/Atom'
NS = {'a': ATOM}

# datetime.fromisoformat is too permissive for this: it accepts a lowercase
# separator, which RFC 4287 section 3.3 forbids. Check the shape directly.
RFC3339 = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$')
IRI = re.compile(r'^[A-Za-z][A-Za-z0-9+.\-]*:')

raw = requests.get('https://example.com/atom.xml', timeout=30).content
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
feed = etree.fromstring(raw, parser)

for required in ('id', 'title', 'updated'):
    if len(feed.findall('a:%s' % required, NS)) != 1:
        print('feed must have exactly one <%s>' % required)

feed_has_author = len(feed.findall('a:author', NS)) > 0
seen = {}

for n, entry in enumerate(feed.findall('a:entry', NS), start=1):
    updated = entry.findtext('a:updated', namespaces=NS) or ''
    if not RFC3339.match(updated.strip()):
        print('entry %d updated is not RFC 3339: %s' % (n, updated))

    ident = (entry.findtext('a:id', namespaces=NS) or '').strip()
    if not IRI.match(ident):
        print('entry %d id is not an absolute IRI: %s' % (n, ident))
    elif ident in seen:
        print('entry %d repeats the id of entry %d' % (n, seen[ident]))
    else:
        seen[ident] = n

    has_author = (len(entry.findall('a:author', NS)) > 0
                  or len(entry.findall('a:source/a:author', NS)) > 0
                  or feed_has_author)
    if not has_author:
        print('entry %d has no resolvable author' % n)

    alternates = [l for l in entry.findall('a:link', NS)
                  if l.get('rel', 'alternate') == 'alternate']
    if entry.find('a:content', NS) is None and not alternates:
        print('entry %d has neither <content> nor a <link rel="alternate">' % n)

# Producing a correct timestamp. Both spellings below are valid RFC 3339.
print(datetime.now(timezone.utc).isoformat(timespec='seconds'))          # ...+00:00
print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))         # ...Z
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.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

final String ATOM = "http://www.w3.org/2005/Atom";

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

DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);   // without this, every Atom lookup below finds nothing
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 feed = doc.getDocumentElement();

boolean feedHasAuthor = childCount(feed, ATOM, "author") > 0;
Map<String, Integer> seen = new HashMap<>();

NodeList entries = feed.getElementsByTagNameNS(ATOM, "entry");
for (int i = 0; i < entries.getLength(); i++) {
    Element entry = (Element) entries.item(i);
    int n = i + 1;

    String updated = firstText(entry, ATOM, "updated");
    // java.time matches literals case-sensitively, but check explicitly so the
    // lowercase case gets its own message: RFC 4287 requires "T" and "Z".
    if (updated.indexOf('t') >= 0 || updated.indexOf('z') >= 0) {
        System.err.println("entry " + n + " updated uses a lowercase T or Z: " + updated);
    } else {
        try {
            OffsetDateTime.parse(updated);
        } catch (DateTimeParseException e) {
            System.err.println("entry " + n + " updated is not RFC 3339: " + updated);
        }
    }

    String id = firstText(entry, ATOM, "id");
    if (!id.matches("[A-Za-z][A-Za-z0-9+.\\-]*:.+")) {
        System.err.println("entry " + n + " id is not an absolute IRI: " + id);
    } else if (seen.containsKey(id)) {
        System.err.println("entry " + n + " repeats the id of entry " + seen.get(id));
    } else {
        seen.put(id, n);
    }

    // entry/author, else entry/source/author, else feed/author.
    NodeList sources = entry.getElementsByTagNameNS(ATOM, "source");
    boolean sourceAuthor = sources.getLength() > 0
        && childCount((Element) sources.item(0), ATOM, "author") > 0;
    if (childCount(entry, ATOM, "author") == 0 && !sourceAuthor && !feedHasAuthor) {
        System.err.println("entry " + n + " has no resolvable author");
    }
}

// Producing a correct timestamp:
System.out.println(DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
using System.Globalization;
using System.Xml;
using System.Xml.Linq;

XNamespace atom = "http://www.w3.org/2005/Atom";

// RFC 4287 section 3.3: uppercase T, and uppercase Z when there is no offset.
// The literals are quoted so they are matched exactly rather than as specifiers.
string[] rfc3339 =
{
    "yyyy-MM-dd'T'HH:mm:ssK",
    "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK",
};

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

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

foreach (var required in new[] { "id", "title", "updated" })
{
    if (feed.Elements(atom + required).Count() != 1)
        Console.Error.WriteLine("feed must have exactly one <" + required + ">");
}

bool feedHasAuthor = feed.Elements(atom + "author").Any();
var seen = new Dictionary<string, int>(StringComparer.Ordinal);
int n = 0;

foreach (var entry in feed.Elements(atom + "entry"))
{
    n++;

    var updated = ((string?)entry.Element(atom + "updated") ?? string.Empty).Trim();
    if (!DateTimeOffset.TryParseExact(updated, rfc3339, CultureInfo.InvariantCulture,
                                      DateTimeStyles.None, out _))
    {
        Console.Error.WriteLine("entry " + n + " updated is not RFC 3339: " + updated);
    }

    var id = ((string?)entry.Element(atom + "id") ?? string.Empty).Trim();
    if (!Uri.IsWellFormedUriString(id, UriKind.Absolute))
        Console.Error.WriteLine("entry " + n + " id is not an absolute IRI: " + id);
    else if (seen.TryGetValue(id, out int first))
        Console.Error.WriteLine("entry " + n + " repeats the id of entry " + first);
    else
        seen[id] = n;

    // entry/author, else entry/source/author, else feed/author.
    bool hasAuthor = entry.Elements(atom + "author").Any()
        || entry.Elements(atom + "source").Elements(atom + "author").Any()
        || feedHasAuthor;
    if (!hasAuthor) Console.Error.WriteLine("entry " + n + " has no resolvable author");

    bool hasAlternate = entry.Elements(atom + "link")
        .Any(l => ((string?)l.Attribute("rel") ?? "alternate") == "alternate");
    if (entry.Element(atom + "content") is null && !hasAlternate)
        Console.Error.WriteLine("entry " + n + " has no content and no alternate link");
}

// Producing a correct timestamp: "o" on a UTC DateTime ends in Z.
Console.WriteLine(DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture));
# RFC 4287 Appendix B carries a RELAX NG Compact schema for Atom. trang
# converts it to the XML syntax xmllint understands.
trang atom.rnc atom.rng
xmllint --noout --nonet --relaxng atom.rng feed.xml

# That schema is informative and cannot express every rule in the RFC. Author
# inheritance and id uniqueness are two of the rules it cannot state, so check
# them separately. Entries carrying no author of their own:
xmlstarlet sel -N a=http://www.w3.org/2005/Atom \
  -t -v 'count(//a:entry[not(a:author) and not(a:source/a:author)])' -n feed.xml
# If that is not zero, the <feed> element itself needs an <author>.

# Duplicate entry ids. Any output at all is a bug:
xmlstarlet sel -N a=http://www.w3.org/2005/Atom \
  -t -m '//a:entry/a:id' -v . -n feed.xml | sort | uniq -d

# Timestamps with a lowercase t or z, which RFC 4287 section 3.3 forbids:
grep -n -E '<(updated|published)>[^<]*[tz]' feed.xml

Merece la pena ejecutar el esquema RELAX NG del RFC 4287, pero conoce sus límites. Es informativo y no normativo, y una gramática no puede expresar restricciones que abarcan varios elementos: la herencia de author, la unicidad de ids y la regla del summary hay que comprobarlas en código.

Preguntas frecuentes

¿Cuál es la diferencia entre RSS y Atom?

Atom es el formato posterior y el más estricto. Es un estándar del IETF, el RFC 4287, publicado en diciembre de 2005; RSS 2.0 se congeló en 2002 y se mantiene como documento de especificación más que como estándar.

Las diferencias que importan van de ambigüedad. En RSS nadie puede decir si una <description> contiene texto o HTML; en Atom cada construcción de texto declara su tipo. RSS hace opcional la identidad del ítem y permite varias escrituras de fecha; Atom exige un id permanente y un solo formato de fecha con zona.

¿Por qué el validador dice que mi entrada no tiene autor si el feed sí lo tiene?

No debería, y si lo dice es porque el elemento author seguramente no está donde crees. La regla implementada aquí es la cadena del RFC 4287: el <author> propio de la entrada, o uno dentro de su <atom:source>, o uno en el elemento <feed>.

La causa habitual de la sorpresa es el espacio de nombres. Un <author> que no esté en http://www.w3.org/2005/Atom, por ejemplo un dc:creator de Dublin Core, no es un author de Atom y no satisface la regla. La otra es la colocación: un <author> dentro del primer <entry> cubre solo esa entrada.

¿Por qué 2026-03-14 no es una fecha Atom válida?

Porque la sección 3.3 del RFC 4287 exige una fecha y hora completa de RFC 3339, no una fecha. Las marcas de tiempo de Atom necesitan la hora y una zona: 2026-03-14T09:30:00Z, o 2026-03-14T09:30:00+05:30 para un desplazamiento.

Hay dos detalles más que son MUST explícitos. El separador debe ser una T mayúscula, y sin desplazamiento numérico la zona debe ser una Z mayúscula. Una t o una z minúsculas se analizan sin problema en cualquier biblioteca de fechas y siguen siendo inválidas, y por eso tienen su propio mensaje de error.

¿Qué debería usar como id?

Algo globalmente único que nunca vayas a necesitar cambiar. La especificación es tajante: un id no debe cambiar cuando la entrada se reubica o se republica, porque los lectores comparan ids carácter a carácter para decidir qué es nuevo.

El esquema de tag URI, el RFC 4151, se diseñó para esto. tag:example.com,2026:post-4192 es único, no se espera que resuelva, y no ata la entrada a donde vive ahora. Una URL de página funciona pero te compromete: añadir una barra final o pasar a https cambia la identidad de cada entrada, y los suscriptores reciben el archivo otra vez.

¿Se envía mi feed a algún sitio cuando lo valido aquí?

No. El analizador y todas las reglas del RFC 4287 son JavaScript ejecutándose en esta pestaña. No hay componente de servidor ni analítica con acceso al editor. Abre el panel de red y valida algo: los recursos de la página se cargan una vez y después nada.

Los feeds que la gente pega en un validador son justo los que todavía no funcionan: un sitio sin lanzar, un feed cuyos enlaces llevan tokens de acceso, un feed de cliente bajo acuerdo de confidencialidad. El servicio del W3C es de servidor, así que todo lo que se comprueba allí se transmite. Aquí Descargar va de tu navegador directamente al host que hayas nombrado.

¿Puede comprobar un feed Atom 0.3?

No, y lo dice claramente. Atom 0.3 usaba el espacio de nombres http://purl.org/atom/ns#, y un feed que lo declare se informa como error nombrando la versión, junto con el espacio de nombres 1.0 que necesitas en su lugar.

Atom 0.3 era un borrador preestándar que el RFC 4287 sustituyó en 2005, y por el camino cambiaron nombres de elementos: 0.3 tiene <modified> e <issued> donde 1.0 tiene <updated> y <published>, y <tagline> pasó a ser <subtitle>. Cambiar solo el espacio de nombres produce un documento inválido de una forma nueva, así que el mensaje te dice que revises también los nombres de elemento.

Herramientas relacionadas

Lecturas de referencia