Skip to main content

spire_core/crawler/robots/
mod.rs

1//! robots.txt, fetched once per origin and obeyed per RFC 9309.
2
3mod cache;
4mod source;
5
6use std::fmt;
7use std::sync::Arc;
8use std::time::Duration;
9
10pub(crate) use cache::{RobotsCache, Verdict};
11pub(crate) use source::{BackendSource, RobotsSource};
12
13use crate::backend::Backend;
14use crate::crawler::Tag;
15use crate::crawler::connector::Connector;
16
17/// How a crawl fetches and obeys robots.txt, see
18/// [`Crawler::with_robots`](crate::crawler::Crawler::with_robots).
19///
20/// robots.txt is fetched once per origin (scheme, host and port) before its
21/// first request, and handled as RFC 9309 describes:
22///
23/// - Its rules for [`user agent`](Self::new) decide what is crawled;
24///   disallowed requests are skipped, and `Crawl-delay` slows the host down.
25/// - A `4xx` other than `429`, or too many redirects, allows everything.
26/// - A `5xx`, a `429` or a network failure disallows everything while
27///   robots.txt is retried with backoff. Once the
28///   [retries](Self::with_unreachable_retries) are spent, the origin's
29///   requests are skipped.
30/// - Rules older than the [maximum age](Self::with_max_age) are fetched
31///   again; until then, and if fetching fails, the old rules apply.
32///
33/// robots.txt is fetched with the crawl's backend unless
34/// [`with_backend`](Self::with_backend) sets another, e.g. an HTTP client
35/// for a browser crawl. [`with_sitemaps`](Self::with_sitemaps) also queues
36/// the sitemaps it lists.
37///
38/// ```
39/// use std::time::Duration;
40///
41/// use spire_core::backend::Noop;
42/// use spire_core::crawler::Robots;
43///
44/// let robots = Robots::new("MyBot")
45///     .with_backend(Noop)
46///     .with_max_age(Duration::from_secs(3600));
47/// ```
48#[derive(Clone)]
49pub struct Robots {
50    pub(crate) agent: Arc<str>,
51    pub(crate) source: Option<Arc<dyn RobotsSource>>,
52    pub(crate) max_age: Duration,
53    pub(crate) retries: u32,
54    pub(crate) retry_delay: Duration,
55    pub(crate) sitemaps: Option<Tag>,
56}
57
58impl Robots {
59    /// Obeys robots.txt for `user_agent`, fetching it with the crawl's
60    /// backend, keeping it for 24 hours and retrying an unreachable one 3
61    /// times.
62    pub fn new(user_agent: impl Into<Arc<str>>) -> Self {
63        Self {
64            agent: user_agent.into(),
65            source: None,
66            max_age: Duration::from_hours(24),
67            retries: 3,
68            retry_delay: Duration::from_secs(30),
69            sitemaps: None,
70        }
71    }
72
73    /// Fetches robots.txt with `backend` instead of the crawl's.
74    #[must_use]
75    pub fn with_backend<B: Backend>(mut self, backend: B) -> Self {
76        let connector = Connector::new(Arc::new(backend));
77        self.source = Some(Arc::new(BackendSource::new(Arc::new(connector))));
78        self
79    }
80
81    /// Fetches robots.txt again once its rules are older than `age`.
82    /// RFC 9309 asks for at most 24 hours.
83    #[must_use]
84    pub const fn with_max_age(mut self, age: Duration) -> Self {
85        self.max_age = age;
86        self
87    }
88
89    /// Retries an unreachable robots.txt `retries` times, 30 seconds apart
90    /// and doubling, before skipping the origin's requests.
91    #[must_use]
92    pub const fn with_unreachable_retries(mut self, retries: u32) -> Self {
93        self.retries = retries;
94        self
95    }
96
97    /// Queues the sitemaps an origin's robots.txt lists in `Sitemap:` lines,
98    /// tagged `tag`, the first time it is fetched.
99    ///
100    /// They go through the frontier like any other seed, so its scope,
101    /// deduplication and depth limits apply. Route the tag to a handler that
102    /// parses them, e.g. `FollowSitemap` in `spire`.
103    #[must_use]
104    pub fn with_sitemaps(mut self, tag: impl Into<Tag>) -> Self {
105        self.sitemaps = Some(tag.into());
106        self
107    }
108
109    /// The delay before the `attempt`-th retry, starting at 1.
110    pub(crate) fn retry_delay(&self, attempt: u32) -> Duration {
111        let exponent = attempt.saturating_sub(1).min(16);
112        self.retry_delay
113            .saturating_mul(1 << exponent)
114            .min(Duration::from_mins(10))
115    }
116}
117
118impl From<&str> for Robots {
119    fn from(user_agent: &str) -> Self {
120        Self::new(user_agent)
121    }
122}
123
124impl From<String> for Robots {
125    fn from(user_agent: String) -> Self {
126        Self::new(user_agent)
127    }
128}
129
130impl fmt::Debug for Robots {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.debug_struct("Robots")
133            .field("agent", &self.agent)
134            .field("own_backend", &self.source.is_some())
135            .field("max_age", &self.max_age)
136            .field("retries", &self.retries)
137            .field("retry_delay", &self.retry_delay)
138            .field("sitemaps", &self.sitemaps)
139            .finish()
140    }
141}