1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RSS と Atom を quick-xml でスクラッチパースする Rust サービスを作った

1
Posted at

きっかけ

フィードを扱うバックエンドは、結局どこも同じグルーコードを再実装している。要素名の違い、RFC 822 と RFC 3339 が混在する日時フォーマット、content:encodeddescription の使い分け、Atom のリンクが属性で RSS のリンクがテキスト。

Rust では rssatom_syndication クレートが定番だが、両方使うと依存ツリーが重くなるし、出力型も別々。1 つのバイナリで両フォーマットを同じ出力形状で扱いたかった。

quick-xml の上に約 500 行のイベントストリームコードを書いた。11.1 MB の Docker イメージで、POST /parseGET /parse?url=POST /normalizeGET /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()&amp;&lt; を解決する必要がある
  • 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。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?