きっかけ
フィードを扱うバックエンドは、結局どこも同じグルーコードを再実装している。要素名の違い、RFC 822 と RFC 3339 が混在する日時フォーマット、content:encoded と description の使い分け、Atom のリンクが属性で RSS のリンクがテキスト。
Rust では rss や atom_syndication クレートが定番だが、両方使うと依存ツリーが重くなるし、出力型も別々。1 つのバイナリで両フォーマットを同じ出力形状で扱いたかった。
quick-xml の上に約 500 行のイベントストリームコードを書いた。11.1 MB の Docker イメージで、POST /parse、GET /parse?url=、POST /normalize、GET /health を axum で提供する。
📦 GitHub: https://github.com/sen-ltd/feed-parser
作ったもの
RSS 2.0 と Atom 1.0 の両方をパースし、統一された JSON 構造に変換する axum サービス。フィード専用の依存クレートを使わず、quick-xml の低レベル API だけで実装。
技術的なポイント
RSS 2.0 と Atom 1.0 の概念マッピング
仕様を並べて読むと、ほぼ 1 対 1 で対応している:
| 概念 | RSS 2.0 | Atom 1.0 |
|---|---|---|
| フィードタイトル | <title> |
<title> |
| フィードリンク | <link>text</link> |
<link href="..." rel="alternate"/> |
| アイテム | <item> |
<entry> |
| 本文 | <content:encoded> |
<content> |
| 公開日 |
<pubDate> (RFC 822) |
<published> (RFC 3339) |
| 著者 |
<author> / <dc:creator>
|
<author><name/></author> |
2 つの独立したパーサーを書き、共通の Feed 出力構造体で束ねるアーキテクチャを選択。要素名のエイリアシングを統一パーサーに詰め込むより、特殊ケース(Atom のリンクが属性ベースの空要素、RSS の <guid isPermaLink>)を別々のフラットな関数として書くほうが短くて読みやすかった。
フォーマット検出
最初の非メタデータ要素だけを見る小さなステートマシン:
pub fn detect(xml: &[u8]) -> Result<Format, String> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let local = std::str::from_utf8(e.name().local_name().as_ref())
.unwrap_or("").to_ascii_lowercase();
match local.as_str() {
"rss" => { return Ok(Format::Rss2); }
"feed" => {
let has_atom_ns = e.attributes().flatten().any(|a| {
let k = a.key.as_ref();
(k == b"xmlns" || k.starts_with(b"xmlns:"))
&& a.value.as_ref().windows(4)
.any(|w| w.eq_ignore_ascii_case(b"atom"))
});
if has_atom_ns { return Ok(Format::Atom10); }
return Err("root <feed> without Atom namespace".into());
}
_ => return Err(format!("unrecognized feed format: <{}>", local)),
}
}
Ok(Event::Eof) => return Err("unrecognized feed format: empty document".into()),
Ok(_) => {}
Err(e) => return Err(format!("xml parse error: {}", e)),
}
buf.clear();
}
}
-
local_name()で名前空間プレフィックスを除去。<atom:feed>も<feed xmlns="...">も同じ"feed"にマッチ -
<feed>だけでは受け入れず、Atom 名前空間の存在を確認。他の XML スキーマの<feed>をゴミパースしないため
CDATA とエスケープの落とし穴
-
Event::Textは.unescape()で&や<を解決する必要がある -
Event::CDataはそのまま。unescape すると二重処理になる - フィードの HTML 本文は CDATA が多用されるので、ここを間違えるとデータが壊れる
日時の正規化
RSS の RFC 822 と Atom の RFC 3339 を chrono で統一:
pub fn normalize_date(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() { return None; }
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Some(dt.to_rfc3339_opts(SecondsFormat::Secs, true));
}
if let Ok(dt) = DateTime::parse_from_rfc2822(trimmed) {
return Some(dt.to_rfc3339_opts(SecondsFormat::Secs, true));
}
None
}
パースに失敗した日時は null にして、normalization_notes に理由を記録。消費者がサイレントに欠落したフィールドではなく、明確なシグナルを受け取れるようにした。
トレードオフ
- RSS 1.0 / RDF は非対応。 構造が根本的に異なり、2003 年頃にピークを迎えた形式
- Atom 0.3 も非対応。 2005 年に廃止
- ポッドキャスト / iTunes 拡張なし。 消費者側で追加する設計
-
XXE は問題にならない。
quick-xmlは外部エンティティを解決しないので、そもそも安全
試してみる
git clone https://github.com/sen-ltd/feed-parser
cd feed-parser
docker build -t feed-parser .
docker run --rm -p 8000:8000 feed-parser &
# RSS 2.0 をパース
curl -sS -X POST http://localhost:8000/parse \
-H 'Content-Type: application/xml' \
--data-binary @tests/fixtures/sample-rss2.xml | jq
# リモートフィードを取得してパース
curl -sS 'http://localhost:8000/parse?url=https://blog.rust-lang.org/feed.xml' | jq
おわりに
38 件のテスト、11.1 MB のイメージ、直接依存 8 個。フィードパースは「ライブラリを使え」が常識だが、実際にサポートすべきフォーマットは 2 つだけで、入力データの実態は仕様が許容する変種よりずっと狭い。500 行で書けるなら、フレームワークを引きずり回すよりも安い。
SEN 合同会社 の 100+ ポートフォリオシリーズ エントリ #150。
