use std::time::Duration;
use codoseo_core::crawl::{Politeness, RobotsFile};
use url::Url;
use xxhash_rust::xxh3::xxh3_64;
use crate::fetch::{FetchError, Fetcher};
pub const ROBOTS_AGENT: &str = "CodoSEObot";
const MAX_ROBOTS_BYTES: usize = 500 * 1024;
pub const MAX_RULES: usize = 2_000;
pub const MAX_PATTERN_LEN: usize = 1_024;
struct Rule {
parts: Vec<String>,
anchored_end: bool,
len: usize,
allow: bool,
}
impl Rule {
fn new(pattern: &str, allow: bool) -> Option<Rule> {
if pattern.is_empty() || pattern.len() > MAX_PATTERN_LEN {
return None;
}
let pattern = if pattern.starts_with('/') || pattern.starts_with('*') {
pattern.to_owned()
} else {
format!("/{pattern}")
};
let (body, anchored_end) = match pattern.strip_suffix('$') {
Some(body) => (body, true),
None => (pattern.as_str(), false),
};
Some(Rule {
parts: body.split('*').map(str::to_owned).collect(),
anchored_end,
len: pattern.len(),
allow,
})
}
fn matches(&self, path: &str) -> bool {
let (first, rest) = self
.parts
.split_first()
.expect("split always yields one part");
let Some(mut remaining) = path.strip_prefix(first.as_str()) else {
return false;
};
let Some((last, middle)) = rest.split_last() else {
return !self.anchored_end || remaining.is_empty();
};
for part in middle {
match remaining.find(part.as_str()) {
Some(at) => remaining = &remaining[at + part.len()..],
None => return false,
}
}
if self.anchored_end {
remaining.len() >= last.len() && remaining.ends_with(last.as_str())
} else {
remaining.contains(last.as_str())
}
}
}
enum Kind {
AllowAll,
BlockAll,
Rules(Vec<Rule>),
}
pub struct RobotsRules {
kind: Kind,
delay: Option<Duration>,
sitemaps: Vec<String>,
}
#[derive(Default)]
struct Group {
agents: Vec<String>,
rules: Vec<(String, bool)>,
delay: Option<String>,
}
fn product_token(value: &str) -> String {
let value = value.trim();
if value.starts_with('*') {
return "*".to_owned();
}
value
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_ascii_lowercase()
}
impl RobotsRules {
pub fn parse(body: &[u8], agent: &str) -> RobotsRules {
let text = String::from_utf8_lossy(&body[..body.len().min(MAX_ROBOTS_BYTES)]);
let ours = product_token(agent);
let mut groups: Vec<Group> = Vec::new();
let mut sitemaps = Vec::new();
let mut reading_agents = false;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("");
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"user-agent" => {
if !reading_agents {
groups.push(Group::default());
}
reading_agents = true;
if let Some(group) = groups.last_mut() {
group.agents.push(product_token(value));
}
}
"sitemap" => sitemaps.push(value.to_owned()),
key => {
reading_agents = false;
let Some(group) = groups.last_mut() else {
continue;
};
match key {
"allow" | "disallow" if !value.is_empty() => {
group.rules.push((value.to_owned(), key == "allow"));
}
"crawl-delay" if group.delay.is_none() => {
group.delay = Some(value.to_owned())
}
_ => {}
}
}
}
}
let named: Vec<&Group> = groups.iter().filter(|g| g.agents.contains(&ours)).collect();
let chosen = if named.is_empty() {
groups
.iter()
.filter(|g| g.agents.iter().any(|a| a == "*"))
.collect()
} else {
named
};
let rules: Vec<Rule> = chosen
.iter()
.flat_map(|g| g.rules.iter())
.filter_map(|(pattern, allow)| Rule::new(pattern, *allow))
.take(MAX_RULES)
.collect();
let delay = chosen
.iter()
.find_map(|g| g.delay.as_deref())
.and_then(parse_delay);
RobotsRules {
kind: Kind::Rules(rules),
delay,
sitemaps,
}
}
pub fn from_status(status: u16) -> RobotsRules {
let kind = if (400..500).contains(&status) && status != 429 {
Kind::AllowAll
} else {
Kind::BlockAll
};
RobotsRules {
kind,
delay: None,
sitemaps: Vec::new(),
}
}
pub fn from_response(status: u16, body: &[u8], agent: &str) -> RobotsRules {
if (200..300).contains(&status) {
RobotsRules::parse(body, agent)
} else {
RobotsRules::from_status(status)
}
}
pub fn allowed(&self, url_or_path: &str) -> bool {
let rules = match &self.kind {
Kind::AllowAll => return true,
Kind::BlockAll => return false,
Kind::Rules(rules) => rules,
};
let owned;
let path = match Url::parse(url_or_path) {
Ok(url) => {
owned = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_owned(),
};
owned.as_str()
}
Err(_) => url_or_path,
};
if path == "/robots.txt" {
return true;
}
let best = rules
.iter()
.filter(|r| r.matches(path))
.max_by_key(|r| (r.len, r.allow));
best.is_none_or(|r| r.allow)
}
pub fn blocks_everything(&self) -> bool {
!self.allowed("/")
}
pub fn crawl_delay(&self) -> Option<Duration> {
self.delay
}
pub fn sitemaps(&self) -> &[String] {
&self.sitemaps
}
}
fn parse_delay(value: &str) -> Option<Duration> {
let max = Politeness::default().max_crawl_delay;
let secs: f64 = value.trim().parse().ok()?;
if !secs.is_finite() || secs <= 0.0 {
return None;
}
Some(Duration::from_secs_f64(secs.min(max.as_secs_f64())))
}
pub async fn fetch_robots(
fetcher: &Fetcher,
url: &Url,
) -> Result<(RobotsRules, RobotsFile), FetchError> {
let robots_url = url
.join("/robots.txt")
.map_err(|e| FetchError::Http(format!("bad robots.txt URL: {e}")))?;
let res = fetcher.fetch_raw(&robots_url, MAX_ROBOTS_BYTES).await?;
let ok = (200..300).contains(&res.status);
let body = if ok {
res.body.unwrap_or_default()
} else {
Default::default()
};
let rules = RobotsRules::from_response(res.status, &body, ROBOTS_AGENT);
let file = RobotsFile {
status: res.status,
body: String::from_utf8_lossy(&body).into_owned(),
hash: xxh3_64(&body),
};
Ok((rules, file))
}