Skip to main content

spire_core/crawler/
mod.rs

1//! The [`Crawler`], which drives a crawl to completion.
2
3mod hosts;
4#[cfg(feature = "robots")]
5mod robots;
6mod run;
7mod stats;
8#[cfg(test)]
9mod tests;
10
11use std::fmt;
12use std::num::NonZeroUsize;
13use std::sync::{Arc, Mutex};
14use std::time::Duration;
15
16#[cfg(feature = "robots")]
17#[cfg_attr(docsrs, doc(cfg(feature = "robots")))]
18pub use robots::Robots;
19pub use stats::Stats;
20use tokio::time::Instant;
21use tokio_util::sync::CancellationToken;
22
23use crate::Result;
24use crate::crawler::hosts::HostPolicy;
25use crate::dataset::{Data, Dataset, Datasets};
26use crate::frontier::dynamic::DynFrontier;
27use crate::frontier::facade::RetryPolicy;
28use crate::frontier::memory::InMemFrontier;
29use crate::frontier::queue::Queue;
30use crate::frontier::seed::Seed;
31use crate::frontier::{Frontier, FrontierExt};
32use crate::sync::lock;
33
34/// The default number of requests handled at once.
35const DEFAULT_CONCURRENCY: NonZeroUsize = NonZeroUsize::new(8).unwrap();
36
37/// Crawls requests from a [`Frontier`] with a [`Backend`] and a [`Worker`].
38///
39/// [`run`](Self::run) pops seeds from the frontier and handles up to
40/// [`with_concurrency`](Self::with_concurrency) of them at once. Each one
41/// gets its own client from the backend and is handed to the worker, whose
42/// [`Outcome`] is reported back to the frontier.
43///
44/// The run ends once the frontier is empty and no request is in flight, when
45/// a worker returns [`Outcome::Stop`], the request or time budget is spent,
46/// or the [`shutdown_token`](Self::shutdown_token) is cancelled. Stopping
47/// lets requests in flight finish, for at most the
48/// [shutdown grace period](Self::with_shutdown_grace) if one is set.
49///
50/// Politeness is per host: [`with_host_concurrency`](Self::with_host_concurrency)
51/// and [`with_host_delay`](Self::with_host_delay) limit how hard a single
52/// host is crawled, and with the `robots` feature, `with_robots` obeys
53/// robots.txt.
54///
55/// # Examples
56///
57/// ```
58/// use std::time::Duration;
59///
60/// use spire_core::backend::Noop;
61/// use spire_core::frontier::FrontierExt;
62/// use spire_core::frontier::memory::InMemFrontier;
63/// use spire_core::frontier::seed::Seed;
64/// use spire_core::worker::from_fn;
65/// use spire_core::{Context, Crawler, Result};
66///
67/// async fn handle(cx: Context<Noop>) -> Result<()> {
68///     cx.queue().follow("https://example.com/next").await?;
69///     Ok(())
70/// }
71///
72/// # async fn example() -> Result<()> {
73/// let crawler = Crawler::new(Noop, from_fn(handle))
74///     .with_frontier(InMemFrontier::fifo().dedup().allow_hosts(["example.com"]))
75///     .with_max_requests(100)
76///     .with_host_concurrency(2)
77///     .with_request_timeout(Duration::from_secs(30))
78///     .with_seed(Seed::get("https://example.com/")?);
79///
80/// let stats = crawler.run().await?;
81/// assert_eq!(stats.requests, 2);
82/// # Ok(())
83/// # }
84/// ```
85///
86/// [`Backend`]: crate::backend::Backend
87/// [`Worker`]: crate::worker::Worker
88/// [`Outcome`]: crate::worker::Outcome
89/// [`Outcome::Stop`]: crate::worker::Outcome::Stop
90pub struct Crawler<B, W> {
91    backend: Arc<B>,
92    worker: Arc<W>,
93    frontier: Arc<dyn DynFrontier>,
94    datasets: Arc<Datasets>,
95    seeds: Mutex<Vec<Seed>>,
96    settings: Settings,
97    shutdown: CancellationToken,
98    live: Arc<Mutex<Live>>,
99}
100
101/// How a run behaves.
102#[derive(Debug, Clone)]
103struct Settings {
104    concurrency: NonZeroUsize,
105    max_requests: Option<usize>,
106    time_limit: Option<Duration>,
107    request_timeout: Option<Duration>,
108    shutdown_grace: Option<Duration>,
109    hosts: HostPolicy,
110    #[cfg(feature = "robots")]
111    robots: Option<Robots>,
112}
113
114/// The stats of the current or last run.
115#[derive(Debug, Default)]
116struct Live {
117    stats: Stats,
118    started: Option<Instant>,
119    running: bool,
120}
121
122impl<B, W> Crawler<B, W> {
123    /// Creates a crawler with an [`InMemFrontier::fifo`] frontier that
124    /// retries with the default [`RetryPolicy`], no datasets, a concurrency
125    /// of 8 and no limits.
126    pub fn new(backend: B, worker: W) -> Self {
127        let frontier = InMemFrontier::fifo().retry_with(RetryPolicy::default());
128        let settings = Settings {
129            concurrency: DEFAULT_CONCURRENCY,
130            max_requests: None,
131            time_limit: None,
132            request_timeout: None,
133            shutdown_grace: None,
134            hosts: HostPolicy::default(),
135            #[cfg(feature = "robots")]
136            robots: None,
137        };
138
139        Self {
140            backend: Arc::new(backend),
141            worker: Arc::new(worker),
142            frontier: Arc::new(frontier),
143            datasets: Arc::default(),
144            seeds: Mutex::default(),
145            settings,
146            shutdown: CancellationToken::new(),
147            live: Arc::default(),
148        }
149    }
150
151    /// Replaces the frontier.
152    ///
153    /// Seeds added with [`with_seed`](Self::with_seed) are pushed when the
154    /// run starts, so they go to the new frontier.
155    #[must_use]
156    pub fn with_frontier(mut self, frontier: impl Frontier) -> Self {
157        self.frontier = Arc::new(frontier);
158        self
159    }
160
161    /// Registers `dataset` for items of type `T`, replacing any previous one.
162    #[must_use]
163    pub fn with_dataset<T, D>(mut self, dataset: D) -> Self
164    where
165        T: Send + 'static,
166        D: Dataset<T>,
167    {
168        Arc::make_mut(&mut self.datasets).insert(dataset);
169        self
170    }
171
172    /// Sets how many requests are handled at once, at least one.
173    #[must_use]
174    pub fn with_concurrency(mut self, limit: usize) -> Self {
175        self.settings.concurrency = NonZeroUsize::new(limit).unwrap_or(NonZeroUsize::MIN);
176        self
177    }
178
179    /// Stops the run after starting `max` requests, counting every attempt.
180    #[must_use]
181    pub const fn with_max_requests(mut self, max: usize) -> Self {
182        self.settings.max_requests = Some(max);
183        self
184    }
185
186    /// Stops the run once it has lasted `limit`.
187    #[must_use]
188    pub const fn with_time_limit(mut self, limit: Duration) -> Self {
189        self.settings.time_limit = Some(limit);
190        self
191    }
192
193    /// Fails a request that takes longer than `timeout` to handle, fetching
194    /// included. Timed out requests are retried.
195    #[must_use]
196    pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
197        self.settings.request_timeout = Some(timeout);
198        self
199    }
200
201    /// Cancels requests still in flight `grace` after the run starts
202    /// stopping, instead of waiting for them indefinitely.
203    ///
204    /// Cancelled requests are reported to the frontier as
205    /// [`Report::Cancelled`](crate::frontier::Report::Cancelled).
206    #[must_use]
207    pub const fn with_shutdown_grace(mut self, grace: Duration) -> Self {
208        self.settings.shutdown_grace = Some(grace);
209        self
210    }
211
212    /// Sets how many requests to the same host are in flight at once, at
213    /// least one. Unlimited by default.
214    #[must_use]
215    pub fn with_host_concurrency(mut self, limit: usize) -> Self {
216        let limit = NonZeroUsize::new(limit).unwrap_or(NonZeroUsize::MIN);
217        self.settings.hosts.max_concurrency = Some(limit);
218        self
219    }
220
221    /// Waits at least `delay` between starting two requests to the same host.
222    #[must_use]
223    pub const fn with_host_delay(mut self, delay: Duration) -> Self {
224        self.settings.hosts.delay = delay;
225        self
226    }
227
228    /// Obeys robots.txt: fetches each origin's file before crawling it,
229    /// skips what it disallows, and honours its `Crawl-delay`.
230    ///
231    /// Takes a user agent, or a [`Robots`] to fetch with another backend or
232    /// change how long rules are kept; see [`Robots`] for how failures are
233    /// handled. Disallowed requests are reported as
234    /// [`Report::Skipped`](crate::frontier::Report::Skipped).
235    ///
236    /// ```
237    /// use spire_core::backend::Noop;
238    /// use spire_core::crawler::Robots;
239    /// use spire_core::worker::from_fn;
240    /// use spire_core::{Context, Crawler};
241    ///
242    /// let worker = from_fn(|_: Context<Noop>| async {});
243    /// let crawler = Crawler::new(Noop, worker).with_robots("MyBot");
244    ///
245    /// // robots.txt through another backend, e.g. HTTP for a browser crawl.
246    /// let worker = from_fn(|_: Context<Noop>| async {});
247    /// let crawler = Crawler::new(Noop, worker).with_robots(Robots::new("MyBot").with_backend(Noop));
248    /// ```
249    #[cfg(feature = "robots")]
250    #[cfg_attr(docsrs, doc(cfg(feature = "robots")))]
251    #[must_use]
252    pub fn with_robots(mut self, robots: impl Into<Robots>) -> Self {
253        self.settings.robots = Some(robots.into());
254        self
255    }
256
257    /// Adds a seed to push when the run starts.
258    #[must_use]
259    pub fn with_seed(self, seed: Seed) -> Self {
260        lock(&self.seeds).push(seed);
261        self
262    }
263
264    /// Adds seeds to push when the run starts.
265    #[must_use]
266    pub fn with_seeds(self, seeds: impl IntoIterator<Item = Seed>) -> Self {
267        lock(&self.seeds).extend(seeds);
268        self
269    }
270
271    /// Returns the backend, e.g. to shut it down after a run.
272    #[must_use]
273    pub fn backend(&self) -> &B {
274        &self.backend
275    }
276
277    /// Returns a queue for adding seeds to the frontier.
278    #[must_use]
279    pub fn queue(&self) -> Queue {
280        Queue::from_dyn(self.frontier.clone())
281    }
282
283    /// Returns the dataset for items of type `T`.
284    ///
285    /// # Errors
286    ///
287    /// Returns a [`Dataset`](crate::ErrorKind::Dataset) error if no dataset
288    /// is registered for `T`.
289    pub fn dataset<T: Send + 'static>(&self) -> Result<Data<T>> {
290        self.datasets.get()
291    }
292
293    /// Returns a copy of every seed the frontier holds, see
294    /// [`Frontier::snapshot`]. After a stopped run it includes the requests
295    /// cancelled or never started.
296    #[must_use]
297    pub fn snapshot(&self) -> Option<Vec<Seed>> {
298        self.frontier.snapshot()
299    }
300
301    /// Returns the stats of the current run while it runs, or of the last
302    /// one.
303    #[must_use]
304    pub fn stats(&self) -> Stats {
305        let live = lock(&self.live);
306        let mut stats = live.stats;
307        if live.running
308            && let Some(started) = live.started
309        {
310            stats.elapsed = started.elapsed();
311        }
312        stats
313    }
314
315    /// Returns a token that stops the run when cancelled.
316    ///
317    /// Requests in flight finish; nothing new starts.
318    #[must_use]
319    pub fn shutdown_token(&self) -> CancellationToken {
320        self.shutdown.clone()
321    }
322
323    /// Updates the live stats.
324    fn tally(&self, update: impl FnOnce(&mut Stats)) {
325        update(&mut lock(&self.live).stats);
326    }
327}
328
329impl<B, W> fmt::Debug for Crawler<B, W> {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        f.debug_struct("Crawler")
332            .field("settings", &self.settings)
333            .field("queued", &self.frontier.size_hint())
334            .field("datasets", &self.datasets)
335            .finish_non_exhaustive()
336    }
337}