Validateur de flux RSS

Toutes les règles RSS 2.0, chacune avec sa référence.

Récupéré directement depuis l’origine. Beaucoup de serveurs le bloquent ; si c’est le cas, collez le contenu.
Entrée
En attenteCollez un document pour le vérifier. La validation se fait à la frappe.

Tout s'exécute dans cet onglet. Rien de ce que vous collez n'est envoyé, journalisé ni transmis où que ce soit. Ouvrez votre panneau réseau et vérifiez.

Collez votre flux ci-dessus, ou saisissez son adresse et appuyez sur Récupérer : il est contrôlé au regard de la spécification RSS 2.0. Chaque constat nomme l’élément, cite la valeur et rappelle la règle. Ce dernier point compte : environ un tiers des reproches faits à un flux portent sur des conventions dont dépendent les lecteurs plutôt que sur des violations de la spécification, et elles sont étiquetées comme telles.

Vous êtes sans doute ici parce que quelque chose en aval a rejeté le flux. Un lecteur affiche la chaîne mais aucun épisode. Un service RSS-vers-e-mail le refuse. Un abonné dit que tous les anciens articles sont réapparus comme neufs. Chacun de ces cas a une courte liste de causes probables, et toutes sont vérifiées.

Cette page existe parce que l’outil canonique se décompose en public. Vérifié en septembre 2026, feedvalidator.org servait un certificat TLS expiré, que les navigateurs refusent d’ouvrir, et son miroir SourceForge porte un copyright 2002-2004 et met encore Atom 0.3 en avant. Le validateur du W3C fonctionne, mais il est côté serveur : votre flux part sur leur machine.

Ce que RSS 2.0 exige réellement

Moins qu’on ne le croit. La racine est <rss> avec version="2.0", contenant exactement un <channel>. Le channel a besoin d’un title, d’un link et d’une description, et le link doit être une URL absolue. Les items sont plus souples : au moins l’un de title ou description, et rien d’autre n’est requis.

Ce que la spécification n’autorise pas, c’est la répétition. Deux éléments <title> dans un channel, ou deux <link> dans un item, sont des erreurs, et le second est signalé avec sa position. Un contrôle structurel attrape une faute facile : des éléments <item> placés directement sous <rss> au lieu d’être dans <channel>. Ils s’analysent, et aucun lecteur ne les affichera jamais.

  • Channel : title, link et description obligatoires.
  • Item : au moins l’un de title ou description.
  • Les éléments uniques ne doivent pas se répéter dans un channel ni dans un item.
  • Les éléments du cœur de RSS sont sans espace de noms, donc <atom:link> n’est pas un <link> RSS.

Les dates sont en RFC 822, pas en ISO 8601

La faute qui a le plus l’air correcte en RSS. pubDate et lastBuildDate doivent être en RFC 822 : Wed, 02 Oct 2024 13:00:00 GMT. Ce que la plupart des frameworks émettent par défaut, c’est de l’ISO 8601, 2024-10-02T13:00:00Z, qui est juste pour Atom et faux ici. Un lecteur qui ne peut pas analyser un pubDate masque l’item ou le trie au mauvais endroit.

Quatre choses sont vérifiées. Que la date s’analyse tout court. Que le jour de la semaine nommé concorde avec la date, car un décalage est la signature d’un formateur écrit à la main. Que l’année ne tienne pas sur deux chiffres, ce que RSS permet et que les agrégateurs lisent de travers. Et qu’un fuseau soit présent, puisque la RFC 822 permet de l’omettre et que les lecteurs devinent alors différemment.

Chaque langage a une réponse en un appel, utilisée dans chacun des exemples ci-dessous : toUTCString, format_datetime, RFC_1123_DATE_TIME, le spécificateur « r », DATE_RSS.

guid, et pourquoi vos abonnés voient deux fois vos vieux articles

Un guid est l’identité d’un item. Les lecteurs s’en servent pour décider si quelque chose est nouveau ou déjà montré : il doit donc être stable et unique. Des guids en double sont une erreur : un lecteur traite les deux items comme un seul et n’en affiche qu’un.

Le piège, c’est isPermaLink, dont la valeur par défaut est true : un simple <guid>abc-123</guid> affirme donc que abc-123 est une URL résoluble. Un identifiant opaque réclame isPermaLink="false" écrit explicitement, et un guid non-URL sans cet attribut est une erreur. Un item sans guid est un avertissement : les lecteurs se rabattent sur une correspondance par link ou par title.

La version catastrophique, c’est un plugin qui régénère les guids à chaque build, en général à partir de quelque chose de volatil comme le permalien. Chaque abonné reçoit alors l’intégralité du catalogue d’un coup.

Esperluettes, enclosures, et les contrôles impossibles

La façon la plus courante de casser un flux est un & brut dans une URL, comme dans p?id=12&sort=asc. En XML, une esperluette ouvre une référence d’entité : l’analyse échoue et les lecteurs rejettent le document entier plutôt que l’item fautif. Vous obtenez l’erreur de syntaxe avec sa position, et une note disant qu’aucune règle RSS n’a pu s’exécuter.

