Skip to main content

spire_core/crawler/
mod.rs

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