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}