1use std::fmt;
24
25use urlparse_rs::{quote_from_bytes, unquote_to_bytes, urlsplit, urlunsplit, UrlError};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct RequestRate {
30 pub requests: u64,
31 pub seconds: u64,
32}
33
34fn normalize(path: &str) -> String {
38 quote_from_bytes(&unquote_to_bytes(path), "/")
39}
40
41fn normalize_path(path: &str) -> String {
44 match path.split_once('?') {
45 Some((path, query)) => {
46 let mut out = normalize(path);
47 out.push('?');
48 let mut run = String::new();
50 for ch in query.chars() {
51 if ch == '=' || ch == '&' {
52 if !run.is_empty() {
53 out.push_str(&normalize(&run));
54 run.clear();
55 }
56 out.push(ch);
57 } else {
58 run.push(ch);
59 }
60 }
61 if !run.is_empty() {
62 out.push_str(&normalize(&run));
63 }
64 out
65 }
66 None => normalize(path),
67 }
68}
69
70#[derive(Debug, Clone)]
72struct RuleLine {
73 path: String,
74 allowance: bool,
75}
76
77impl RuleLine {
78 fn new(path: &str, allowance: bool) -> Self {
79 let allowance = if path.is_empty() && !allowance { true } else { allowance };
81 RuleLine { path: normalize_path(path), allowance }
82 }
83
84 fn applies_to(&self, filename: &str) -> bool {
85 self.path == "*" || filename.starts_with(&self.path)
86 }
87}
88
89impl fmt::Display for RuleLine {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 write!(f, "{}: {}", if self.allowance { "Allow" } else { "Disallow" }, self.path)
92 }
93}
94
95#[derive(Debug, Clone, Default)]
97struct Entry {
98 useragents: Vec<String>,
99 rulelines: Vec<RuleLine>,
100 delay: Option<i64>,
101 req_rate: Option<RequestRate>,
102}
103
104impl Entry {
105 fn applies_to(&self, useragent: &str) -> bool {
108 let useragent = useragent.split('/').next().unwrap_or("").to_lowercase();
109 for agent in &self.useragents {
110 if agent == "*" {
111 return true;
112 }
113 if useragent.contains(&agent.to_lowercase()) {
114 return true;
115 }
116 }
117 false
118 }
119
120 fn allowance(&self, filename: &str) -> bool {
122 for line in &self.rulelines {
123 if line.applies_to(filename) {
124 return line.allowance;
125 }
126 }
127 true
128 }
129}
130
131impl fmt::Display for Entry {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 let mut parts: Vec<String> = Vec::new();
134 for agent in &self.useragents {
135 parts.push(format!("User-agent: {agent}"));
136 }
137 if let Some(delay) = self.delay {
138 parts.push(format!("Crawl-delay: {delay}"));
139 }
140 if let Some(rate) = self.req_rate {
141 parts.push(format!("Request-rate: {}/{}", rate.requests, rate.seconds));
142 }
143 for line in &self.rulelines {
144 parts.push(line.to_string());
145 }
146 f.write_str(&parts.join("\n"))
147 }
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct RobotFileParser {
153 entries: Vec<Entry>,
154 sitemaps: Vec<String>,
155 default_entry: Option<Entry>,
156 disallow_all: bool,
157 allow_all: bool,
158 url: String,
159 host: String,
160 path: String,
161 last_checked: bool,
164}
165
166impl RobotFileParser {
167 pub fn new(url: &str) -> Self {
169 let mut rp = RobotFileParser::default();
170 rp.set_url(url);
171 rp
172 }
173
174 pub fn mtime(&self) -> bool {
176 self.last_checked
177 }
178
179 pub fn modified(&mut self) {
181 self.last_checked = true;
182 }
183
184 pub fn set_url(&mut self, url: &str) {
187 self.url = url.to_string();
188 if let Ok(s) = urlsplit(url, "", true) {
189 self.host = s.netloc;
190 self.path = s.path;
191 } else {
192 self.host = String::new();
193 self.path = String::new();
194 }
195 }
196
197 pub fn url(&self) -> &str {
199 &self.url
200 }
201
202 pub fn host(&self) -> &str {
204 &self.host
205 }
206
207 pub fn path(&self) -> &str {
209 &self.path
210 }
211
212 fn add_entry(&mut self, entry: Entry) {
213 if entry.useragents.iter().any(|a| a == "*") {
214 if self.default_entry.is_none() {
216 self.default_entry = Some(entry);
217 }
218 } else {
219 self.entries.push(entry);
220 }
221 }
222
223 pub fn parse<S: AsRef<str>>(&mut self, lines: &[S]) {
226 let mut state = 0u8;
228 let mut entry = Entry::default();
229 self.modified();
230 for raw in lines {
231 let raw = raw.as_ref();
232 if raw.is_empty() {
233 match state {
234 1 => {
235 entry = Entry::default();
236 state = 0;
237 }
238 2 => {
239 self.add_entry(std::mem::take(&mut entry));
240 state = 0;
241 }
242 _ => {}
243 }
244 }
245 let line = match raw.find('#') {
247 Some(i) => &raw[..i],
248 None => raw,
249 };
250 let line = line.trim();
251 if line.is_empty() {
252 continue;
253 }
254 let (key, value) = match line.split_once(':') {
255 Some((k, v)) => (k.trim().to_lowercase(), v.trim()),
256 None => continue, };
258 match key.as_str() {
259 "user-agent" => {
260 if state == 2 {
261 self.add_entry(std::mem::take(&mut entry));
262 }
263 entry.useragents.push(value.to_string());
264 state = 1;
265 }
266 "disallow" => {
267 if state != 0 {
268 entry.rulelines.push(RuleLine::new(value, false));
269 state = 2;
270 }
271 }
272 "allow" => {
273 if state != 0 {
274 entry.rulelines.push(RuleLine::new(value, true));
275 state = 2;
276 }
277 }
278 "crawl-delay" => {
279 if state != 0 {
280 if is_ascii_digits(value)
281 && let Ok(n) = value.parse::<i64>()
282 {
283 entry.delay = Some(n);
284 }
285 state = 2;
286 }
287 }
288 "request-rate" => {
289 if state != 0 {
290 let nums: Vec<&str> = value.split('/').collect();
291 if nums.len() == 2
292 && is_ascii_digits(nums[0].trim())
293 && is_ascii_digits(nums[1].trim())
294 && let (Ok(r), Ok(s)) =
295 (nums[0].trim().parse::<u64>(), nums[1].trim().parse::<u64>())
296 {
297 entry.req_rate = Some(RequestRate { requests: r, seconds: s });
298 }
299 state = 2;
300 }
301 }
302 "sitemap" => {
303 self.sitemaps.push(value.to_string());
305 }
306 _ => {}
307 }
308 }
309 if state == 2 {
310 self.add_entry(entry);
311 }
312 }
313
314 pub fn can_fetch(&self, useragent: &str, url: &str) -> Result<bool, UrlError> {
319 if self.disallow_all {
320 return Ok(false);
321 }
322 if self.allow_all {
323 return Ok(true);
324 }
325 if !self.last_checked {
327 return Ok(false);
328 }
329 let parsed = urlsplit(url, "", true)?;
330 let url = urlunsplit("", "", &parsed.path, &parsed.query, &parsed.fragment);
331 let url = normalize_path(&url);
332 let url = if url.is_empty() { "/".to_string() } else { url };
333 for entry in &self.entries {
334 if entry.applies_to(useragent) {
335 return Ok(entry.allowance(&url));
336 }
337 }
338 if let Some(default) = &self.default_entry {
339 return Ok(default.allowance(&url));
340 }
341 Ok(true)
343 }
344
345 pub fn crawl_delay(&self, useragent: &str) -> Option<i64> {
347 if !self.last_checked {
348 return None;
349 }
350 for entry in &self.entries {
351 if entry.applies_to(useragent) {
352 return entry.delay;
353 }
354 }
355 self.default_entry.as_ref().and_then(|e| e.delay)
356 }
357
358 pub fn request_rate(&self, useragent: &str) -> Option<RequestRate> {
360 if !self.last_checked {
361 return None;
362 }
363 for entry in &self.entries {
364 if entry.applies_to(useragent) {
365 return entry.req_rate;
366 }
367 }
368 self.default_entry.as_ref().and_then(|e| e.req_rate)
369 }
370
371 pub fn site_maps(&self) -> Option<&[String]> {
373 if self.sitemaps.is_empty() {
374 None
375 } else {
376 Some(&self.sitemaps)
377 }
378 }
379}
380
381impl fmt::Display for RobotFileParser {
382 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383 let mut entries: Vec<&Entry> = self.entries.iter().collect();
384 if let Some(default) = &self.default_entry {
385 entries.push(default);
386 }
387 let joined: Vec<String> = entries.iter().map(|e| e.to_string()).collect();
388 f.write_str(&joined.join("\n\n"))
389 }
390}
391
392fn is_ascii_digits(s: &str) -> bool {
395 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn parsed(text: &str) -> RobotFileParser {
403 let mut rp = RobotFileParser::new("http://example.com/robots.txt");
404 let lines: Vec<&str> = text.split('\n').collect();
405 rp.parse(&lines);
406 rp
407 }
408
409 #[test]
410 fn basic_allow_disallow() {
411 let rp = parsed("User-agent: *\nDisallow: /private\nAllow: /private/ok");
412 assert_eq!(rp.can_fetch("Bot", "http://example.com/public"), Ok(true));
413 assert_eq!(rp.can_fetch("Bot", "http://example.com/private/x"), Ok(false));
414 assert_eq!(rp.can_fetch("Bot", "http://example.com/private/ok"), Ok(false));
416 }
417
418 #[test]
419 fn empty_disallow_allows_all() {
420 let rp = parsed("User-agent: *\nDisallow:");
421 assert_eq!(rp.can_fetch("Bot", "http://example.com/anything"), Ok(true));
422 }
423
424 #[test]
425 fn not_parsed_denies() {
426 let rp = RobotFileParser::new("http://example.com/robots.txt");
427 assert_eq!(rp.can_fetch("Bot", "http://example.com/"), Ok(false));
428 }
429
430 #[test]
431 fn agent_specific_overrides_default() {
432 let rp = parsed("User-agent: BadBot\nDisallow: /\n\nUser-agent: *\nDisallow: /admin");
433 assert_eq!(rp.can_fetch("BadBot", "http://example.com/x"), Ok(false));
434 assert_eq!(rp.can_fetch("GoodBot", "http://example.com/x"), Ok(true));
435 assert_eq!(rp.can_fetch("GoodBot", "http://example.com/admin"), Ok(false));
436 }
437
438 #[test]
439 fn crawl_delay_and_request_rate() {
440 let rp = parsed("User-agent: *\nCrawl-delay: 10\nRequest-rate: 1/5");
441 assert_eq!(rp.crawl_delay("Bot"), Some(10));
442 assert_eq!(rp.request_rate("Bot"), Some(RequestRate { requests: 1, seconds: 5 }));
443 }
444
445 #[test]
446 fn sitemaps() {
447 let rp = parsed("Sitemap: http://example.com/sitemap.xml\nUser-agent: *\nDisallow:");
448 assert_eq!(rp.site_maps(), Some(&["http://example.com/sitemap.xml".to_string()][..]));
449 }
450}