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}