Skip to main content

spire_core/crawler/
mod.rs

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