use crate::parser::Selector;
use crate::spiders::request::SpiderRequest;
use crate::spiders::response::SpiderResponse;
use crate::spiders::spider::Spider;
use async_trait::async_trait;
use std::collections::HashSet;
use std::sync::Arc;
pub type ParseNodeFn =
Arc<dyn Fn(&SpiderResponse, &Selector) -> Vec<serde_json::Value> + Send + Sync>;
pub struct XmlFeedSpider {
name: String,
feed_urls: Vec<String>,
iter_tag: String,
parse_node: Option<ParseNodeFn>,
allowed_domains: HashSet<String>,
concurrent_requests: u32,
development_mode: bool,
robots_txt_obey: bool,
}
const VOID_TAG_REWRITES: &[(&str, &str)] = &[("link", "xmlfeed-link"), ("meta", "xmlfeed-meta")];
impl XmlFeedSpider {
pub fn builder(name: &str) -> XmlFeedSpiderBuilder {
XmlFeedSpiderBuilder::new(name)
}
fn rewrite_void_tags(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let bytes = body.as_bytes();
let mut i = 0;
'outer: while i < bytes.len() {
if bytes[i] == b'<' {
let (start, closing) = if bytes.get(i + 1) == Some(&b'/') {
(i + 2, true)
} else {
(i + 1, false)
};
for (from, to) in VOID_TAG_REWRITES {
let end = start + from.len();
let next = bytes.get(end);
let name_matches = body
.get(start..end)
.is_some_and(|s| s.eq_ignore_ascii_case(from));
let boundary_ok = matches!(
next,
Some(b'>')
| Some(b' ')
| Some(b'\t')
| Some(b'\r')
| Some(b'\n')
| Some(b'/')
) || (closing && next.is_none());
if name_matches && boundary_ok {
if closing {
out.push_str("</");
out.push_str(to);
i = end;
continue 'outer;
}
let mut j = end;
let mut quote: Option<u8> = None;
let gt = loop {
match bytes.get(j) {
None => break None,
Some(&b) => match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => break Some(j),
_ => {}
},
},
}
j += 1;
};
out.push('<');
out.push_str(to);
match gt {
Some(gt) if bytes[gt - 1] == b'/' => {
out.push_str(&body[end..gt - 1]);
out.push_str("></");
out.push_str(to);
out.push('>');
i = gt + 1;
}
Some(gt) => {
out.push_str(&body[end..=gt]);
i = gt + 1;
}
None => i = end,
}
continue 'outer;
}
}
}
let ch_len = body[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&body[i..i + ch_len]);
i += ch_len;
}
out
}
fn original_tag(tag: &str) -> &str {
VOID_TAG_REWRITES
.iter()
.find(|(_, to)| *to == tag)
.map_or(tag, |(from, _)| from)
}
fn node_to_item(node: &Selector) -> serde_json::Value {
let mut obj = serde_json::Map::new();
for child in &node.children() {
let tag = child.tag().to_string();
if tag.starts_with('#') {
continue; }
let text = child.get_all_text("", false, &[], None);
obj.insert(
Self::original_tag(&tag).to_string(),
serde_json::Value::String(text.as_str().trim().to_string()),
);
}
serde_json::Value::Object(obj)
}
}
#[async_trait]
impl Spider for XmlFeedSpider {
fn name(&self) -> &str {
&self.name
}
fn start_urls(&self) -> Vec<String> {
self.feed_urls.clone()
}
fn allowed_domains(&self) -> HashSet<String> {
self.allowed_domains.clone()
}
fn concurrent_requests(&self) -> u32 {
self.concurrent_requests
}
fn development_mode(&self) -> bool {
self.development_mode
}
fn robots_txt_obey(&self) -> bool {
self.robots_txt_obey
}
async fn parse(
&self,
response: SpiderResponse,
) -> (Vec<serde_json::Value>, Vec<SpiderRequest>) {
let rewritten = Self::rewrite_void_tags(response.text());
let selector = Selector::from_html_with_url(&rewritten, response.url());
let iter_tag = VOID_TAG_REWRITES
.iter()
.find(|(from, _)| from.eq_ignore_ascii_case(&self.iter_tag))
.map_or(self.iter_tag.clone(), |(_, to)| (*to).to_string());
let mut items = Vec::new();
for node in &selector.css(&iter_tag) {
match &self.parse_node {
Some(f) => items.extend(f(&response, node)),
None => items.push(Self::node_to_item(node)),
}
}
(items, vec![])
}
}
#[must_use = "a builder does nothing until `.build()` is called"]
pub struct XmlFeedSpiderBuilder {
spider: XmlFeedSpider,
}
impl XmlFeedSpiderBuilder {
fn new(name: &str) -> Self {
Self {
spider: XmlFeedSpider {
name: name.to_string(),
feed_urls: Vec::new(),
iter_tag: "item".to_string(),
parse_node: None,
allowed_domains: HashSet::new(),
concurrent_requests: 4,
development_mode: false,
robots_txt_obey: false,
},
}
}
pub fn feed_url(mut self, url: &str) -> Self {
self.spider.feed_urls.push(url.to_string());
self
}
pub fn feed_urls(mut self, urls: impl IntoIterator<Item = String>) -> Self {
self.spider.feed_urls.extend(urls);
self
}
pub fn iter_tag(mut self, tag: &str) -> Self {
self.spider.iter_tag = tag.to_string();
self
}
pub fn parse_node(mut self, f: ParseNodeFn) -> Self {
self.spider.parse_node = Some(f);
self
}
pub fn allowed_domains(mut self, domains: impl IntoIterator<Item = String>) -> Self {
self.spider.allowed_domains.extend(domains);
self
}
pub fn concurrent_requests(mut self, n: u32) -> Self {
self.spider.concurrent_requests = n;
self
}
pub fn development_mode(mut self, on: bool) -> Self {
self.spider.development_mode = on;
self
}
pub fn robots_txt_obey(mut self, on: bool) -> Self {
self.spider.robots_txt_obey = on;
self
}
pub fn build(self) -> XmlFeedSpider {
self.spider
}
}
#[cfg(test)]
mod tests {
use super::XmlFeedSpider;
use crate::parser::Selector;
#[test]
fn self_closing_link_does_not_swallow_siblings() {
let body = r#"<feed><entry><title>A1</title><link href="https://a/1"/><id>urn:1</id><updated>2020</updated></entry></feed>"#;
let rewritten = XmlFeedSpider::rewrite_void_tags(body);
assert!(rewritten.contains(r#"<xmlfeed-link href="https://a/1"></xmlfeed-link>"#));
let selector = Selector::from_html(&rewritten);
let entries = selector.css("entry");
let item = XmlFeedSpider::node_to_item(&entries[0]);
let obj = item.as_object().unwrap();
assert_eq!(obj.get("title").unwrap(), "A1");
assert_eq!(obj.get("id").unwrap(), "urn:1");
assert_eq!(obj.get("updated").unwrap(), "2020");
assert_eq!(obj.get("link").unwrap(), "");
}
#[test]
fn newline_after_tag_name_is_a_boundary() {
let rewritten = XmlFeedSpider::rewrite_void_tags("<link\n>https://a/1</link>");
assert_eq!(rewritten, "<xmlfeed-link\n>https://a/1</xmlfeed-link>");
let rewritten = XmlFeedSpider::rewrite_void_tags("<link\r\n href=\"x\">t</link>");
assert_eq!(rewritten, "<xmlfeed-link\r\n href=\"x\">t</xmlfeed-link>");
}
#[test]
fn gt_inside_quoted_attribute_does_not_end_the_tag() {
let rewritten = XmlFeedSpider::rewrite_void_tags(r#"<link title="a>b"/><id>1</id>"#);
assert_eq!(
rewritten,
r#"<xmlfeed-link title="a>b"></xmlfeed-link><id>1</id>"#
);
}
#[test]
fn non_matching_and_truncated_tags_pass_through() {
assert_eq!(
XmlFeedSpider::rewrite_void_tags("<linkage>x</linkage>"),
"<linkage>x</linkage>"
);
assert_eq!(
XmlFeedSpider::rewrite_void_tags("<link href=\"x"),
"<xmlfeed-link href=\"x"
);
assert_eq!(XmlFeedSpider::rewrite_void_tags("</link"), "</xmlfeed-link");
}
}