use std::fs;
use std::io::Write;
use std::path::Path;
use crate::error::{CrawlerError, CrawlerResult};
use crate::export::exporter::Exporter;
use crate::output::output::Output;
use crate::result::status::Status;
use crate::types::ContentTypeId;
use crate::utils;
pub struct SitemapExporter {
pub output_sitemap_xml: Option<String>,
pub output_sitemap_txt: Option<String>,
pub base_priority: f64,
pub priority_increase: f64,
}
impl SitemapExporter {
pub fn new(
output_sitemap_xml: Option<String>,
output_sitemap_txt: Option<String>,
base_priority: f64,
priority_increase: f64,
) -> Self {
Self {
output_sitemap_xml,
output_sitemap_txt,
base_priority,
priority_increase,
}
}
fn collect_sitemap_urls(&self, status: &Status) -> Vec<String> {
let visited_urls = status.get_visited_urls();
let mut urls: Vec<String> = visited_urls
.iter()
.filter(|vu| !vu.is_external && vu.content_type == ContentTypeId::Html && vu.status_code == 200)
.map(|vu| vu.url.clone())
.collect();
urls.sort_by(|a, b| {
let a_trimmed = a.trim_end_matches('/');
let b_trimmed = b.trim_end_matches('/');
let a_slashes = a_trimmed.matches('/').count();
let b_slashes = b_trimmed.matches('/').count();
a_slashes.cmp(&b_slashes).then_with(|| a.cmp(b))
});
urls
}
fn generate_xml_sitemap(&self, output_file: &str, urls: &[String]) -> CrawlerResult<String> {
let output_file = if output_file.to_lowercase().ends_with(".xml") {
output_file.to_string()
} else {
let stripped = regex::Regex::new(r"\.xml$")
.ok()
.map(|re| re.replace(output_file, "").to_string())
.unwrap_or_else(|| output_file.to_string());
format!("{}.xml", stripped)
};
let path = Path::new(&output_file);
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent).map_err(|e| {
CrawlerError::Export(format!("Cannot create output directory '{}': {}", parent.display(), e))
})?;
}
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<urlset xmlns=\"https://www.sitemaps.org/schemas/sitemap/0.9\">\n");
xml.push_str("<!-- Sitemap generated using SiteOne Crawler - https://crawler.siteone.io/features/sitemap-generator/ -->\n");
for url in urls {
let slashes_count = url::Url::parse(url)
.ok()
.map(|u| u.path().matches('/').count())
.unwrap_or(1) as f64;
let priority = (self.base_priority + (self.priority_increase * (1.0 - slashes_count))).clamp(0.1, 1.0);
let escaped_url = escape_xml(url);
xml.push_str(" <url>\n");
xml.push_str(&format!(" <loc>{}</loc>\n", escaped_url));
xml.push_str(&format!(" <priority>{:.1}</priority>\n", priority));
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>\n");
let mut file = fs::File::create(&output_file)
.map_err(|e| CrawlerError::Export(format!("Failed to create XML sitemap file '{}': {}", output_file, e)))?;
file.write_all(xml.as_bytes())
.map_err(|e| CrawlerError::Export(format!("Failed to write XML sitemap to '{}': {}", output_file, e)))?;
Ok(output_file)
}
fn generate_txt_sitemap(&self, output_file: &str, urls: &[String]) -> CrawlerResult<String> {
let output_file = if output_file.to_lowercase().ends_with(".txt") {
output_file.to_string()
} else {
let stripped = regex::Regex::new(r"\.txt$")
.ok()
.map(|re| re.replace(output_file, "").to_string())
.unwrap_or_else(|| output_file.to_string());
format!("{}.txt", stripped)
};
let path = Path::new(&output_file);
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent).map_err(|e| {
CrawlerError::Export(format!("Cannot create output directory '{}': {}", parent.display(), e))
})?;
}
let content = urls.join("\n");
fs::write(&output_file, &content)
.map_err(|e| CrawlerError::Export(format!("Failed to write TXT sitemap to '{}': {}", output_file, e)))?;
Ok(output_file)
}
}
impl Exporter for SitemapExporter {
fn get_name(&self) -> &str {
"SitemapExporter"
}
fn should_be_activated(&self) -> bool {
self.output_sitemap_xml.is_some() || self.output_sitemap_txt.is_some()
}
fn export(&mut self, status: &Status, _output: &dyn Output) -> CrawlerResult<()> {
let urls = self.collect_sitemap_urls(status);
if let Some(ref output_file) = self.output_sitemap_xml.clone() {
match self.generate_xml_sitemap(output_file, &urls) {
Ok(sitemap_file) => {
let display_path = utils::get_output_formatted_path(&sitemap_file);
status.add_info_to_summary("sitemap-xml", &format!("XML sitemap generated to '{}'", display_path));
}
Err(e) => {
status.add_critical_to_summary("sitemap-xml", &format!("Sitemap XML ERROR: {}", e));
}
}
}
if let Some(ref output_file) = self.output_sitemap_txt.clone() {
match self.generate_txt_sitemap(output_file, &urls) {
Ok(sitemap_file) => {
let display_path = utils::get_output_formatted_path(&sitemap_file);
status.add_info_to_summary("sitemap-txt", &format!("TXT sitemap generated to '{}'", display_path));
}
Err(e) => {
status.add_critical_to_summary("sitemap-txt", &format!("Sitemap TXT ERROR: {}", e));
}
}
}
Ok(())
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}