use std::sync::Arc;
use dashmap::DashMap;
use quick_xml::events::Event;
use quick_xml::Reader;
use crate::analyzers::SitemapEntry;
use crate::http::HttpClient;
pub struct SitemapCache {
cache: DashMap<String, Vec<SitemapEntry>>,
client: Arc<HttpClient>,
}
impl SitemapCache {
pub fn new(client: Arc<HttpClient>) -> Self {
Self {
cache: DashMap::new(),
client,
}
}
pub async fn fetch(&self, sitemap_url: &str) -> Vec<SitemapEntry> {
self.fetch_inner(sitemap_url, 0).await
}
async fn fetch_inner(&self, sitemap_url: &str, depth: usize) -> Vec<SitemapEntry> {
if depth > 2 {
return Vec::new();
}
if let Some(entries) = self.cache.get(sitemap_url) {
return entries.clone();
}
let url = match url::Url::parse(sitemap_url) {
Ok(u) => u,
Err(_) => return Vec::new(),
};
let body = match self.client.fetch(&url).await {
Ok(result) if result.status_code == 200 => result.body,
_ => return Vec::new(),
};
let entries = match parse_sitemap(&body) {
Ok(SitemapContent::UrlSet(entries)) => entries,
Ok(SitemapContent::SitemapIndex(child_urls)) => {
let mut all_entries = Vec::new();
for child_url in &child_urls {
let child_entries = Box::pin(self.fetch_inner(child_url, depth + 1)).await;
all_entries.extend(child_entries);
}
all_entries
}
Err(_) => return Vec::new(),
};
self.cache.insert(sitemap_url.to_string(), entries.clone());
entries
}
pub async fn fetch_all(&self, urls: &[String]) -> Vec<SitemapEntry> {
let mut all = Vec::new();
for url in urls {
all.extend(self.fetch(url).await);
}
all
}
pub fn cached_count(&self) -> usize {
self.cache.iter().map(|e| e.value().len()).sum()
}
}
enum SitemapContent {
UrlSet(Vec<SitemapEntry>),
SitemapIndex(Vec<String>),
}
fn parse_sitemap(xml: &str) -> Result<SitemapContent, SitemapError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut root_tag = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
root_tag = Some(String::from_utf8_lossy(e.name().as_ref()).to_string());
break;
}
Ok(Event::Eof) => break,
Ok(_) => continue,
Err(_) => return Err(SitemapError::InvalidXml),
}
}
let root = root_tag.ok_or(SitemapError::InvalidXml)?;
match root.as_str() {
"urlset" => parse_urlset(reader, &mut buf),
"sitemapindex" => parse_sitemapindex(reader, &mut buf),
_ => Err(SitemapError::UnknownRoot),
}
}
fn parse_urlset(
mut reader: Reader<&[u8]>,
buf: &mut Vec<u8>,
) -> Result<SitemapContent, SitemapError> {
let mut entries = Vec::new();
let mut current_url: Option<String> = None;
let mut current_lastmod: Option<String> = None;
let mut current_changefreq: Option<String> = None;
let mut current_priority: Option<f64> = None;
let mut in_url = false;
let mut current_tag = String::new();
loop {
buf.clear();
match reader.read_event_into(buf) {
Ok(Event::Start(e)) => {
current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
if current_tag == "url" {
in_url = true;
current_url = None;
current_lastmod = None;
current_changefreq = None;
current_priority = None;
}
}
Ok(Event::Text(e)) => {
let text = String::from_utf8_lossy(e.as_ref()).to_string();
if in_url {
match current_tag.as_str() {
"loc" => current_url = Some(text),
"lastmod" => current_lastmod = Some(text),
"changefreq" => current_changefreq = Some(text),
"priority" => {
current_priority = text.parse().ok();
}
_ => {}
}
}
}
Ok(Event::End(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if name == "url" && in_url {
if let Some(loc) = current_url.take() {
entries.push(SitemapEntry {
url: loc,
lastmod: current_lastmod.take(),
changefreq: current_changefreq.take(),
priority: current_priority.take(),
});
}
in_url = false;
}
current_tag.clear();
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
Ok(SitemapContent::UrlSet(entries))
}
fn parse_sitemapindex(
mut reader: Reader<&[u8]>,
buf: &mut Vec<u8>,
) -> Result<SitemapContent, SitemapError> {
let mut child_urls = Vec::new();
let mut current_tag = String::new();
loop {
buf.clear();
match reader.read_event_into(buf) {
Ok(Event::Start(e)) => {
current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
}
Ok(Event::Text(e)) => {
if current_tag == "loc" {
child_urls.push(String::from_utf8_lossy(e.as_ref()).to_string());
}
}
Ok(Event::End(_)) => {
current_tag.clear();
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
Ok(SitemapContent::SitemapIndex(child_urls))
}
#[derive(Debug)]
pub enum SitemapError {
InvalidXml,
UnknownRoot,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_parse_urlset() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-15</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/about</loc>
<lastmod>2024-01-10</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>"#;
match parse_sitemap(xml).unwrap() {
SitemapContent::UrlSet(entries) => {
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].url, "https://example.com/");
assert_eq!(entries[0].lastmod, Some("2024-01-15".to_string()));
assert_eq!(entries[0].changefreq, Some("daily".to_string()));
assert_eq!(entries[0].priority, Some(1.0));
assert_eq!(entries[1].url, "https://example.com/about");
}
_ => panic!("Expected UrlSet"),
}
}
#[test]
fn test_parse_sitemapindex() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-pages.xml</loc>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-posts.xml</loc>
</sitemap>
</sitemapindex>"#;
match parse_sitemap(xml).unwrap() {
SitemapContent::SitemapIndex(urls) => {
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://example.com/sitemap-pages.xml");
assert_eq!(urls[1], "https://example.com/sitemap-posts.xml");
}
_ => panic!("Expected SitemapIndex"),
}
}
#[test]
fn test_parse_empty_urlset() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>"#;
match parse_sitemap(xml).unwrap() {
SitemapContent::UrlSet(entries) => {
assert_eq!(entries.len(), 0);
}
_ => panic!("Expected UrlSet"),
}
}
}