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