feed-parser 2.0.0

A simple RSS 1.0 / RSS 2.0 / Atom feed parser
Documentation
use crate::parsers::{
    Feed,
    errors::{ParseError, ParseResult},
    internal::{TextRun, compile_tag_patterns, deserialize_feed, escape_inline_html},
};
use core::str;
use quick_xml::Reader;
use quick_xml::Writer;
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
use regex::Regex;
use std::io::Cursor;
use std::sync::LazyLock;

#[cfg(test)]
mod tests;

/// Elements whose content may contain unescaped HTML.
static HTML_BEARING_TAGS: LazyLock<Vec<(&'static str, Regex)>> =
    LazyLock::new(|| compile_tag_patterns(&["title", "description", "summary", "content"]));

/// Parses an Atom document into one [`Feed`] per `<entry>`.
///
/// The `href` of each entry's alternate `text/html` `<link>` becomes the feed
/// link; other link relations are ignored.
///
/// # Errors
///
/// Returns [`ParseError::XmlParseError`] if the document is not well-formed XML,
/// [`ParseError::MissingField`] if an entry lacks a required field, and
/// [`ParseError::InvalidFeedFormat`] if an entry is never closed.
pub fn parse(text: &str) -> ParseResult<Vec<Feed>> {
    let text = escape_inline_html(text, &HTML_BEARING_TAGS);

    let mut reader = Reader::from_str(&text);

    let mut feeds = Vec::new();
    let mut writer = Writer::new(Cursor::new(Vec::new()));
    // Character data is buffered rather than written straight through; see
    // `TextRun` for why the reader's own `trim_text` cannot be used here.
    let mut run = TextRun::default();
    let mut parsing = false;
    loop {
        match reader.read_event()? {
            Event::Start(e) => {
                run.flush(&mut writer)?;
                if parsing {
                    if e.name().as_ref() == b"dc:creator" {
                        writer.write_event(Event::Start(BytesStart::new("creator")))?;
                    } else if e.name().as_ref() == b"dc:date" {
                        writer.write_event(Event::Start(BytesStart::new("date")))?;
                    } else if e.name().as_ref() == b"pubDate" || e.name().as_ref() == b"published" {
                        writer.write_event(Event::Start(BytesStart::new("publish_date")))?;
                    } else if e.name().as_ref() == b"description" {
                        writer.write_event(Event::Start(BytesStart::new("description")))?;
                    } else if e.name().as_ref() == b"link" {
                        continue;
                    } else {
                        writer.write_event(Event::Start(e.clone()))?;
                    }
                }
                if e.name().as_ref() == b"entry" {
                    writer.write_event(Event::Start(BytesStart::new("entry")))?;
                    parsing = true;
                }
            }
            Event::Empty(e) => {
                run.flush(&mut writer)?;
                if parsing {
                    if e.name().as_ref() == b"link" {
                        let mut is_link = true;
                        for attr in e.attributes() {
                            let attr = attr.map_err(quick_xml::Error::InvalidAttr)?;
                            if attr.key.0 == b"type" {
                                if str::from_utf8(attr.value.as_ref())? != "text/html" {
                                    is_link = false;
                                }
                            } else if attr.key.0 == b"rel"
                                && str::from_utf8(attr.value.as_ref())? != "alternate"
                            {
                                is_link = false;
                            }
                        }
                        if !is_link {
                            continue;
                        }
                        for attr in e.attributes() {
                            let attr = attr.map_err(quick_xml::Error::InvalidAttr)?;
                            if attr.key.0 == b"href" {
                                let href = str::from_utf8(attr.value.as_ref())?;
                                writer.write_event(Event::Start(BytesStart::new("link")))?;
                                writer.write_event(Event::Text(BytesText::new(href)))?;
                                writer.write_event(Event::End(BytesEnd::new("link")))?;
                            }
                        }
                    } else {
                        writer.write_event(Event::Empty(e))?;
                    }
                }
            }
            Event::End(e) => {
                run.flush(&mut writer)?;
                if parsing && e.name().as_ref() == b"entry" {
                    writer.write_event(Event::End(BytesEnd::new("entry")))?;
                    let feed_text = writer.into_inner().into_inner();
                    feeds.push(deserialize_feed(str::from_utf8(&feed_text)?)?);

                    writer = Writer::new(Cursor::new(Vec::new()));
                    parsing = false;
                } else if parsing {
                    if e.name().as_ref() == b"dc:creator" {
                        writer.write_event(Event::End(BytesEnd::new("creator")))?;
                    } else if e.name().as_ref() == b"dc:date" {
                        writer.write_event(Event::End(BytesEnd::new("date")))?;
                    } else if e.name().as_ref() == b"pubDate" || e.name().as_ref() == b"published" {
                        writer.write_event(Event::End(BytesEnd::new("publish_date")))?;
                    } else if e.name().as_ref() == b"link" {
                        continue;
                    } else {
                        writer.write_event(Event::End(e))?;
                    }
                }
            }
            Event::Text(e) => {
                if parsing {
                    // Atom sources commonly double-escape their content, so
                    // decode once before handing the text on.
                    let text = html_escape::decode_html_entities(str::from_utf8(&e)?);
                    run.push(Event::Text(BytesText::new(&text).into_owned()));
                }
            }
            Event::CData(e) => {
                if parsing {
                    run.push(Event::CData(e));
                }
            }
            // quick-xml reports entity references as their own event. Dropping
            // them would silently delete every escaped character in the entry.
            Event::GeneralRef(e) => {
                if parsing {
                    run.push(Event::GeneralRef(e));
                }
            }
            Event::Eof => break,
            _ => {}
        }
    }

    if parsing {
        return Err(ParseError::InvalidFeedFormat(
            "reached end of document inside an unclosed <entry> element".to_string(),
        ));
    }

    Ok(feeds)
}