1use std::sync::Arc;
8
9use dashmap::DashMap;
10
11use crate::analyzers::RobotsRule;
12use crate::http::HttpClient;
13use crate::CrawlConfig;
14
15pub struct RobotsTxtCache {
21 cache: DashMap<String, CachedRobotsTxt>,
23 client: Arc<HttpClient>,
25 user_agent: String,
27}
28
29struct CachedRobotsTxt {
30 rules: Vec<RobotsRule>,
31 sitemap_urls: Vec<String>,
32 raw_content: String,
33}
34
35impl RobotsTxtCache {
36 pub fn new(client: Arc<HttpClient>, config: &CrawlConfig) -> Self {
38 Self {
39 cache: DashMap::new(),
40 client,
41 user_agent: config.user_agent.clone(),
42 }
43 }
44
45 pub async fn get(&self, scheme: &str, domain: &str) -> (Vec<RobotsRule>, Vec<String>, String) {
51 if let Some(entry) = self.cache.get(domain) {
53 return (
54 entry.rules.clone(),
55 entry.sitemap_urls.clone(),
56 entry.raw_content.clone(),
57 );
58 }
59
60 let robots_url = format!("{scheme}://{domain}/robots.txt");
62 let url = match url::Url::parse(&robots_url) {
63 Ok(u) => u,
64 Err(_) => {
65 self.insert_empty(domain);
66 return (Vec::new(), Vec::new(), String::new());
67 }
68 };
69
70 let content = match self.client.fetch(&url).await {
71 Ok(result) => {
72 if result.status_code == 200 {
73 result.body
74 } else {
75 self.insert_empty(domain);
77 return (Vec::new(), Vec::new(), String::new());
78 }
79 }
80 Err(_) => {
81 self.insert_empty(domain);
82 return (Vec::new(), Vec::new(), String::new());
83 }
84 };
85
86 let (rules, sitemap_urls) = parse_robots_txt(&content, &self.user_agent);
87
88 let entry = CachedRobotsTxt {
89 rules: rules.clone(),
90 sitemap_urls: sitemap_urls.clone(),
91 raw_content: content.clone(),
92 };
93 self.cache.insert(domain.to_string(), entry);
94
95 (rules, sitemap_urls, content)
96 }
97
98 pub async fn is_disallowed(&self, scheme: &str, domain: &str, path: &str) -> bool {
100 let (rules, _, _) = self.get(scheme, domain).await;
101 is_path_disallowed(path, &rules, &self.user_agent)
102 }
103
104 pub async fn crawl_delay(&self, scheme: &str, domain: &str) -> Option<f64> {
106 let (rules, _, _) = self.get(scheme, domain).await;
107 get_crawl_delay(&rules, &self.user_agent)
108 }
109
110 pub async fn sitemaps(&self, scheme: &str, domain: &str) -> Vec<String> {
112 let (_, sitemap_urls, _) = self.get(scheme, domain).await;
113 sitemap_urls
114 }
115
116 pub async fn raw_content(&self, scheme: &str, domain: &str) -> String {
118 let (_, _, raw) = self.get(scheme, domain).await;
119 raw
120 }
121
122 fn insert_empty(&self, domain: &str) {
123 self.cache.insert(
124 domain.to_string(),
125 CachedRobotsTxt {
126 rules: Vec::new(),
127 sitemap_urls: Vec::new(),
128 raw_content: String::new(),
129 },
130 );
131 }
132}
133
134fn parse_robots_txt(content: &str, target_agent: &str) -> (Vec<RobotsRule>, Vec<String>) {
142 let mut rules = Vec::new();
143 let mut sitemap_urls = Vec::new();
144 let mut current_agent = String::new();
145 let mut current_disallowed = Vec::new();
146 let mut current_allowed = Vec::new();
147 let mut current_delay: Option<f64> = None;
148 let mut applies_to_all = false;
149
150 for line in content.lines() {
151 let line = line.trim();
152
153 if line.is_empty() || line.starts_with('#') {
155 continue;
156 }
157
158 let (key, value) = match line.split_once(':') {
160 Some((k, v)) => (k.trim().to_lowercase(), v.trim().to_string()),
161 None => continue,
162 };
163
164 match key.as_str() {
165 "user-agent" => {
166 if (!current_agent.is_empty() && user_agent_matches(¤t_agent, target_agent))
168 || applies_to_all
169 {
170 rules.push(RobotsRule {
171 user_agent: current_agent.clone(),
172 disallowed_paths: current_disallowed.clone(),
173 allowed_paths: current_allowed.clone(),
174 crawl_delay: current_delay,
175 sitemaps: Vec::new(), });
177 }
178 current_agent = value;
179 current_disallowed.clear();
180 current_allowed.clear();
181 current_delay = None;
182 applies_to_all = current_agent == "*";
183 }
184 "disallow" => {
185 if !value.is_empty() {
186 current_disallowed.push(value);
187 }
188 }
189 "allow" => {
190 if !value.is_empty() {
191 current_allowed.push(value);
192 }
193 }
194 "crawl-delay" => {
195 if let Ok(delay) = value.parse::<f64>() {
196 current_delay = Some(delay);
197 }
198 }
199 "sitemap" => {
200 if !value.is_empty() {
201 sitemap_urls.push(value);
202 }
203 }
204 _ => {} }
206 }
207
208 if (!current_agent.is_empty() && user_agent_matches(¤t_agent, target_agent))
210 || applies_to_all
211 {
212 rules.push(RobotsRule {
213 user_agent: current_agent,
214 disallowed_paths: current_disallowed,
215 allowed_paths: current_allowed,
216 crawl_delay: current_delay,
217 sitemaps: Vec::new(),
218 });
219 }
220
221 (rules, sitemap_urls)
222}
223
224fn user_agent_matches(rule_agent: &str, target: &str) -> bool {
228 if rule_agent == "*" {
229 return true;
230 }
231 let rule_lower = rule_agent.to_lowercase();
232 let target_lower = target.to_lowercase();
233 target_lower.contains(&rule_lower) || rule_lower.contains(&target_lower)
234}
235
236fn is_path_disallowed(path: &str, rules: &[RobotsRule], user_agent: &str) -> bool {
238 let mut disallowed = Vec::new();
239 let mut allowed = Vec::new();
240
241 for rule in rules {
242 if user_agent_matches(&rule.user_agent, user_agent) || rule.user_agent == "*" {
243 disallowed.extend(rule.disallowed_paths.iter());
244 allowed.extend(rule.allowed_paths.iter());
245 }
246 }
247
248 for pattern in &allowed {
250 if path.starts_with(pattern.as_str()) {
251 return false;
252 }
253 }
254
255 for pattern in &disallowed {
256 if path.starts_with(pattern.as_str()) {
257 return true;
258 }
259 }
260
261 false
262}
263
264fn get_crawl_delay(rules: &[RobotsRule], user_agent: &str) -> Option<f64> {
266 for rule in rules {
267 if user_agent_matches(&rule.user_agent, user_agent) || rule.user_agent == "*" {
268 if let Some(delay) = rule.crawl_delay {
269 return Some(delay);
270 }
271 }
272 }
273 None
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_parse_robots_txt_basic() {
282 let content = r#"
283User-agent: *
284Disallow: /admin/
285Disallow: /private/
286Allow: /admin/public/
287Crawl-delay: 2
288Sitemap: https://example.com/sitemap.xml
289"#;
290 let (rules, sitemaps) = parse_robots_txt(content, "MyBot/1.0");
291 assert_eq!(rules.len(), 1);
292 assert_eq!(rules[0].disallowed_paths, vec!["/admin/", "/private/"]);
293 assert_eq!(rules[0].allowed_paths, vec!["/admin/public/"]);
294 assert_eq!(rules[0].crawl_delay, Some(2.0));
295 assert_eq!(sitemaps, vec!["https://example.com/sitemap.xml"]);
296 }
297
298 #[test]
299 fn test_parse_robots_txt_specific_agent() {
300 let content = r#"
301User-agent: Googlebot
302Disallow: /no-google/
303
304User-agent: *
305Disallow: /no-all/
306"#;
307 let (rules, _) = parse_robots_txt(content, "Googlebot/2.1");
308 assert!(rules
310 .iter()
311 .any(|r| r.disallowed_paths.contains(&"/no-google/".to_string())));
312 assert!(rules
313 .iter()
314 .any(|r| r.disallowed_paths.contains(&"/no-all/".to_string())));
315 }
316
317 #[test]
318 fn test_is_path_disallowed() {
319 let rules = vec![RobotsRule {
320 user_agent: "*".to_string(),
321 disallowed_paths: vec!["/admin/".to_string(), "/private/".to_string()],
322 allowed_paths: vec!["/admin/public/".to_string()],
323 crawl_delay: None,
324 sitemaps: Vec::new(),
325 }];
326
327 assert!(is_path_disallowed("/admin/settings", &rules, "AnyBot"));
328 assert!(!is_path_disallowed("/admin/public/page", &rules, "AnyBot"));
329 assert!(!is_path_disallowed("/public/page", &rules, "AnyBot"));
330 }
331
332 #[test]
333 fn test_user_agent_matches() {
334 assert!(user_agent_matches("*", "AnyBot"));
335 assert!(user_agent_matches("Googlebot", "Googlebot/2.1"));
336 assert!(user_agent_matches(
337 "Googlebot",
338 "Mozilla/5.0 (compatible; Googlebot/2.1)"
339 ));
340 assert!(!user_agent_matches("Bingbot", "Googlebot/2.1"));
341 }
342
343 #[test]
344 fn test_get_crawl_delay() {
345 let rules = vec![RobotsRule {
346 user_agent: "*".to_string(),
347 disallowed_paths: Vec::new(),
348 allowed_paths: Vec::new(),
349 crawl_delay: Some(5.0),
350 sitemaps: Vec::new(),
351 }];
352
353 assert_eq!(get_crawl_delay(&rules, "AnyBot"), Some(5.0));
354 }
355}