Atom フィードバリデーター

author の継承まで含めて、RFC 4287 をきちんと確認。

配信元から直接取得します。多くのサーバーはこれをブロックするため、その場合は貼り付けてください。
入力
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.

上に Atom フィードを貼り付けるか、URL から取得すると、RFC 4287 に照らして検査します。指摘はすべて、根拠となる節を示します。ルートが <feed> ではなく <entry> である単独の Atom エントリ文書も認識し、エントリの規則で検査します。

Atom は RSS より厳格で、それこそが存在意義です。RSS が解釈に委ねたもののほとんどを、Atom は決めました。フィードにも各エントリにも id、title、updated がちょうど 1 つずつ。日付形式は 1 つだけで、異形はありません。すべてのテキスト構成要素が、それが text か HTML か XHTML かを自分で宣言します。

ここには、多くのツールが飛ばしてしまう規則がきちんと実装されています。author の継承です。エントリは、author を持つ <atom:source> があるか、フィードに author があれば、自前の author を持たなくてかまいません。entry/author だけを見ると、妥当なフィードに対して偽のエラーが 1 ページ分出ますし、何も見なければ本物の欠落を見逃します。

Atom が生まれた理由と、RSS との違い

RSS 2.0 は 2002 年に凍結され、その曖昧さも一緒に凍結されました。最大のものが <description> です。仕様は、それが平文か HTML かを一度も言わなかったので、リーダーごとに推測しました。項目の同一性は任意でした。日付は RFC 822 に従い、2 桁の年やタイムゾーンなしも許されました。

Atom はその応答として IETF で書かれ、2005 年 12 月に RFC 4287 として公開されました。テキストは type を明示します。すべてのフィードとエントリに、必須で恒久的な id があります。日付は RFC 3339 で、ゾーンは必須です。すべてが 1 つの名前空間に収まるので、拡張が中核の語彙と衝突することはありません。

  • RSS:description はテキストか HTML か、誰にも分からない。Atom:type が明示される。
  • RSS:guid は任意で、しばしば不安定。Atom:id は必須、絶対 IRI、恒久的。
  • RSS:RFC 822 の日付、ゾーンは任意。Atom:RFC 3339、ゾーンは必須。

フィードとエントリに必要なもの

フィードに <id>、<title>、<updated> がちょうど 1 つずつ。各エントリにも同じ 3 つ。1 つ以上ではなく、ちょうど 1 つなので、重複もエラーです。最もよくある失敗は、タイトルとエントリはあるのに id がないフィードです。

<content> を持たないエントリは、少なくとも 1 つの <link rel="alternate"> を持たなければなりません。読み手に、そのもの自体か、そこへ辿り着く手段のどちらかを差し出す必要があるからです。<content> に src 属性があるなら要素は空でなければならず、そのエントリには <summary> が必要です。base64 の内容にも同じことが当てはまります。

すべての <link> には href が必要で、type と hreflang が同じ rel="alternate" のリンクを 2 つ持つことはできません。リーダーがどちらを選べばよいか分からなくなるからです。rel の省略は alternate とみなされます。フィードには <link rel="self"> もあるべきで、RFC 4287 はこれを SHOULD としているため、ラベル付きの警告として扱います。

author の継承規則

RFC 4287 は、すべてのエントリについて author を解決できることを要求しますが、その要素がエントリ上にあることまでは求めません。規則はフォールバックの連鎖です。エントリ自身の <author>、その <atom:source> の中の <author>、あるいは <feed> の <author>。source のケースは、よそから集めたエントリを再配信するアグリゲーターのために存在します。

3 段階すべてを実装している無料バリデーターはほとんどありません。entry/author しか見ないものは、妥当な単著ブログのフィード、つまり最もありふれた Atom フィードの形に対して、全エントリでエラーを報告します。このツールは連鎖をたどり、3 つとも外れたときにだけエラーを報告します。

単独の Atom エントリ文書には継承元のフィードがないので、自前の author を持たなければなりません。人物構成要素にはいずれも <name> が必要です。メールアドレスしかない <author> は 3.2.1 節に照らしてエラーです。

日付と id を、RFC が書いたとおりに

Atom のタイムスタンプは RFC 3339 であり、3.3 節がさらに 2 つの要件を足します。区切りは大文字の T でなければならず、数値オフセットがない場合のゾーンは大文字の Z でなければなりません。ですから 2026-03-14T09:30:00Z は有効で、2026-03-14t09:30:00z は無効です。どの日付ライブラリも後者を解析してしまいますが。小文字には専用のメッセージがあります。日付だけの表記も無効です。

id は絶対 IRI でなければならないので、スキームが要ります。https:、tag:、urn: はいずれも該当し、相対パスや裸の文字列は該当しません。エントリ間で id が重複するのはエラーです。リーダーは id で重複を除くからです。

