lc_rag/loaders/
sitemap.rs1use std::collections::HashMap;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use regex::Regex;
10use std::sync::LazyLock;
11
12use super::{DocumentLoader, LoaderError};
13use lc_vector_stores::Document;
14
15static LOC_REGEX: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"<loc>\s*(.*?)\s*</loc>").unwrap());
18
19const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
21
22pub struct SitemapLoader {
26 source: SitemapSource,
28 max_pages: usize,
30 timeout: Duration,
32}
33
34enum SitemapSource {
36 Url(String),
38 Xml(String),
40}
41
42impl SitemapLoader {
43 pub fn from_url(url: impl Into<String>) -> Self {
45 Self {
46 source: SitemapSource::Url(url.into()),
47 max_pages: 100,
48 timeout: DEFAULT_HTTP_TIMEOUT,
49 }
50 }
51
52 pub fn from_xml(xml: impl Into<String>) -> Self {
54 Self {
55 source: SitemapSource::Xml(xml.into()),
56 max_pages: 100,
57 timeout: DEFAULT_HTTP_TIMEOUT,
58 }
59 }
60
61 pub fn with_max_pages(mut self, max: usize) -> Self {
63 self.max_pages = max;
64 self
65 }
66
67 pub fn with_timeout(mut self, timeout: Duration) -> Self {
69 self.timeout = timeout;
70 self
71 }
72
73 fn parse_urls(xml: &str) -> Vec<String> {
75 LOC_REGEX
76 .captures_iter(xml)
77 .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
78 .collect()
79 }
80
81 async fn fetch_page(url: &str, timeout: Duration) -> Result<String, LoaderError> {
83 let (_final_url, body) = super::guarded_fetch(url, timeout).await?;
84 Ok(body)
85 }
86}
87
88#[async_trait]
89impl DocumentLoader for SitemapLoader {
90 async fn load(&self) -> Result<Vec<Document>, LoaderError> {
91 let xml = match &self.source {
93 SitemapSource::Url(url) => Self::fetch_page(url, self.timeout).await?,
94 SitemapSource::Xml(content) => content.clone(),
95 };
96
97 let urls = Self::parse_urls(&xml);
99 let mut documents = Vec::new();
100
101 for url in urls.iter().take(self.max_pages) {
102 match Self::fetch_page(url, self.timeout).await {
103 Ok(html) => {
104 let text = super::HTMLLoader::extract_text(&html);
105 let mut metadata = HashMap::new();
106 metadata.insert("format".to_string(), "html".to_string().into());
107 metadata.insert("source".to_string(), url.clone().into());
108
109 documents.push(Document {
110 content: text,
111 metadata,
112 id: None,
113 });
114 }
115 Err(e) => {
116 log::warn!("Failed to crawl {} (skipped from results): {}", url, e);
117 continue;
118 }
119 }
120 }
121
122 Ok(documents)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn test_parse_urls_simple() {
132 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
133 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
134 <url><loc>https://example.com/</loc></url>
135 <url><loc>https://example.com/about</loc></url>
136 <url><loc>https://example.com/contact</loc></url>
137 </urlset>"#;
138 let urls = SitemapLoader::parse_urls(xml);
139 assert_eq!(urls.len(), 3);
140 assert_eq!(urls[0], "https://example.com/");
141 assert_eq!(urls[1], "https://example.com/about");
142 }
143
144 #[test]
145 fn test_parse_urls_with_whitespace() {
146 let xml = r#"<urlset>
147 <url><loc> https://example.com/page1 </loc></url>
148 <url><loc>https://example.com/page2</loc></url>
149 </urlset>"#;
150 let urls = SitemapLoader::parse_urls(xml);
151 assert_eq!(urls.len(), 2);
152 assert_eq!(urls[0], "https://example.com/page1");
153 }
154
155 #[test]
156 fn test_parse_urls_empty() {
157 let xml = r#"<?xml version="1.0"?><urlset></urlset>"#;
158 let urls = SitemapLoader::parse_urls(xml);
159 assert!(urls.is_empty());
160 }
161
162 #[test]
163 fn test_from_url() {
164 let loader = SitemapLoader::from_url("https://example.com/sitemap.xml");
165 assert_eq!(loader.max_pages, 100);
166 }
167
168 #[test]
169 fn test_with_max_pages() {
170 let loader = SitemapLoader::from_url("https://example.com/sitemap.xml").with_max_pages(5);
171 assert_eq!(loader.max_pages, 5);
172 }
173
174 #[tokio::test]
175 async fn test_load_from_xml() {
176 let xml = r#"<?xml version="1.0"?>
177 <urlset>
178 <url><loc>https://example.com/</loc></url>
179 </urlset>"#;
180 let loader = SitemapLoader::from_xml(xml).with_max_pages(0);
182 let docs = loader.load().await.unwrap();
183 assert!(docs.is_empty()); }
185}