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}