use async_compression::tokio::bufread::GzipDecoder;
use quick_xml::events::Event;
use quick_xml::Reader;
use std::collections::HashSet;
use thiserror::Error;
use tokio::io::BufReader;
use url::Url;
#[derive(Debug, Error)]
pub enum SitemapError {
#[error("invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
#[error("XML parsing failed: {0}")]
XmlError(#[from] quick_xml::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("no URLs found in sitemap")]
NoUrlsFound,
#[error("invalid sitemap structure")]
InvalidStructure,
#[error("maximum recursion depth exceeded")]
MaxDepthExceeded,
#[error("invalid scheme: {0} (only http/https allowed)")]
InvalidScheme(String),
}
pub type Result<T> = std::result::Result<T, SitemapError>;
#[derive(Debug, Clone)]
pub struct SitemapConfig {
pub gzip_enabled: bool,
pub max_depth: u8,
pub concurrency: usize,
}
impl Default for SitemapConfig {
fn default() -> Self {
Self {
gzip_enabled: true,
max_depth: 3,
concurrency: 5,
}
}
}
impl SitemapConfig {
pub fn builder() -> SitemapConfigBuilder {
SitemapConfigBuilder::default()
}
}
#[derive(Default)]
#[must_use = "builders do nothing unless you call build()"]
pub struct SitemapConfigBuilder {
gzip_enabled: bool,
max_depth: u8,
concurrency: usize,
}
impl SitemapConfigBuilder {
pub fn gzip_enabled(mut self, enabled: bool) -> Self {
self.gzip_enabled = enabled;
self
}
pub fn max_depth(mut self, depth: u8) -> Self {
self.max_depth = depth;
self
}
pub fn concurrency(mut self, count: usize) -> Self {
self.concurrency = count;
self
}
#[must_use]
pub fn build(self) -> SitemapConfig {
SitemapConfig {
gzip_enabled: self.gzip_enabled,
max_depth: self.max_depth,
concurrency: self.concurrency,
}
}
}
pub struct SitemapParser {
config: SitemapConfig,
client: reqwest::Client,
}
impl SitemapParser {
#[must_use]
pub fn new() -> Self {
Self {
config: SitemapConfig::default(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("BUG: failed to build HTTP client"),
}
}
#[must_use]
pub fn with_config(config: SitemapConfig) -> Self {
Self {
config,
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("BUG: failed to build HTTP client"),
}
}
pub async fn parse_from_url(&self, url: &str) -> Result<Vec<Url>> {
self.parse_with_depth(url, self.config.max_depth).await
}
async fn parse_with_depth(&self, url: &str, depth: u8) -> Result<Vec<Url>> {
if depth == 0 {
return Err(SitemapError::MaxDepthExceeded);
}
let _base_url = Url::parse(url)?;
let response = self.client.get(url).send().await.map_err(|e| {
tracing::warn!("HTTP request failed for {}: {}", url, e);
SitemapError::HttpError(e)
})?;
let is_gzip = url.ends_with(".gz")
|| response
.headers()
.get("content-encoding")
.map(|v| v == "gzip")
.unwrap_or(false);
let bytes = response.bytes().await?;
let urls = if is_gzip && self.config.gzip_enabled {
self.parse_gzip_sitemap(&bytes).await?
} else {
self.parse_xml_sitemap(&bytes).await?
};
if self.is_sitemap_index(&urls) {
tracing::debug!("Detected sitemap index, recursing (depth: {})", depth);
self.parse_sitemap_index(&urls, depth - 1).await
} else {
Ok(urls)
}
}
async fn parse_gzip_sitemap(&self, bytes: &[u8]) -> Result<Vec<Url>> {
let reader = BufReader::new(bytes);
let mut decoder = GzipDecoder::new(reader);
let mut decompressed = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut decoder, &mut decompressed).await?;
self.parse_xml_sitemap(&decompressed).await
}
async fn parse_xml_sitemap(&self, bytes: &[u8]) -> Result<Vec<Url>> {
let mut reader = Reader::from_reader(bytes);
let mut urls = HashSet::new();
let mut buf = Vec::new();
let mut in_loc = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) if e.name().as_ref() == b"loc" => {
in_loc = true;
}
Ok(Event::Text(ref e)) if in_loc => {
if let Ok(text) = e.unescape() {
if let Ok(url) = Url::parse(&text) {
if url.scheme() == "http" || url.scheme() == "https" {
urls.insert(url);
} else {
tracing::debug!(
"Filtered URL with invalid scheme: {} ({})",
url,
url.scheme()
);
}
}
}
}
Ok(Event::End(ref e)) if e.name().as_ref() == b"loc" => {
in_loc = false;
}
Ok(Event::Eof) => break,
Err(e) => return Err(SitemapError::XmlError(e)),
_ => {}
}
buf.clear();
}
if urls.is_empty() {
Err(SitemapError::NoUrlsFound)
} else {
Ok(urls.into_iter().collect())
}
}
fn is_sitemap_index(&self, urls: &[Url]) -> bool {
urls.iter()
.any(|u| u.path().ends_with(".xml") || u.path().ends_with(".xml.gz"))
}
async fn parse_sitemap_index(&self, sitemap_urls: &[Url], depth: u8) -> Result<Vec<Url>> {
use futures::stream::{self, StreamExt};
let mut all_urls = HashSet::new();
let results = stream::iter(sitemap_urls)
.map(|url| async move { self.parse_with_depth(url.as_str(), depth).await })
.buffered(self.config.concurrency)
.collect::<Vec<_>>()
.await;
for result in results {
match result {
Ok(urls) => all_urls.extend(urls),
Err(e) => tracing::warn!("Failed to parse sitemap: {}", e),
}
}
if all_urls.is_empty() {
Err(SitemapError::NoUrlsFound)
} else {
Ok(all_urls.into_iter().collect())
}
}
#[must_use]
pub fn has_gzip(&self) -> bool {
self.config.gzip_enabled
}
#[must_use]
pub fn max_depth(&self) -> u8 {
self.config.max_depth
}
}
impl Default for SitemapParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_parse_simple_sitemap() {
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/page1</loc></url>
<url><loc>https://example.com/page2</loc></url>
<url><loc>https://example.com/page3</loc></url>
</urlset>"#;
let parser = SitemapParser::new();
let urls = parser.parse_xml_sitemap(xml.as_bytes()).await.unwrap();
assert_eq!(urls.len(), 3);
assert!(urls
.iter()
.any(|u| u.as_str() == "https://example.com/page1"));
}
#[tokio::test]
async fn test_parse_sitemap_with_duplicates() {
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/page1</loc></url>
<url><loc>https://example.com/page1</loc></url>
<url><loc>https://example.com/page2</loc></url>
</urlset>"#;
let parser = SitemapParser::new();
let urls = parser.parse_xml_sitemap(xml.as_bytes()).await.unwrap();
assert_eq!(urls.len(), 2);
}
#[tokio::test]
async fn test_parse_empty_sitemap() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>"#;
let parser = SitemapParser::new();
let result = parser.parse_xml_sitemap(xml.as_bytes()).await;
assert!(matches!(result, Err(SitemapError::NoUrlsFound)));
}
#[tokio::test]
async fn test_parse_malformed_xml() {
let xml = r#"<?xml version="1.0"?>
<urlset>
<url><loc>https://example.com/page1</loc>
<!-- Missing closing tag -->
</urlset>"#;
let parser = SitemapParser::new();
let result = parser.parse_xml_sitemap(xml.as_bytes()).await;
assert!(result.is_ok() || matches!(result, Err(SitemapError::XmlError(_))));
}
#[test]
fn test_config_builder() {
let config = SitemapConfig::builder()
.gzip_enabled(true)
.max_depth(5)
.concurrency(10)
.build();
assert!(config.gzip_enabled);
assert_eq!(config.max_depth, 5);
assert_eq!(config.concurrency, 10);
}
#[test]
fn test_config_default() {
let config = SitemapConfig::default();
assert!(config.gzip_enabled);
assert_eq!(config.max_depth, 3);
assert_eq!(config.concurrency, 5);
}
#[test]
fn test_is_sitemap_index() {
let parser = SitemapParser::new();
let index_urls = vec![
Url::parse("https://example.com/sitemap1.xml").unwrap(),
Url::parse("https://example.com/sitemap2.xml.gz").unwrap(),
];
assert!(parser.is_sitemap_index(&index_urls));
let regular_urls = vec![
Url::parse("https://example.com/page1").unwrap(),
Url::parse("https://example.com/page2").unwrap(),
];
assert!(!parser.is_sitemap_index(®ular_urls));
}
#[test]
fn test_parser_has_gzip() {
let parser_gzip =
SitemapParser::with_config(SitemapConfig::builder().gzip_enabled(true).build());
assert!(parser_gzip.has_gzip());
let parser_no_gzip =
SitemapParser::with_config(SitemapConfig::builder().gzip_enabled(false).build());
assert!(!parser_no_gzip.has_gzip());
}
#[tokio::test]
async fn test_filter_invalid_schemes() {
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/valid</loc></url>
<url><loc>http://example.com/valid</loc></url>
<url><loc>ftp://example.com/invalid</loc></url>
<url><loc>file:///etc/passwd</loc></url>
<url><loc>javascript:alert(1)</loc></url>
</urlset>"#;
let parser = SitemapParser::new();
let urls = parser.parse_xml_sitemap(xml.as_bytes()).await.unwrap();
assert_eq!(urls.len(), 2);
assert!(urls
.iter()
.all(|u| u.scheme() == "http" || u.scheme() == "https"));
}
#[tokio::test]
async fn test_parse_sitemap_with_namespaces() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>https://example.com/page1</loc>
<image:image><image:loc>https://example.com/image.jpg</image:loc></image:image>
</url>
<url><loc>https://example.com/page2</loc></url>
</urlset>"#;
let parser = SitemapParser::new();
let urls = parser.parse_xml_sitemap(xml.as_bytes()).await.unwrap();
assert!(urls.len() >= 2);
}
}