use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use ureq::ResponseExt;
use crate::detect::check::Evidence;
use crate::detect::robots::RobotsDocument;
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
pub(crate) enum FetchError {
Malformed(String),
Blocked(String),
}
pub(crate) struct RobotsCache {
origins: Mutex<HashMap<String, Arc<RobotsDocument>>>,
}
impl RobotsCache {
pub(crate) fn new() -> Self {
Self {
origins: Mutex::new(HashMap::new()),
}
}
fn document(&self, agent: &ureq::Agent, url: &str) -> Option<Arc<RobotsDocument>> {
let origin = origin_of(url)?;
if let Some(hit) = self
.origins
.lock()
.ok()
.and_then(|origins| origins.get(&origin).cloned())
{
return Some(hit);
}
let document = Arc::new(RobotsDocument::parse(&fetch_robots(agent, &origin)?));
if let Ok(mut origins) = self.origins.lock() {
origins.insert(origin, Arc::clone(&document));
}
Some(document)
}
}
fn origin_of(url: &str) -> Option<String> {
let origin = url::Url::parse(url).ok()?.origin();
origin.is_tuple().then(|| origin.ascii_serialization())
}
pub(crate) fn fetch_evidence(url: &str, robots: &RobotsCache) -> Result<Evidence, FetchError> {
let started = Instant::now();
let agent = agent();
let fetch_start = Instant::now();
let mut response = match agent.get(url).call() {
Ok(response) => response,
Err(error) => return Err(classify(&error)),
};
let fetch_ms = ms(fetch_start.elapsed());
let status = response.status().as_u16();
let final_url = response.get_uri().to_string();
let headers = header_map(&response);
let body_html = response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
.unwrap_or_default();
let robots_document = robots.document(&agent, url);
Ok(Evidence {
url: url.to_string(),
final_url,
status: Some(status),
headers,
body_html,
robots: robots_document,
render: None,
fetch_ms,
total_ms: ms(started.elapsed()),
})
}
pub(crate) fn fetch_robots_only(url: &str, robots: &RobotsCache) -> Option<Arc<RobotsDocument>> {
robots.document(&agent(), url)
}
fn fetch_robots(agent: &ureq::Agent, origin: &str) -> Option<String> {
let mut response = agent.get(&format!("{origin}/robots.txt")).call().ok()?;
if !response.status().is_success() {
return None;
}
response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
.ok()
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(20)))
.user_agent(concat!("scrape-le/", env!("CARGO_PKG_VERSION")))
.build()
.new_agent()
}
fn header_map(response: &ureq::http::Response<ureq::Body>) -> HashMap<String, String> {
response
.headers()
.iter()
.filter_map(|(name, value)| {
let value = value.to_str().ok()?;
Some((name.as_str().to_lowercase(), value.to_string()))
})
.collect()
}
fn classify(error: &ureq::Error) -> FetchError {
match error {
ureq::Error::Timeout(_) => FetchError::Malformed(format!("timed out: {error}")),
ureq::Error::HostNotFound => FetchError::Malformed(format!("DNS failure: {error}")),
ureq::Error::BadUri(_) => FetchError::Malformed(format!("unparseable URL: {error}")),
ureq::Error::Io(io)
if io.to_string().contains("lookup address")
|| io.to_string().contains("No such host") =>
{
FetchError::Malformed(format!("DNS failure: {io}"))
}
other => FetchError::Blocked(format!("could not fetch the page: {other}")),
}
}
fn ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}