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 検査です。

関連ツール

関連する解説

これで解決できるエラー