Skip to main content

crawlkit_engine/
robots.rs

1//! robots.txt fetching, parsing, and caching.
2//!
3//! Provides a `RobotsTxtCache` that fetches and parses robots.txt files
4//! once per domain, then serves cached results to the crawl loop and
5//! analyzers.
6
7use std::sync::Arc;
8
9use dashmap::DashMap;
10
11use crate::analyzers::RobotsRule;
12use crate::http::HttpClient;
13use crate::CrawlConfig;
14
15/// Per-domain robots.txt cache.
16///
17/// Thread-safe. Fetches robots.txt once per unique domain, parses it,
18/// and caches the rules. Subsequent requests for the same domain return
19/// the cached result.
20pub struct RobotsTxtCache {
21    /// Domain -> parsed rules.
22    cache: DashMap<String, CachedRobotsTxt>,
23    /// HTTP client for fetching.
24    client: Arc<HttpClient>,
25    /// Default user-agent for rule matching.
26    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    /// Create a new cache with the given HTTP client.
37    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    /// Get robots.txt rules for a domain.
46    ///
47    /// Returns `(rules, sitemap_urls, raw_content)`. If the domain has
48    /// no robots.txt, returns empty vectors and an empty string.
49    /// Fetches from the network on first access for each domain.
50    pub async fn get(&self, scheme: &str, domain: &str) -> (Vec<RobotsRule>, Vec<String>, String) {
51        // Check cache first
52        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        // Fetch robots.txt
61        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                    // 404 or other error: treat as no robots.txt
76                    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    /// Check if a path is disallowed for the given domain and user-agent.
99    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    /// Get crawl-delay for a domain (in seconds).
105    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    /// Get all sitemap URLs discovered from robots.txt for a domain.
111    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    /// Get the raw robots.txt content for a domain.
117    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
134/// Parse robots.txt content into rules for a specific user-agent.
135///
136/// Implements the standard robots.txt protocol:
137/// - User-agent lines apply to subsequent rules until next User-agent or end
138/// - Disallow/Allow paths are prefix-matched
139/// - Crawl-delay specifies delay between requests
140/// - Sitemap directives are collected
141fn 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        // Skip empty lines and comments
154        if line.is_empty() || line.starts_with('#') {
155            continue;
156        }
157
158        // Split on first ':'
159        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                // Save previous block if it applies to our target
167                if (!current_agent.is_empty() && user_agent_matches(&current_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(), // sitemaps are global
176                    });
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            _ => {} // Ignore unknown directives
205        }
206    }
207
208    // Save last block
209    if (!current_agent.is_empty() && user_agent_matches(&current_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
224/// Check if a user-agent string matches the target.
225///
226/// Case-insensitive substring match. `*` matches everything.
227fn 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
236/// Check if a path is disallowed by any rule.
237fn 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    // Allow overrides Disallow for matching prefixes
249    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
264/// Get crawl-delay for a user-agent from the rules.
265fn 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        // Should match both the specific rule and the wildcard
309        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}