microclaw_engine/internal/tool_runtime/
website_policy.rs1use std::collections::HashMap;
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16use reqwest::Url;
17
18const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
19const MAX_ROBOTS_BYTES: usize = 500_000;
20
21#[derive(Debug, Clone, Default)]
22struct RobotsRules {
23 disallow: Vec<String>,
24 allow: Vec<String>,
25 crawl_delay: Option<f64>,
26}
27
28#[derive(Debug, Clone)]
29struct CachedRobots {
30 fetched_at: Instant,
31 rules: RobotsRules,
32}
33
34#[derive(Debug, Clone)]
35pub struct CrawlHint {
36 pub allowed: bool,
37 pub reason: Option<String>,
38 pub crawl_delay_secs: Option<f64>,
39}
40
41fn cache() -> &'static Mutex<HashMap<String, CachedRobots>> {
42 use std::sync::OnceLock;
43 static CACHE: OnceLock<Mutex<HashMap<String, CachedRobots>>> = OnceLock::new();
44 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
45}
46
47fn cache_key(scheme: &str, host: &str, port: Option<u16>) -> String {
48 match port {
49 Some(p) => format!("{scheme}://{host}:{p}"),
50 None => format!("{scheme}://{host}"),
51 }
52}
53
54fn parse_robots_txt(text: &str, our_agent: &str) -> RobotsRules {
55 let mut our_rules = RobotsRules::default();
58 let mut star_rules = RobotsRules::default();
59 let mut current: Option<Vec<String>> = None; let our_agent_lower = our_agent.to_ascii_lowercase();
61
62 for raw_line in text.lines() {
63 let line = raw_line.split('#').next().unwrap_or("").trim();
64 if line.is_empty() {
65 continue;
66 }
67 let Some((key, value)) = line.split_once(':') else {
68 continue;
69 };
70 let key = key.trim().to_ascii_lowercase();
71 let value = value.trim().to_string();
72 match key.as_str() {
73 "user-agent" => {
74 if current.as_ref().is_some_and(|_| true) {
75 }
77 current = Some(vec![value.to_ascii_lowercase()]);
78 }
79 "disallow" | "allow" | "crawl-delay" => {
80 let Some(uas) = current.as_ref() else {
81 continue;
82 };
83 let matches_ours = uas.iter().any(|u| u == &our_agent_lower);
84 let matches_star = uas.iter().any(|u| u == "*");
85 let targets: Vec<&mut RobotsRules> = if matches_ours {
86 vec![&mut our_rules]
87 } else if matches_star {
88 vec![&mut star_rules]
89 } else {
90 continue;
91 };
92 for rules in targets {
93 match key.as_str() {
94 "disallow" if !value.is_empty() => {
95 rules.disallow.push(value.clone());
96 }
97 "allow" if !value.is_empty() => {
98 rules.allow.push(value.clone());
99 }
100 "crawl-delay" => {
101 if let Ok(n) = value.parse::<f64>() {
102 rules.crawl_delay = Some(n);
103 }
104 }
105 _ => {}
106 }
107 }
108 }
109 _ => {}
110 }
111 }
112
113 if our_rules.disallow.is_empty()
115 && our_rules.allow.is_empty()
116 && our_rules.crawl_delay.is_none()
117 {
118 our_rules = star_rules;
119 }
120 our_rules
121}
122
123fn evaluate_path(rules: &RobotsRules, path: &str) -> CrawlHint {
127 let mut best_allow = 0usize;
128 let mut best_disallow = 0usize;
129 for rule in &rules.allow {
130 if path.starts_with(rule) && rule.len() > best_allow {
131 best_allow = rule.len();
132 }
133 }
134 for rule in &rules.disallow {
135 if path.starts_with(rule) && rule.len() > best_disallow {
136 best_disallow = rule.len();
137 }
138 }
139 if best_disallow > best_allow {
140 CrawlHint {
141 allowed: false,
142 reason: Some(format!(
143 "disallowed by robots.txt (rule len {best_disallow})"
144 )),
145 crawl_delay_secs: rules.crawl_delay,
146 }
147 } else {
148 CrawlHint {
149 allowed: true,
150 reason: None,
151 crawl_delay_secs: rules.crawl_delay,
152 }
153 }
154}
155
156pub async fn consult_robots(client: &reqwest::Client, url: &Url, user_agent: &str) -> CrawlHint {
160 let Some(host) = url.host_str() else {
161 return CrawlHint {
162 allowed: true,
163 reason: None,
164 crawl_delay_secs: None,
165 };
166 };
167 let scheme = url.scheme();
168 let port = url.port();
169 let key = cache_key(scheme, host, port);
170 let now = Instant::now();
171 if let Ok(guard) = cache().lock() {
172 if let Some(entry) = guard.get(&key) {
173 if now.duration_since(entry.fetched_at) < CACHE_TTL {
174 return evaluate_path(&entry.rules, url.path());
175 }
176 }
177 }
178
179 let robots_url = {
180 let mut u = url.clone();
181 u.set_path("/robots.txt");
182 u.set_query(None);
183 u.set_fragment(None);
184 u
185 };
186 let body = match client.get(robots_url).send().await {
187 Ok(r) if r.status().is_success() => r.text().await.unwrap_or_default(),
188 _ => String::new(),
189 };
190 let truncated = if body.len() > MAX_ROBOTS_BYTES {
191 body[..MAX_ROBOTS_BYTES].to_string()
192 } else {
193 body
194 };
195 let rules = parse_robots_txt(&truncated, user_agent);
196 if let Ok(mut guard) = cache().lock() {
197 guard.insert(
198 key,
199 CachedRobots {
200 fetched_at: now,
201 rules: rules.clone(),
202 },
203 );
204 }
205 evaluate_path(&rules, url.path())
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn empty_robots_allows_all() {
214 let rules = parse_robots_txt("", "microclaw");
215 let hint = evaluate_path(&rules, "/anything");
216 assert!(hint.allowed);
217 }
218
219 #[test]
220 fn disallow_blocks_matching_prefix() {
221 let txt = "User-agent: *\nDisallow: /private/\n";
222 let rules = parse_robots_txt(txt, "microclaw");
223 assert!(!evaluate_path(&rules, "/private/doc").allowed);
224 assert!(evaluate_path(&rules, "/public/doc").allowed);
225 }
226
227 #[test]
228 fn allow_overrides_disallow_when_longer() {
229 let txt = "User-agent: *\nDisallow: /private/\nAllow: /private/public/\n";
230 let rules = parse_robots_txt(txt, "microclaw");
231 assert!(!evaluate_path(&rules, "/private/doc").allowed);
232 assert!(evaluate_path(&rules, "/private/public/doc").allowed);
233 }
234
235 #[test]
236 fn ua_specific_rules_override_star() {
237 let txt = "User-agent: *\nDisallow: /\n\nUser-agent: microclaw\nDisallow: /secret/\n";
238 let rules = parse_robots_txt(txt, "microclaw");
239 assert!(!evaluate_path(&rules, "/secret/x").allowed);
241 assert!(evaluate_path(&rules, "/open").allowed);
242 }
243
244 #[test]
245 fn crawl_delay_parsed() {
246 let txt = "User-agent: *\nCrawl-Delay: 5\n";
247 let rules = parse_robots_txt(txt, "microclaw");
248 assert_eq!(rules.crawl_delay, Some(5.0));
249 }
250
251 #[test]
252 fn comments_stripped() {
253 let txt = "User-agent: * # applies to all\nDisallow: /admin # internal\n";
254 let rules = parse_robots_txt(txt, "microclaw");
255 assert!(!evaluate_path(&rules, "/admin/panel").allowed);
256 }
257}