大文字小文字だけ、末尾のスラッシュだけ、既定ポートだけが違う id は警告になります。id は 1 文字ずつ比較されるので、/p/1 と /p/1/ は別の 2 エントリです。tag スキーム(RFC 4151)はそれを避けられますし、tag:example.com,2026:post-4192 はドメイン移転を生き延びます。

コードで Atom を作り、検査する

自動化する価値のある 4 つの規則は、いずれもスキーマでは表現できないものです。RFC 3339 の大文字小文字、id の一意性、author の継承、そして content か alternate リンクかの二択。各サンプルはそれらを、安全な解析の形で検査します。

// 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

RFC 4287 の RELAX NG スキーマは走らせる価値がありますが、限界を知っておいてください。あれは規範ではなく参考であり、文法は要素をまたぐ制約を表現できません。author の継承、id の一意性、summary の規則は、いずれもコードで確かめるほかありません。

よくある質問

RSS と Atom の違いは何ですか。

Atom のほうが後発で、より厳格です。IETF の標準 RFC 4287 として 2005 年 12 月に公開されました。RSS 2.0 は 2002 年に凍結され、標準ではなく仕様文書として保守されています。

効いてくる違いは曖昧さに関するものです。RSS では <description> がテキストか HTML かを誰にも判定できませんが、Atom ではすべてのテキスト構成要素が自分の型を宣言します。RSS は項目の同一性を任意とし、日付の書き方も複数許しますが、Atom は恒久的な id と、ゾーン付きの単一の日付形式を要求します。

フィードに author があるのに、エントリに author がないと言われるのはなぜですか。

本来そうは言わないはずで、そう出るなら author 要素はあなたの思っている場所にない可能性が高いです。ここで実装している規則は RFC 4287 の連鎖です。エントリ自身の <author>、その <atom:source> の中の <author>、または <feed> 要素の <author>。

驚きの原因はたいてい名前空間です。http://www.w3.org/2005/Atom にない <author>、たとえば Dublin Core の dc:creator は Atom の author ではなく、この規則を満たしません。もう 1 つは配置です。最初の <entry> の中にある <author> は、そのエントリだけを覆います。

なぜ 2026-03-14 は妥当な Atom の日付ではないのですか。

RFC 4287 の 3.3 節が、日付ではなく完全な RFC 3339 の日付時刻を要求しているからです。Atom のタイムスタンプには時刻とゾーンが必要です。2026-03-14T09:30:00Z、あるいはオフセットなら 2026-03-14T09:30:00+05:30 のように。

さらに 2 点が明示的な MUST です。区切りは大文字の T でなければならず、数値オフセットがないときのゾーンは大文字の Z でなければなりません。小文字の t や z はどの日付ライブラリでも問題なく解析されますが、それでも無効です。だからこそ専用のエラーメッセージを用意しています。

id には何を使うべきですか。

二度と変える必要のない、グローバルに一意なものです。仕様は断固としています。エントリが移動・再配信されても id は変わってはなりません。リーダーは何が新着かを決めるのに id を 1 文字ずつ比較するからです。

tag URI スキーム(RFC 4151)はまさにこのために設計されました。tag:example.com,2026:post-4192 は一意で、解決することを期待されておらず、エントリを「今どこにあるか」に縛りません。ページの URL でも動きますが、縛られます。末尾にスラッシュを足したり https へ移ったりすれば全エントリの同一性が変わり、購読者はアーカイブをもう一度受け取ります。

ここで検証すると、フィードはどこかへ送られますか。

いいえ。パーサーも RFC 4287 のすべての規則も、このタブで動く JavaScript です。サーバー側の仕組みも、エディターにアクセスできる解析ツールもありません。ネットワークパネルを開いて何か検証してみてください。ページのアセットが一度読み込まれ、そのあとは何も起きません。

人がバリデーターに貼り付けるフィードは、まだうまく動いていないものです。未公開のサイト、リンクにアクセストークンが入ったフィード、秘密保持契約下のクライアントのフィード。W3C のサービスはサーバー側なので、そこで検査したものは送信されます。ここでの取得は、あなたのブラウザーから指定したホストへ直接向かいます。

Atom 0.3 のフィードを検査できますか。

できません。そして、そうはっきり伝えます。Atom 0.3 は名前空間 http://purl.org/atom/ns# を使っており、それを宣言しているフィードは、バージョン名を挙げたエラーとして報告し、代わりに必要な 1.0 の名前空間も示します。

Atom 0.3 は標準化前の草案で、RFC 4287 が 2005 年に置き換えました。その間に要素名も変わっています。0.3 の <modified> と <issued> は 1.0 では <updated> と <published> であり、<tagline> は <subtitle> になりました。名前空間だけを変えると、新しい形で無効な文書ができあがります。ですからメッセージでは、要素名も見直すよう伝えています。

関連ツール

関連する解説