Validatore di feed RSS
Ogni regola di RSS 2.0, ciascuna con il suo riferimento.
Tutto viene eseguito in questa scheda. Nulla di ciò che incolli viene caricato, registrato o inviato da qualche parte. Apri il pannello di rete e verifica.
Incolla il tuo feed qui sopra, oppure inserisci il suo indirizzo e premi Scarica, e viene controllato rispetto alla specifica RSS 2.0. Ogni rilievo nomina l’elemento, cita il valore e richiama la regola. Quest’ultimo punto conta: circa un terzo delle cose per cui un feed viene rimproverato sono convenzioni su cui i lettori fanno affidamento, non violazioni della specifica, e qui vengono etichettate come tali.
Probabilmente sei qui perché qualcosa a valle ha rifiutato il feed. Un lettore mostra il canale ma nessun episodio. Un servizio da RSS a e-mail lo rifiuta. Un abbonato dice che tutti i vecchi post sono ricomparsi come nuovi. Ognuno di questi casi ha un breve elenco di cause probabili, e tutte vengono controllate.
Questa pagina esiste perché lo strumento canonico si sta decomponendo in pubblico. Verificato a settembre 2026, feedvalidator.org serviva un certificato TLS scaduto, che i browser rifiutano di aprire, e il suo mirror su SourceForge riporta un copyright 2002-2004 e mette ancora in vetrina Atom 0.3. Il validatore del W3C funziona, ma è lato server: il tuo feed finisce sulla loro macchina.
Che cosa richiede davvero RSS 2.0
Meno di quanto quasi tutti si aspettino. La radice è <rss> con version="2.0", che contiene esattamente un <channel>. Il channel ha bisogno di title, link e description, e il link deve essere una URL assoluta. Gli item sono più liberi: almeno uno fra title e description, e nient’altro è obbligatorio.
Quello che la specifica non consente è la ripetizione. Due elementi <title> in un channel, o due <link> in un item, sono errori, e il secondo viene segnalato con la sua posizione. Un controllo strutturale coglie uno sbaglio facile: elementi <item> appesi direttamente sotto <rss> invece che dentro <channel>. Si analizzano, e nessun lettore li mostrerà mai.
- Channel: title, link e description obbligatori.
- Item: almeno uno fra title e description.
- Gli elementi unici non devono ripetersi in un channel né in un item.
- Gli elementi centrali di RSS stanno senza namespace, quindi <atom:link> non è un <link> RSS.
Le date sono RFC 822, non ISO 8601
L’errore che in RSS sembra più corretto di tutti. pubDate e lastBuildDate devono essere RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. Quello che la maggior parte dei framework emette per impostazione predefinita è ISO 8601, 2024-10-02T13:00:00Z, giusto per Atom e sbagliato qui. Un lettore che non riesce ad analizzare un pubDate nasconde l’item o lo ordina nel posto sbagliato.
Vengono controllate quattro cose. Se la data si analizza. Se il giorno della settimana indicato concorda con la data, dato che una discrepanza è l’impronta di un formattatore scritto a mano. Se l’anno è a due cifre, cosa che RSS permette e che gli aggregatori leggono male. E se è presente un fuso, dato che la RFC 822 consente di ometterlo e i lettori a quel punto tirano a indovinare in modi diversi.
Ogni linguaggio ha una risposta in una sola chiamata, usata in ciascun esempio qui sotto: toUTCString, format_datetime, RFC_1123_DATE_TIME, lo specificatore "r", DATE_RSS.
guid, e perché gli abbonati vedono due volte i tuoi vecchi post
Un guid è l’identità di un item. I lettori lo usano per decidere se qualcosa è nuovo o già mostrato, quindi deve essere stabile e univoco. guid duplicati sono un errore: un lettore tratta i due item come uno solo e ne mostra uno.
La trappola è isPermaLink, il cui valore predefinito è true: un nudo <guid>abc-123</guid> afferma quindi che abc-123 è una URL risolvibile. Un identificatore opaco richiede isPermaLink="false" scritto esplicitamente, e un guid non-URL senza di esso è un errore. Un item senza guid è un avviso: i lettori ripiegano sull’abbinamento per link o per title.
La versione catastrofica è un plugin che rigenera i guid a ogni build, di solito da qualcosa di volatile come il permalink. Ogni abbonato riceve l’intero catalogo tutto in una volta.
E commerciali, enclosure, e i controlli che non si possono fare
Il modo più comune in cui un feed si rompe è una & nuda dentro una URL, come in p?id=12&sort=asc. In XML una e commerciale apre un riferimento a entità, quindi l’analisi fallisce e i lettori rifiutano l’intero documento invece del singolo item. Ottieni l’errore di sintassi con la sua posizione, e una nota che dice che nessuna regola RSS ha potuto essere eseguita.
Un <enclosure> ha bisogno di url, length e type. length è la dimensione in byte come numero intero, e 0 è un avviso perché significa che il generatore non è riuscito a fare stat del file. Due enclosure su uno stesso item è un avviso del Best Practices Profile, non della specifica.
Sul channel si verifica la presenza di un <atom:link rel="self">, che non è affatto un elemento RSS ma su cui gli aggregatori si appoggiano. Tre controlli compaiono come non eseguiti, dato che un browser non può farli: se rel="self" corrisponde al punto in cui il feed viene servito, e se URL e length degli enclosure sono corretti.
Controllare un feed da codice
Vale la pena farlo girare nella pipeline di pubblicazione, perché i modi di rottura qui sopra sono silenziosi: il feed continua a compilarsi e a essere servito, e lo scopri da un abbonato. Ogni esempio controlla la regola sull’item, il formato della data e il 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";Ogni esempio disattiva la risoluzione delle entità esterne, perché un feed è un documento che recuperi altrove e che non hai scritto tu. In Java e in PHP il comportamento non sicuro è quello predefinito.
Domande frequenti
Il mio feed funziona in un lettore e non in un altro. Che cosa c’è che non va?
Di solito una data, un guid o la codifica. I lettori differiscono enormemente in ciò che perdonano, quindi un feed che Feedly mostra senza battere ciglio può essere rifiutato da un client più severo.
Procedi con ordine. Controlla che il documento sia XML ben formato, perché una e commerciale nuda rompe tutto ciò che viene dopo. Poi controlla il pubDate di ogni item, perché una data non analizzabile fa sparire gli item o li ordina in modo strano. Poi l’unicità dei guid. Se il feed è pulito qui e un lettore continua a comportarsi male, la causa è il trasporto: un Content-Type sbagliato, una catena di redirect, o la cache.
feedvalidator.org è ancora lo strumento da usare?
È stato la risposta canonica per vent’anni, ma a settembre 2026 serve un certificato TLS scaduto, che i browser bloccano. Il suo mirror su SourceForge riporta un copyright 2002-2004 e pubblicizza ancora Atom 0.3, che la RFC 4287 ha sostituito nel 2005.
Il Feed Validation Service del W3C è mantenuto e il suo catalogo di errori è accurato. I suoi limiti sono l’età e l’architettura: messaggi asciutti, e il tuo feed spedito sul loro server. Questa pagina riproduce i controlli di quel catalogo che corrispondono a rotture reali e li esegue in locale.
Che formato di data serve a RSS?
RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. Uno scostamento numerico come +0000 è altrettanto valido al posto del nome del fuso. Il giorno della settimana è facoltativo, ma se lo metti dev’essere quello giusto.
ISO 8601, cioè 2024-10-02T13:00:00Z, non è valido in RSS. Lo è in Atom, ed è per questo che tanti generatori lo emettono: qualcuno ha copiato un helper per le date da un template Atom. Evita gli anni a due cifre e includi sempre un fuso orario.
Perché tutti i miei vecchi post sono ricomparsi come nuovi agli abbonati?
Qualcosa ha cambiato il guid di ogni item. Un lettore tiene l’elenco dei guid che ha già mostrato, e tutto ciò che non è in quell’elenco è nuovo. Cambiali tutti in una volta e ogni abbonato riceve di nuovo l’archivio, che per un podcast significa ogni episodio scaricato su ogni dispositivo.
La causa è un guid ricavato da qualcosa di impermanente: uno costruito sul permalink si rompe quando passi a https, uno costruito sul titolo si rompe quando correggi un refuso. Usa una chiave di database, un UUID o una tag URI, con isPermaLink="false".
Il mio feed viene inviato a un server?
No. Il parser e ogni regola girano in questa scheda come JavaScript. Non c’è alcun back end a cui inviare qualcosa e nessun analytics con accesso all’editor. Apri il pannello di rete mentre convalidi e guardalo restare vuoto.
Sembra poco importante per un formato pubblico come RSS, finché non si guarda quali feed la gente incolla in un validatore: programmi non ancora lanciati, feed podcast privati le cui URL di enclosure portano un token per abbonato, feed di staging pieni di bozze.
Convalida i feed podcast e i tag iTunes?
Convalida il livello RSS 2.0, da cui parte gran parte dei rifiuti dei podcast: un pubDate malformato, un guid duplicato, un enclosure senza length o type. Qui quelle regole contano in modo particolare, dato che l’enclosure è l’episodio.
Quello che non controlla è il namespace itunes: itunes:image e le sue regole sulle dimensioni, itunes:category, itunes:explicit, itunes:duration. Restano intoccati a meno che il loro prefisso non sia dichiarato. Una regola di Apple vale la pena ripeterla: ogni episodio ha bisogno di un identificatore globalmente univoco che non cambi mai, ed è il controllo sul guid qui sopra.