Un <enclosure> a besoin de url, length et type. length est la taille en octets sous forme de nombre entier, et 0 est un avertissement car cela signifie que le générateur n’a pas pu faire un stat du fichier. Deux enclosures sur un même item est un avertissement du Best Practices Profile, pas de la spécification.

On vérifie que le channel porte un <atom:link rel="self">, qui n’est pas du tout un élément RSS mais sur lequel les agrégateurs s’appuient. Trois contrôles sont listés comme non effectués, un navigateur ne pouvant pas les faire : si rel="self" correspond à l’endroit où le flux est servi, et si les URL et les length des enclosures sont justes.

Vérifier un flux en code

À faire tourner dans votre chaîne de publication, car les modes de panne ci-dessus sont silencieux : le flux se construit et se sert toujours, et vous l’apprenez d’un abonné. Chaque exemple vérifie la règle sur l’item, le format de date et le 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";

Chaque exemple désactive la résolution des entités externes, parce qu’un flux est un document que vous récupérez ailleurs et que vous n’avez pas écrit. En Java et en PHP, le comportement dangereux est celui par défaut.

Questions fréquentes

Mon flux marche dans un lecteur et pas dans un autre. Qu’est-ce qui cloche ?

En général une date, un guid ou l’encodage. Les lecteurs diffèrent énormément dans ce qu’ils pardonnent : un flux que Feedly affiche sans broncher peut être rejeté par un client plus strict.

Procédez dans l’ordre. Vérifiez d’abord que le document est bien du XML bien formé, car une esperluette brute casse tout ce qui suit. Puis vérifiez le pubDate de chaque item, car une date inanalysable fait disparaître les items ou les trie bizarrement. Ensuite l’unicité des guids. Si le flux est propre ici et qu’un lecteur se comporte encore mal, la cause est le transport : un mauvais Content-Type, une chaîne de redirections, ou le cache.

feedvalidator.org est-il encore l’outil à utiliser ?

Il a été la réponse canonique pendant vingt ans, mais en septembre 2026 il sert un certificat TLS expiré, que les navigateurs bloquent. Son miroir SourceForge porte un copyright 2002-2004 et vante toujours Atom 0.3, que la RFC 4287 a remplacé en 2005.

Le Feed Validation Service du W3C est maintenu et son catalogue d’erreurs est complet. Ses limites sont l’âge et l’architecture : des messages laconiques, et votre flux envoyé sur leur serveur. Cette page reprend les contrôles de ce catalogue qui correspondent à de vraies casses et les exécute en local.

Quel format de date faut-il pour RSS ?

RFC 822 : Wed, 02 Oct 2024 13:00:00 GMT. Un décalage numérique comme +0000 est tout aussi valide à la place du nom de fuseau. Le jour de la semaine est facultatif, mais si vous le mettez, il doit être le bon.

ISO 8601, c’est-à-dire 2024-10-02T13:00:00Z, n’est pas valide en RSS. Il l’est en Atom, et c’est pourquoi tant de générateurs l’émettent : quelqu’un a copié un utilitaire de date depuis un gabarit Atom. Évitez les années à deux chiffres et mettez toujours un fuseau horaire.

Pourquoi tous mes anciens articles sont-ils réapparus comme neufs chez les abonnés ?

Quelque chose a changé le guid de chaque item. Un lecteur garde la liste des guids qu’il a déjà montrés, et tout ce qui n’y figure pas est neuf. Changez-les tous d’un coup et chaque abonné reçoit de nouveau les archives, ce qui pour un podcast signifie chaque épisode téléchargé sur chaque appareil.

La cause est un guid tiré de quelque chose d’impermanent : un guid construit sur le permalien casse au passage en https, un guid construit sur le titre casse quand vous corrigez une coquille. Utilisez une clé de base de données, un UUID ou une tag URI, avec isPermaLink="false".

Mon flux est-il envoyé à un serveur ?

Non. L’analyseur et toutes les règles tournent dans cet onglet en JavaScript. Il n’y a pas de back-end à qui envoyer quoi que ce soit, ni d’analytics ayant accès à l’éditeur. Ouvrez votre panneau réseau pendant la validation et regardez-le rester vide.

Cela semble sans importance pour un format aussi public que RSS, jusqu’à ce qu’on regarde quels flux les gens collent dans un validateur : des émissions non lancées, des flux de podcast privés dont les URL d’enclosure portent un jeton par abonné, des flux de préproduction pleins de brouillons.

Cela valide-t-il les flux de podcast et les balises iTunes ?

Cela valide la couche RSS 2.0, d’où part une grande part des rejets de podcast : un pubDate mal formé, un guid en double, un enclosure sans length ou sans type. Ces règles comptent particulièrement ici, puisque l’enclosure est l’épisode.

Ce qui n’est pas vérifié, c’est l’espace de noms itunes : itunes:image et ses règles de dimensions, itunes:category, itunes:explicit, itunes:duration. Ils sont laissés tranquilles sauf si leur préfixe n’est pas déclaré. Une règle d’Apple mérite d’être redite : chaque épisode a besoin d’un identifiant globalement unique qui ne change jamais, ce qui est le contrôle sur le guid ci-dessus.

Outils associés

Pour aller plus loin

Erreurs que cet outil résout