Skip to main content

crawlkit_engine/
ai_bots.rs

1use serde::{Deserialize, Serialize};
2
3use crate::storage::Severity;
4
5/// Represents an AI crawler/bot that may access web content.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AiBot {
8    /// Identifier string used in robots.txt User-agent directive.
9    pub name: &'static str,
10    /// Organization that operates the bot.
11    pub owner: &'static str,
12    /// Purpose of the bot (training, search, etc.).
13    pub purpose: &'static str,
14    /// Severity when this bot is blocked.
15    pub severity: Severity,
16}
17
18/// Registry of known AI bots for robots.txt analysis.
19///
20/// Maintains a static list of AI crawlers with their metadata.
21/// Extensible via TOML configuration (Phase 8.5).
22pub struct AiBotRegistry {
23    bots: &'static [AiBot],
24}
25
26impl AiBotRegistry {
27    /// Create registry with default bot list.
28    #[must_use]
29    pub fn default_registry() -> Self {
30        Self { bots: AI_BOTS }
31    }
32
33    /// Create registry with custom bot list.
34    #[must_use]
35    pub fn with_bots(bots: &'static [AiBot]) -> Self {
36        Self { bots }
37    }
38
39    /// Get all registered bots.
40    #[must_use]
41    pub fn bots(&self) -> &'static [AiBot] {
42        self.bots
43    }
44
45    /// Find a bot by name (case-insensitive).
46    #[must_use]
47    pub fn find(&self, name: &str) -> Option<&AiBot> {
48        let lower = name.to_lowercase();
49        self.bots.iter().find(|b| b.name.to_lowercase() == lower)
50    }
51}
52
53impl AiBot {
54    /// Get ordinal index for finding code generation.
55    #[must_use]
56    pub fn ordinal(&self) -> usize {
57        AI_BOTS
58            .iter()
59            .position(|b| std::ptr::eq(b, self))
60            .unwrap_or(0)
61            + 1
62    }
63}
64
65/// Default list of known AI bots.
66pub static AI_BOTS: &[AiBot] = &[
67    AiBot {
68        name: "GPTBot",
69        owner: "OpenAI",
70        purpose: "ChatGPT training and browsing",
71        severity: Severity::Error,
72    },
73    AiBot {
74        name: "Google-Extended",
75        owner: "Google",
76        purpose: "Gemini/AI Overviews training",
77        severity: Severity::Error,
78    },
79    AiBot {
80        name: "PerplexityBot",
81        owner: "Perplexity AI",
82        purpose: "Real-time search answers",
83        severity: Severity::Error,
84    },
85    AiBot {
86        name: "ClaudeBot",
87        owner: "Anthropic",
88        purpose: "Claude web search",
89        severity: Severity::Error,
90    },
91    AiBot {
92        name: "Anthropic-AI",
93        owner: "Anthropic",
94        purpose: "Claude training",
95        severity: Severity::Error,
96    },
97    AiBot {
98        name: "Bytespider",
99        owner: "ByteDance",
100        purpose: "TikTok/Douyin AI",
101        severity: Severity::Warning,
102    },
103    AiBot {
104        name: "Amazonbot",
105        owner: "Amazon",
106        purpose: "Alexa/shopping AI",
107        severity: Severity::Warning,
108    },
109    AiBot {
110        name: "Meta-ExternalAgent",
111        owner: "Meta",
112        purpose: "LLaMA training",
113        severity: Severity::Warning,
114    },
115    AiBot {
116        name: "Applebot-Extended",
117        owner: "Apple",
118        purpose: "Apple Intelligence",
119        severity: Severity::Warning,
120    },
121    AiBot {
122        name: "cohere-ai",
123        owner: "Cohere",
124        purpose: "Command R training",
125        severity: Severity::Info,
126    },
127];
128
129/// Parse robots.txt content and check if a specific bot is disallowed.
130///
131/// # Arguments
132/// * `robots_txt` - Raw robots.txt content
133/// * `bot_name` - User-agent string to check
134///
135/// # Returns
136/// `true` if the bot is disallowed for all paths.
137#[must_use]
138pub fn robots_txt_disallows_bot(robots_txt: &str, bot_name: &str) -> bool {
139    let mut matching_block: Option<bool> = None;
140    let mut disallow_all = false;
141
142    for line in robots_txt.lines() {
143        let trimmed = line.trim();
144
145        if trimmed.is_empty() || trimmed.starts_with('#') {
146            if let Some(is_matching) = matching_block {
147                if is_matching && disallow_all {
148                    return true;
149                }
150            }
151            matching_block = None;
152            disallow_all = false;
153            continue;
154        }
155
156        if let Some((key, value)) = parse_directive(trimmed) {
157            match key.as_str() {
158                "user-agent" => {
159                    if let Some(true) = matching_block {
160                        if disallow_all {
161                            return true;
162                        }
163                    }
164                    let matches = value == "*" || value.to_lowercase() == bot_name.to_lowercase();
165                    matching_block = Some(matches);
166                    disallow_all = false;
167                }
168                "disallow" if matching_block.unwrap_or(false) => {
169                    if value == "/" {
170                        disallow_all = true;
171                    } else if value.is_empty() {
172                        disallow_all = false;
173                    }
174                }
175                _ => {}
176            }
177        }
178    }
179
180    if let Some(true) = matching_block {
181        if disallow_all {
182            return true;
183        }
184    }
185
186    false
187}
188
189/// Parse a robots.txt directive into (key, value) pair.
190fn parse_directive(line: &str) -> Option<(String, String)> {
191    let parts: Vec<&str> = line.splitn(2, ':').collect();
192    if parts.len() == 2 {
193        Some((parts[0].trim().to_lowercase(), parts[1].trim().to_string()))
194    } else {
195        None
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_ai_bot_registry_default() {
205        let registry = AiBotRegistry::default_registry();
206        assert!(!registry.bots().is_empty());
207        assert!(registry.find("GPTBot").is_some());
208        assert!(registry.find("gptbot").is_some()); // case-insensitive
209        assert!(registry.find("nonexistent").is_none());
210    }
211
212    #[test]
213    fn test_robots_txt_blocks_gptbot() {
214        let robots_txt = "User-agent: GPTBot\nDisallow: /";
215        assert!(robots_txt_disallows_bot(robots_txt, "GPTBot"));
216    }
217
218    #[test]
219    fn test_robots_txt_allows_gptbot() {
220        let robots_txt = "User-agent: GPTBot\nAllow: /";
221        assert!(!robots_txt_disallows_bot(robots_txt, "GPTBot"));
222    }
223
224    #[test]
225    fn test_robots_txt_blocks_all_bots() {
226        let robots_txt = "User-agent: *\nDisallow: /";
227        assert!(robots_txt_disallows_bot(robots_txt, "GPTBot"));
228        assert!(robots_txt_disallows_bot(robots_txt, "AnyBot"));
229    }
230
231    #[test]
232    fn test_robots_txt_empty() {
233        assert!(!robots_txt_disallows_bot("", "GPTBot"));
234    }
235
236    #[test]
237    fn test_robots_txt_multiple_blocks() {
238        let robots_txt = "User-agent: *\nAllow: /\n\nUser-agent: GPTBot\nDisallow: /";
239        assert!(!robots_txt_disallows_bot(robots_txt, "Googlebot"));
240        assert!(robots_txt_disallows_bot(robots_txt, "GPTBot"));
241    }
242}