use std::sync::Arc;
use dashmap::DashMap;
use crate::analyzers::RobotsRule;
use crate::http::HttpClient;
use crate::CrawlConfig;
pub struct RobotsTxtCache {
cache: DashMap<String, CachedRobotsTxt>,
client: Arc<HttpClient>,
user_agent: String,
}
struct CachedRobotsTxt {
rules: Vec<RobotsRule>,
sitemap_urls: Vec<String>,
raw_content: String,
}
impl RobotsTxtCache {
pub fn new(client: Arc<HttpClient>, config: &CrawlConfig) -> Self {
Self {
cache: DashMap::new(),
client,
user_agent: config.user_agent.clone(),
}
}
pub async fn get(&self, scheme: &str, domain: &str) -> (Vec<RobotsRule>, Vec<String>, String) {
if let Some(entry) = self.cache.get(domain) {
return (
entry.rules.clone(),
entry.sitemap_urls.clone(),
entry.raw_content.clone(),
);
}
let robots_url = format!("{scheme}://{domain}/robots.txt");
let url = match url::Url::parse(&robots_url) {
Ok(u) => u,
Err(_) => {
self.insert_empty(domain);
return (Vec::new(), Vec::new(), String::new());
}
};
let content = match self.client.fetch(&url).await {
Ok(result) => {
if result.status_code == 200 {
result.body
} else {
self.insert_empty(domain);
return (Vec::new(), Vec::new(), String::new());
}
}
Err(_) => {
self.insert_empty(domain);
return (Vec::new(), Vec::new(), String::new());
}
};
let (rules, sitemap_urls) = parse_robots_txt(&content, &self.user_agent);
let entry = CachedRobotsTxt {
rules: rules.clone(),
sitemap_urls: sitemap_urls.clone(),
raw_content: content.clone(),
};
self.cache.insert(domain.to_string(), entry);
(rules, sitemap_urls, content)
}
pub async fn is_disallowed(&self, scheme: &str, domain: &str, path: &str) -> bool {
let (rules, _, _) = self.get(scheme, domain).await;
is_path_disallowed(path, &rules, &self.user_agent)
}
pub async fn crawl_delay(&self, scheme: &str, domain: &str) -> Option<f64> {
let (rules, _, _) = self.get(scheme, domain).await;
get_crawl_delay(&rules, &self.user_agent)
}
pub async fn sitemaps(&self, scheme: &str, domain: &str) -> Vec<String> {
let (_, sitemap_urls, _) = self.get(scheme, domain).await;
sitemap_urls
}
pub async fn raw_content(&self, scheme: &str, domain: &str) -> String {
let (_, _, raw) = self.get(scheme, domain).await;
raw
}
fn insert_empty(&self, domain: &str) {
self.cache.insert(
domain.to_string(),
CachedRobotsTxt {
rules: Vec::new(),
sitemap_urls: Vec::new(),
raw_content: String::new(),
},
);
}
}
fn parse_robots_txt(content: &str, target_agent: &str) -> (Vec<RobotsRule>, Vec<String>) {
let mut rules = Vec::new();
let mut sitemap_urls = Vec::new();
let mut current_agent = String::new();
let mut current_disallowed = Vec::new();
let mut current_allowed = Vec::new();
let mut current_delay: Option<f64> = None;
let mut applies_to_all = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = match line.split_once(':') {
Some((k, v)) => (k.trim().to_lowercase(), v.trim().to_string()),
None => continue,
};
match key.as_str() {
"user-agent" => {
if (!current_agent.is_empty() && user_agent_matches(¤t_agent, target_agent))
|| applies_to_all
{
rules.push(RobotsRule {
user_agent: current_agent.clone(),
disallowed_paths: current_disallowed.clone(),
allowed_paths: current_allowed.clone(),
crawl_delay: current_delay,
sitemaps: Vec::new(), });
}
current_agent = value;
current_disallowed.clear();
current_allowed.clear();
current_delay = None;
applies_to_all = current_agent == "*";
}
"disallow" => {
if !value.is_empty() {
current_disallowed.push(value);
}
}
"allow" => {
if !value.is_empty() {
current_allowed.push(value);
}
}
"crawl-delay" => {
if let Ok(delay) = value.parse::<f64>() {
current_delay = Some(delay);
}
}
"sitemap" => {
if !value.is_empty() {
sitemap_urls.push(value);
}
}
_ => {} }
}
if (!current_agent.is_empty() && user_agent_matches(¤t_agent, target_agent))
|| applies_to_all
{
rules.push(RobotsRule {
user_agent: current_agent,
disallowed_paths: current_disallowed,
allowed_paths: current_allowed,
crawl_delay: current_delay,
sitemaps: Vec::new(),
});
}
(rules, sitemap_urls)
}
fn user_agent_matches(rule_agent: &str, target: &str) -> bool {
if rule_agent == "*" {
return true;
}
let rule_lower = rule_agent.to_lowercase();
let target_lower = target.to_lowercase();
target_lower.contains(&rule_lower) || rule_lower.contains(&target_lower)
}
fn is_path_disallowed(path: &str, rules: &[RobotsRule], user_agent: &str) -> bool {
let mut disallowed = Vec::new();
let mut allowed = Vec::new();
for rule in rules {
if user_agent_matches(&rule.user_agent, user_agent) || rule.user_agent == "*" {
disallowed.extend(rule.disallowed_paths.iter());
allowed.extend(rule.allowed_paths.iter());
}
}
for pattern in &allowed {
if path.starts_with(pattern.as_str()) {
return false;
}
}
for pattern in &disallowed {
if path.starts_with(pattern.as_str()) {
return true;
}
}
false
}
fn get_crawl_delay(rules: &[RobotsRule], user_agent: &str) -> Option<f64> {
for rule in rules {
if user_agent_matches(&rule.user_agent, user_agent) || rule.user_agent == "*" {
if let Some(delay) = rule.crawl_delay {
return Some(delay);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_robots_txt_basic() {
let content = r#"
User-agent: *
Disallow: /admin/
Disallow: /private/
Allow: /admin/public/
Crawl-delay: 2
Sitemap: https://example.com/sitemap.xml
"#;
let (rules, sitemaps) = parse_robots_txt(content, "MyBot/1.0");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].disallowed_paths, vec!["/admin/", "/private/"]);
assert_eq!(rules[0].allowed_paths, vec!["/admin/public/"]);
assert_eq!(rules[0].crawl_delay, Some(2.0));
assert_eq!(sitemaps, vec!["https://example.com/sitemap.xml"]);
}
#[test]
fn test_parse_robots_txt_specific_agent() {
let content = r#"
User-agent: Googlebot
Disallow: /no-google/
User-agent: *
Disallow: /no-all/
"#;
let (rules, _) = parse_robots_txt(content, "Googlebot/2.1");
assert!(rules
.iter()
.any(|r| r.disallowed_paths.contains(&"/no-google/".to_string())));
assert!(rules
.iter()
.any(|r| r.disallowed_paths.contains(&"/no-all/".to_string())));
}
#[test]
fn test_is_path_disallowed() {
let rules = vec![RobotsRule {
user_agent: "*".to_string(),
disallowed_paths: vec!["/admin/".to_string(), "/private/".to_string()],
allowed_paths: vec!["/admin/public/".to_string()],
crawl_delay: None,
sitemaps: Vec::new(),
}];
assert!(is_path_disallowed("/admin/settings", &rules, "AnyBot"));
assert!(!is_path_disallowed("/admin/public/page", &rules, "AnyBot"));
assert!(!is_path_disallowed("/public/page", &rules, "AnyBot"));
}
#[test]
fn test_user_agent_matches() {
assert!(user_agent_matches("*", "AnyBot"));
assert!(user_agent_matches("Googlebot", "Googlebot/2.1"));
assert!(user_agent_matches(
"Googlebot",
"Mozilla/5.0 (compatible; Googlebot/2.1)"
));
assert!(!user_agent_matches("Bingbot", "Googlebot/2.1"));
}
#[test]
fn test_get_crawl_delay() {
let rules = vec![RobotsRule {
user_agent: "*".to_string(),
disallowed_paths: Vec::new(),
allowed_paths: Vec::new(),
crawl_delay: Some(5.0),
sitemaps: Vec::new(),
}];
assert_eq!(get_crawl_delay(&rules, "AnyBot"), Some(5.0));
}
}