use pagesift_content::{Document, Metadata};
#[derive(Default)]
struct Item {
title: String,
link: String,
summary: String,
date: String,
}
#[derive(Default)]
struct Feed {
title: String,
description: String,
language: String,
link: String,
items: Vec<Item>,
}
pub(super) fn parse(xml: &str, wants_links: bool) -> Option<Document> {
let body = xml.strip_prefix('\u{feff}').unwrap_or(xml).trim_start();
if !body.starts_with('<') {
return None;
}
let mut end = body.len().min(4096);
while !body.is_char_boundary(end) {
end -= 1;
}
let head = &body[..end];
let feed_like = head.contains("<rss")
|| head.contains("<feed")
|| head.contains("<rdf:RDF")
|| head.contains("<RDF");
if !feed_like || !(body.contains("<item") || body.contains("<entry")) {
return None;
}
let mut feed = read(body)?;
if feed.items.is_empty() {
return None;
}
let base = pagesift_net::Url::parse(&feed.link).ok();
for item in &mut feed.items {
item.link = safe_link(&item.link, base.as_ref()).unwrap_or_default();
}
let mut markdown = String::new();
if !feed.title.is_empty() {
markdown.push_str("# ");
markdown.push_str(&feed.title);
markdown.push_str("\n\n");
}
if !feed.description.is_empty() {
markdown.push_str(&feed.description);
markdown.push_str("\n\n");
}
for item in &feed.items {
let title = if item.title.is_empty() {
"Untitled"
} else {
&item.title
};
match item.link.is_empty() {
false => {
markdown.push_str(&format!("- [{}]({})", escape_link_text(title), item.link));
}
true => {
markdown.push_str("- ");
markdown.push_str(&escape_link_text(title));
}
}
if !item.date.is_empty() {
markdown.push_str(" — ");
markdown.push_str(&item.date);
}
markdown.push('\n');
if !item.summary.is_empty() {
markdown.push_str(" ");
markdown.push_str(&item.summary);
markdown.push('\n');
}
}
let links = wants_links.then(|| {
feed.items
.iter()
.filter(|i| !i.link.is_empty())
.map(|i| pagesift_content::Link {
url: i.link.clone(),
text: i.title.clone(),
internal: base
.as_ref()
.is_some_and(|b| pagesift_content::same_site(b, &i.link)),
})
.collect()
});
Some(Document {
markdown,
metadata: Metadata {
title: (!feed.title.is_empty()).then_some(feed.title),
description: (!feed.description.is_empty()).then_some(feed.description),
language: (!feed.language.is_empty()).then_some(feed.language),
..Default::default()
},
links,
barrier: None,
plain_text: None,
html: None,
images: None,
fingerprints: None,
coverage: Default::default(),
})
}
fn read(xml: &str) -> Option<Feed> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(xml.as_bytes());
let mut feed = Feed::default();
let mut in_item = false;
let mut item = Item::default();
let mut field: Option<&'static str> = None;
let mut text = String::new();
let mut buf = Vec::new();
let mut depth = 0usize;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
depth += 1;
match e.local_name().as_ref() {
"item" | "entry" => {
in_item = true;
item = Item::default();
}
"title" => field = Some("title"),
"link" if in_item => {
if let Some(href) = attr(&e, "href") {
if item.link.is_empty() {
item.link = href;
}
field = None;
} else {
field = Some("link");
}
}
"link" => {
if let Some(href) = attr(&e, "href") {
if feed.link.is_empty() {
feed.link = href;
}
field = None;
} else {
field = Some("feedlink");
}
}
"description" | "summary" | "content" if in_item => field = Some("summary"),
"description" if !in_item => field = Some("feeddesc"),
"language" => field = Some("language"),
"pubDate" | "published" | "updated" | "date" if in_item => field = Some("date"),
_ => field = None,
}
if field.is_some() {
text.clear();
}
}
Ok(Event::Empty(e)) => {
if e.local_name().as_ref() == "link"
&& let Some(href) = attr(&e, "href")
{
let link = if in_item {
&mut item.link
} else {
&mut feed.link
};
if link.is_empty() {
*link = href;
}
}
}
Ok(Event::Text(t)) => {
if field.is_some() {
text.push_str(&t.xml10_content());
}
}
Ok(Event::CData(t)) => {
if field.is_some() {
text.push_str(&t.xml10_content());
}
}
Ok(Event::GeneralRef(r)) => {
if field.is_some() {
if let Ok(Some(c)) = r.resolve_char_ref() {
text.push(c);
} else {
match r.into_inner().as_ref() {
"amp" => text.push('&'),
"lt" => text.push('<'),
"gt" => text.push('>'),
"quot" => text.push('"'),
"apos" => text.push('\''),
_ => {}
}
}
}
}
Ok(Event::End(e)) => {
depth = depth.saturating_sub(1);
match (field.take(), e.local_name().as_ref()) {
(Some("title"), "title") => {
if in_item {
item.title = clean(&text);
} else {
feed.title = clean(&text);
}
}
(Some("link"), "link") => {
let link = text.trim();
if item.link.is_empty() {
item.link = link.to_string();
}
}
(Some("feedlink"), "link") => {
if feed.link.is_empty() {
feed.link = text.trim().to_string();
}
}
(Some("summary"), "description" | "summary" | "content") => {
item.summary = strip_html(&text)
}
(Some("feeddesc"), "description") => feed.description = strip_html(&text),
(Some("language"), "language") => feed.language = clean(&text),
(Some("date"), "pubDate" | "published" | "updated" | "date") => {
item.date = clean(&text)
}
_ => {}
}
if matches!(e.local_name().as_ref(), "item" | "entry") {
if !item.title.is_empty() || !item.link.is_empty() {
feed.items.push(std::mem::take(&mut item));
}
in_item = false;
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
if depth > 128 {
break;
}
}
Some(feed)
}
fn attr(e: &quick_xml::events::BytesStart<'_>, name: &str) -> Option<String> {
let rel = e
.try_get_attribute("rel")
.ok()
.flatten()
.map(|a| a.value.to_string());
if rel.is_some_and(|r| r != "alternate") {
return None;
}
let value = e.try_get_attribute(name).ok().flatten()?;
Some(value.value.replace("&", "&"))
}
fn clean(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn escape_link_text(s: &str) -> String {
s.replace('[', "\\[").replace(']', "\\]")
}
fn safe_link(raw: &str, base: Option<&pagesift_net::Url>) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let url = match pagesift_net::Url::parse(raw) {
Ok(url) => url,
Err(_) => base?.join(raw).ok()?,
};
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
}
fn strip_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(lt) = rest.find('<') {
out.push_str(&rest[..lt]);
let tag = &rest[lt..];
if !tag[1..].starts_with(|c: char| c.is_ascii_alphabetic() || matches!(c, '/' | '!' | '?'))
{
out.push('<');
rest = &tag[1..];
continue;
}
if let Some(comment) = tag.strip_prefix("<!--") {
rest = comment.find("-->").map_or("", |i| &comment[i + 3..]);
out.push(' ');
continue;
}
let Some(gt) = tag.find('>') else {
rest = "";
break;
};
let name: String = tag[1..gt]
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
rest = &tag[gt + 1..];
if name == "script" || name == "style" {
let close = format!("</{name}");
rest = match rest.to_ascii_lowercase().find(&close) {
Some(i) => rest[i..].find('>').map_or("", |g| &rest[i + g + 1..]),
None => "",
};
}
out.push(' ');
}
out.push_str(rest);
let out = out
.replace(" ", " ")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&");
escape_link_text(&clean(&out))
}
#[cfg(test)]
#[path = "tests_feed.rs"]
mod tests;