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