RSS フィードバリデーター
RSS 2.0 の全規則を、根拠となる仕様つきで確認。
すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.
上にフィードを貼り付けるか、アドレスを入力して「取得」を押すと、RSS 2.0 仕様に照らして検査します。指摘のたびに要素名を挙げ、値を引用し、根拠となる規則を示します。最後の点が重要です。フィードが叱られる事柄のうち 3 分の 1 ほどは、仕様違反ではなくリーダーが依存している慣習であり、ここではそう明示します。
ここへ来た理由は、たぶん下流の何かがフィードを拒否したからでしょう。リーダーがチャンネルは表示するのにエピソードを出さない。RSS からメールへ変換するサービスが受け付けない。購読者から「古い記事が全部また新着として出てきた」と言われた。それぞれに考えられる原因の短いリストがあり、そのすべてを検査します。
このページが存在するのは、定番のツールが公衆の面前で朽ちているからです。2026 年 9 月時点で feedvalidator.org は期限切れの TLS 証明書を返しており、ブラウザーが開くのを拒みます。SourceForge のミラーは著作権表示が 2002〜2004 年で、いまだに Atom 0.3 を前面に出しています。W3C のバリデーターは動きますが、サーバー側です。あなたのフィードは相手のマシンへ送られます。
RSS 2.0 が実際に要求すること
多くの人が思うより少ないのです。ルートは version="2.0" を持つ <rss> で、その中に <channel> がちょうど 1 つ。channel には title、link、description が必要で、link は絶対 URL でなければなりません。item はもっと緩く、title か description のどちらか一方があればよく、それ以外に必須のものはありません。
仕様が許さないのは繰り返しです。channel に <title> が 2 つ、item に <link> が 2 つあるのはエラーで、2 つめが位置つきで報告されます。ありがちな間違いを捕まえる構造検査もあります。<item> が <channel> の中ではなく <rss> の直下に置かれている場合です。解析自体は通りますが、どのリーダーもそれを表示することはありません。
- channel:title、link、description が必須。
- item:title か description の少なくとも一方。
- 単一であるべき要素を channel や item の中で繰り返してはいけません。
- RSS 中核の要素は名前空間なしなので、<atom:link> は RSS の <link> ではありません。
日付は RFC 822 であって ISO 8601 ではない
RSS で最も「正しそうに見える」間違いです。pubDate と lastBuildDate は RFC 822 でなければなりません。Wed, 02 Oct 2024 13:00:00 GMT のように。多くのフレームワークが既定で出すのは ISO 8601 の 2024-10-02T13:00:00Z で、それは Atom には正しく、ここでは誤りです。pubDate を解析できないリーダーは、その項目を隠すか、間違った位置に並べます。
検査は 4 点です。そもそも解析できるか。曜日名が日付と一致しているか(不一致は手書きの整形処理の指紋です)。年が 2 桁になっていないか(RSS は許しますがアグリゲーターが誤読します)。タイムゾーンがあるか(RFC 822 は省略を許し、その結果リーダーごとに推測が食い違います)。
どの言語にも 1 回の呼び出しで済む答えがあり、下の各サンプルで使っています。toUTCString、format_datetime、RFC_1123_DATE_TIME、"r" 指定子、DATE_RSS です。
guid と、購読者に古い記事が二重に見える理由
guid は項目の同一性そのものです。リーダーはそれを使って、新着かすでに表示済みかを判断します。ですから安定していて一意でなければなりません。guid の重複はエラーです。リーダーは 2 つの項目を 1 つと見なし、片方しか表示しません。
罠は isPermaLink です。既定値が true なので、素の <guid>abc-123</guid> は「abc-123 は解決可能な URL である」と主張していることになります。不透明な識別子には isPermaLink="false" を明示する必要があり、それがない URL でない guid はエラーです。guid のない項目は警告です。リーダーは link や title による突き合わせに退避します。
最悪の形は、ビルドのたびに guid を作り直すプラグインです。たいていはパーマリンクのような変わりやすいものから作っています。結果として、全購読者がバックカタログを一度に受け取ります。
アンパサンド、enclosure、そして実行できない検査
フィードが壊れる最も一般的な原因は、URL の中の生の & です。p?id=12&sort=asc のように。XML ではアンパサンドが実体参照の始まりなので、解析が失敗し、リーダーは 1 項目ではなく文書全体を拒否します。ここでは構文エラーが位置つきで出て、RSS の規則は 1 つも実行できなかったという注記が付きます。
<enclosure> には url、length、type が必要です。length はバイト数を整数で書いたもので、0 は警告です。生成器がファイルを stat できなかったという意味だからです。1 つの項目に enclosure が 2 つあるのは、仕様ではなく Best Practices Profile に基づく警告です。
channel に <atom:link rel="self"> があるかも確認します。これは RSS の要素ではまったくありませんが、アグリゲーターが頼りにしているものです。3 つの検査は「実行しなかった」と並びます。ブラウザーには不可能だからです。rel="self" がフィードの配信場所と一致しているか、そして enclosure の URL と length が正しいかです。
コードでフィードを検査する
公開パイプラインに組み込む価値があります。上に挙げた失敗はどれも静かだからです。フィードは問題なくビルドされ配信され、あなたは購読者から知らされます。どのサンプルも item の規則、日付の書式、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";どのサンプルも外部実体の解決を無効にしています。フィードはよそから取得した、自分では書いていない文書だからです。Java と PHP では、危険なほうの挙動が既定です。
よくある質問
あるリーダーでは動くのに別のリーダーでは動きません。何が悪いのでしょう。
たいていは日付か guid か文字符号化です。リーダーごとに「大目に見る範囲」がまるで違うので、Feedly が機嫌よく描画するフィードでも、より厳格なクライアントには拒否されます。
順番に潰してください。まずそもそも整形式の XML かどうか。生のアンパサンド 1 つで下流のすべてが壊れます。次に全項目の pubDate。解析できない日付は、項目を消すか妙な順序に並べます。次に guid の一意性。ここまでフィードがきれいなのに特定のリーダーだけ挙動が変なら、原因は転送です。Content-Type の誤り、リダイレクトの連鎖、キャッシュのいずれかです。
feedvalidator.org はいまでも使うべきツールですか。
20 年にわたり定番の答えでしたが、2026 年 9 月時点で期限切れの TLS 証明書を返しており、ブラウザーがブロックします。SourceForge のミラーは著作権表示が 2002〜2004 年で、RFC 4287 が 2005 年に置き換えた Atom 0.3 をいまだに宣伝しています。
W3C の Feed Validation Service は保守されており、エラーの網羅性も十分です。限界は古さとアーキテクチャです。メッセージが素っ気なく、フィードが先方のサーバーへ送られます。このページは、その catalogue のうち実際の不具合に対応する検査を再現し、ローカルで実行します。
RSS にはどの日付書式が必要ですか。
RFC 822 です。Wed, 02 Oct 2024 13:00:00 GMT のように。ゾーン名の代わりに +0000 のような数値オフセットでもまったく問題ありません。曜日は省略できますが、書くなら正しい曜日でなければなりません。
ISO 8601、つまり 2024-10-02T13:00:00Z は RSS では無効です。Atom では有効であり、だからこそ多くの生成器がそれを出します。誰かが Atom のテンプレートから日付ヘルパーを写したのです。2 桁の年は避け、タイムゾーンは必ず入れてください。
古い記事が全部、購読者に新着として出てしまったのはなぜですか。
何かが全項目の guid を変えたからです。リーダーは表示済みの guid の一覧を持っており、そこに無いものはすべて新着です。全部を一度に変えれば、購読者はアーカイブをもう一度受け取ります。ポッドキャストなら、全エピソードが全端末にダウンロードされるということです。
原因は、永続的でないものから導いた guid です。パーマリンクから作れば https へ移行したときに壊れ、タイトルから作れば誤字を直したときに壊れます。データベースのキー、UUID、あるいは tag URI を、isPermaLink="false" と併せて使ってください。
フィードはサーバーに送られますか。
いいえ。パーサーもすべての規則も、このタブで JavaScript として動きます。何かを送るバックエンドもなければ、エディターにアクセスできる解析ツールもありません。検証しながらネットワークパネルを開いて、空のままであることを見てください。
RSS のように公開が前提の形式では些細に思えるかもしれません。しかし、人がバリデーターに貼り付けるのがどんなフィードかを考えてみてください。まだ公開していない番組、enclosure の URL に購読者ごとのトークンが入った非公開ポッドキャスト、下書きだらけのステージング用フィードです。
ポッドキャストのフィードや iTunes タグも検証しますか。
RSS 2.0 の層は検証します。ポッドキャストの審査落ちの多くはそこから始まります。壊れた pubDate、重複した guid、length や type の欠けた enclosure。ここではその規則がとりわけ重要です。enclosure こそがエピソードだからです。
検査しないのは itunes 名前空間です。itunes:image とその寸法規則、itunes:category、itunes:explicit、itunes:duration。プレフィックスが未宣言でない限り、そのままにします。Apple の規則をひとつ言い直しておきます。各エピソードには、決して変わらないグローバルに一意な識別子が必要です。それが上の guid 検査です。