lc_rag/loaders/
web_scraper.rs1use std::collections::{HashMap, HashSet};
7use std::sync::LazyLock;
8
9use async_trait::async_trait;
10
11use super::{DocumentLoader, LoaderError};
12use lc_vector_stores::Document;
13
14static HREF_RE: LazyLock<regex::Regex> =
16 LazyLock::new(|| regex::Regex::new(r#"href\s*=\s*["']([^"']+)["']"#).unwrap());
17static DOMAIN_RE: LazyLock<regex::Regex> =
18 LazyLock::new(|| regex::Regex::new(r"https?://([^/]+)").unwrap());
19static DOMAIN_PREFIX_RE: LazyLock<regex::Regex> =
20 LazyLock::new(|| regex::Regex::new(r"https?://[^/]+").unwrap());
21
22pub struct WebScraperLoader {
26 url: String,
28 max_depth: usize,
30 max_pages: usize,
32 fail_on_error: bool,
34}
35
36impl WebScraperLoader {
37 pub fn new(url: impl Into<String>) -> Self {
39 Self {
40 url: url.into(),
41 max_depth: 0,
42 max_pages: 1,
43 fail_on_error: false,
44 }
45 }
46
47 pub fn with_max_depth(mut self, depth: usize) -> Self {
49 self.max_depth = depth;
50 self
51 }
52
53 pub fn with_max_pages(mut self, pages: usize) -> Self {
55 self.max_pages = pages;
56 self
57 }
58
59 pub fn with_fail_on_error(mut self, fail: bool) -> Self {
61 self.fail_on_error = fail;
62 self
63 }
64
65 fn extract_text(html: &str) -> String {
67 super::HTMLLoader::extract_text(html)
68 }
69
70 fn extract_links(html: &str, base_url: &str) -> Vec<String> {
72 let base_domain = Self::extract_domain(base_url);
73 HREF_RE
74 .captures_iter(html)
75 .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
76 .filter(|link| !link.starts_with('#') && !link.starts_with("javascript:"))
77 .filter_map(|link| Self::resolve_url(base_url, &link))
78 .filter(|url| Self::extract_domain(url) == base_domain)
79 .collect()
80 }
81
82 fn extract_domain(url: &str) -> String {
84 DOMAIN_RE
85 .captures(url)
86 .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
87 .unwrap_or_default()
88 }
89
90 fn resolve_url(base: &str, href: &str) -> Option<String> {
92 if href.starts_with("http://") || href.starts_with("https://") {
93 Some(href.to_string())
94 } else if href.starts_with('/') {
95 let domain = DOMAIN_PREFIX_RE.find(base)?.as_str();
97 Some(format!("{}{}", domain, href))
98 } else {
99 let base_dir = base.rfind('/').map(|i| &base[..=i]).unwrap_or(base);
101 Some(format!("{}{}", base_dir, href))
102 }
103 }
104
105 async fn fetch_page(url: &str) -> Result<(String, String), LoaderError> {
107 let response = reqwest::get(url)
108 .await
109 .map_err(|e| LoaderError::Other(format!("HTTP 请求失败 {}: {}", url, e)))?;
110 let status = response.status();
111 if !status.is_success() {
112 return Err(LoaderError::Other(format!("HTTP 错误 {}: {}", url, status)));
113 }
114 let html = response
115 .text()
116 .await
117 .map_err(|e| LoaderError::Other(format!("读取响应失败 {}: {}", url, e)))?;
118 Ok((url.to_string(), html))
119 }
120}
121
122#[async_trait]
123impl DocumentLoader for WebScraperLoader {
124 async fn load(&self) -> Result<Vec<Document>, LoaderError> {
125 let mut documents = Vec::new();
126 let mut visited = HashSet::new();
127 let mut queue = vec![(self.url.clone(), 0usize)];
128 let mut failed_count: usize = 0;
129
130 while let Some((url, depth)) = queue.pop() {
131 if visited.contains(&url) || documents.len() >= self.max_pages {
132 continue;
133 }
134 visited.insert(url.clone());
135
136 let (fetched_url, html) = match Self::fetch_page(&url).await {
137 Ok(r) => r,
138 Err(e) => {
139 failed_count += 1;
140 if self.fail_on_error {
141 return Err(e);
142 }
143 eprintln!(
145 "警告: 爬取 {} 失败 (第 {} 个失败): {}",
146 url, failed_count, e
147 );
148 continue;
149 }
150 };
151
152 let text = Self::extract_text(&html);
153
154 let mut metadata = HashMap::new();
155 metadata.insert("format".to_string(), "html".to_string());
156 metadata.insert("source".to_string(), fetched_url.clone());
157
158 documents.push(Document {
159 content: text,
160 metadata,
161 id: None,
162 });
163
164 if depth < self.max_depth {
166 let links = Self::extract_links(&html, &fetched_url);
167 for link in links {
168 if !visited.contains(&link) {
169 queue.push((link, depth + 1));
170 }
171 }
172 }
173 }
174
175 if failed_count > 0 {
176 eprintln!(
177 "警告: 爬取完成,共 {} 个页面失败,{} 个页面成功",
178 failed_count,
179 documents.len()
180 );
181 }
182
183 Ok(documents)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_extract_links() {
193 let html = "<html><body><a href=\"/about\">About</a><a href=\"https://example.com/contact\">Contact</a><a href=\"#top\">Top</a></body></html>";
194 let links = WebScraperLoader::extract_links(html, "https://example.com/");
195 assert!(links.contains(&"https://example.com/about".to_string()));
196 assert!(links.contains(&"https://example.com/contact".to_string()));
197 assert!(!links.iter().any(|l| l.contains('#')));
199 }
200
201 #[test]
202 fn test_extract_domain() {
203 assert_eq!(
204 WebScraperLoader::extract_domain("https://example.com/path"),
205 "example.com"
206 );
207 assert_eq!(
208 WebScraperLoader::extract_domain("http://sub.example.com:8080/path"),
209 "sub.example.com:8080"
210 );
211 }
212
213 #[test]
214 fn test_resolve_url_absolute() {
215 let result =
216 WebScraperLoader::resolve_url("https://example.com/", "https://other.com/page");
217 assert_eq!(result, Some("https://other.com/page".to_string()));
218 }
219
220 #[test]
221 fn test_resolve_url_relative() {
222 let result = WebScraperLoader::resolve_url("https://example.com/dir/page", "other");
223 assert_eq!(result, Some("https://example.com/dir/other".to_string()));
224 }
225
226 #[test]
227 fn test_resolve_url_root_relative() {
228 let result = WebScraperLoader::resolve_url("https://example.com/dir/page", "/root");
229 assert_eq!(result, Some("https://example.com/root".to_string()));
230 }
231
232 #[test]
233 fn test_extract_text() {
234 let html = "<html><body><p>Hello World</p></body></html>";
235 let text = WebScraperLoader::extract_text(html);
236 assert!(text.contains("Hello World"));
237 }
238
239 #[test]
240 fn test_new_creates_single_page_scraper() {
241 let loader = WebScraperLoader::new("https://example.com");
242 assert_eq!(loader.max_depth, 0);
243 assert_eq!(loader.max_pages, 1);
244 }
245
246 #[test]
247 fn test_with_options() {
248 let loader = WebScraperLoader::new("https://example.com")
249 .with_max_depth(2)
250 .with_max_pages(10);
251 assert_eq!(loader.max_depth, 2);
252 assert_eq!(loader.max_pages, 10);
253 }
254}