Skip to main content

spire_core/frontier/
mod.rs

1//! The frontier: requests waiting to be crawled.
2//!
3//! A [`Frontier`] stores [`Seed`]s and decides which one is crawled next.
4//! [`InMemFrontier`] provides the storage; facades wrap any frontier to add
5//! behavior and are chained with [`FrontierExt`]:
6//!
7//! ```
8//! use spire_core::frontier::{FrontierExt, InMemFrontier};
9//!
10//! let frontier = InMemFrontier::fifo()
11//!     .dedup()
12//!     .max_depth(5)
13//!     .retry(3)
14//!     .tag_backoff();
15//! ```
16//!
17//! Pushes flow from the outermost facade inwards, so above, a seed that is
18//! too deep is refused before it is marked as seen.
19//!
20//! - [`Filter`] refuses seeds outside the crawl's scope, e.g. other hosts.
21//! - [`Dedup`] refuses seeds whose [`Fingerprint`](seed::Fingerprint) was seen before.
22//! - [`MaxDepth`] refuses seeds deeper than a limit.
23//! - [`Retry`] re-queues seeds after [`Report::Retry`], with a delay.
24//! - [`TagBackoff`] holds back a tag's seeds after [`Report::Backoff`].
25//! - [`Revisits`] crawls seeds again on a schedule, kept by storage that
26//!   implements [`Schedule`].
27//! - [`Scored`] adds a [`Score`] to every pushed seed's priority, for
28//!   best-first crawling.
29//!
30//! Without a [`Retry`] facade, retries are refused and the seeds fail;
31//! without a [`TagBackoff`] facade, backoffs only delay the retried request.
32
33pub(crate) mod dynamic;
34pub mod facade;
35mod memory;
36pub mod queue;
37pub mod score;
38pub mod seed;
39pub mod seen;
40#[cfg(test)]
41pub(crate) mod test_util;
42
43use std::future::{Future, ready};
44use std::time::Duration;
45
46use tokio::time::Instant;
47
48use self::facade::{
49    AllowedHosts, AvoidTraps, Dedup, Filter, MaxDepth, Retry, RetryPolicy, Revisits, Scored,
50    TagBackoff, TrapPolicy,
51};
52pub use self::memory::InMemFrontier;
53use self::score::Score;
54use self::seed::Seed;
55use self::seen::{InMemSeen, SeenSet};
56use crate::Result;
57use crate::crawler::{Revisit, Tag};
58
59/// Stores [`Seed`]s and decides which one is crawled next.
60pub trait Frontier: Send + Sync + 'static {
61    /// Offers a seed, which the frontier may refuse.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the underlying storage fails.
66    fn push(&self, seed: Seed) -> impl Future<Output = Result<Admission>> + Send;
67
68    /// Takes the next seed that is ready to be crawled.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the underlying storage fails.
73    fn pop(&self) -> impl Future<Output = Result<NextSeed>> + Send;
74
75    /// Tells the frontier how a popped seed ended.
76    ///
77    /// Facades react to the reports they handle and pass the rest inwards.
78    /// The default implementation ignores the report, refusing retries.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the underlying storage fails.
83    fn report(&self, report: Report) -> impl Future<Output = Result<Ack>> + Send {
84        let ack = match report {
85            Report::Retry { .. } => Ack::RetryRefused,
86            _ => Ack::Applied,
87        };
88        ready(Ok(ack))
89    }
90
91    /// Returns the number of queued seeds, if known.
92    fn size_hint(&self) -> Option<usize> {
93        None
94    }
95
96    /// Returns a copy of every seed held, in no particular order, to save
97    /// the crawl and resume it later; `None` if the frontier cannot, e.g.
98    /// because it persists seeds itself.
99    ///
100    /// Facades add the seeds they hold, such as seeds waiting for a retry.
101    fn snapshot(&self) -> Option<Vec<Seed>> {
102        None
103    }
104}
105
106/// Storage that can hold seeds back until a time, for
107/// [`FrontierExt::revisit`].
108///
109/// [`InMemFrontier`] keeps scheduled seeds in
110/// memory, and a redb frontier in its file, so they survive a restart. It is
111/// storage, not a facade: revisits sit directly on it, so every facade above
112/// sees them as it sees queued seeds.
113pub trait Schedule: Frontier {
114    /// Holds `seed` back until `due`, then hands it out from
115    /// [`pop`](Frontier::pop) like a queued seed. A seed with the same
116    /// [fingerprint](seed::Seed::fingerprint) scheduled before is replaced,
117    /// so a page has at most one revisit.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the underlying storage fails.
122    fn schedule(&self, seed: Seed, due: Instant) -> impl Future<Output = Result<()>> + Send;
123
124    /// Returns when the next scheduled seed is due, if any.
125    ///
126    /// [`pop`](Frontier::pop) does not wait for scheduled seeds by itself, so
127    /// a crawl over storage holding some ends once nothing else is queued;
128    /// [`Revisits`] waits for them.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the underlying storage fails.
133    fn next_due(&self) -> impl Future<Output = Result<Option<Instant>>> + Send;
134}
135
136impl<F: Frontier> Frontier for std::sync::Arc<F> {
137    fn push(&self, seed: Seed) -> impl Future<Output = Result<Admission>> + Send {
138        F::push(self, seed)
139    }
140
141    fn pop(&self) -> impl Future<Output = Result<NextSeed>> + Send {
142        F::pop(self)
143    }
144
145    fn report(&self, report: Report) -> impl Future<Output = Result<Ack>> + Send {
146        F::report(self, report)
147    }
148
149    fn size_hint(&self) -> Option<usize> {
150        F::size_hint(self)
151    }
152
153    fn snapshot(&self) -> Option<Vec<Seed>> {
154        F::snapshot(self)
155    }
156}
157
158impl<F: Schedule> Schedule for std::sync::Arc<F> {
159    fn schedule(&self, seed: Seed, due: Instant) -> impl Future<Output = Result<()>> + Send {
160        F::schedule(self, seed, due)
161    }
162
163    fn next_due(&self) -> impl Future<Output = Result<Option<Instant>>> + Send {
164        F::next_due(self)
165    }
166}
167
168/// Whether [`Frontier::push`] queued a seed.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170#[non_exhaustive]
171pub enum Admission {
172    /// The seed was queued.
173    Queued,
174    /// The seed was refused because an identical one was seen before.
175    Duplicate,
176    /// The seed was refused because it is deeper than allowed.
177    TooDeep,
178    /// The link was skipped because it is not an `http(s)` URL.
179    Unsupported,
180    /// The seed was refused because it is outside the crawl's scope.
181    OutOfScope,
182    /// The seed was refused because its URL prefix looks like a scraper trap.
183    SuspectedTrap,
184    /// The seed was refused because its URL prefix exhausted a configured budget.
185    PrefixBudgetExceeded,
186}
187
188impl Admission {
189    /// Whether the seed was queued.
190    #[must_use]
191    pub const fn is_queued(self) -> bool {
192        matches!(self, Self::Queued)
193    }
194}
195
196/// The result of [`Frontier::pop`].
197#[derive(Debug)]
198pub enum NextSeed {
199    /// A seed is ready to be crawled.
200    Ready(Seed),
201    /// No seed is ready now, but one will be at the given instant.
202    Wait(Instant),
203    /// No seed is queued.
204    Empty,
205}
206
207/// How a popped seed ended, see [`Frontier::report`].
208#[derive(Debug)]
209#[non_exhaustive]
210pub enum Report {
211    /// The seed was crawled.
212    Done(Seed),
213    /// The seed failed and will not be tried again.
214    Failed(Seed),
215    /// The seed was not crawled on purpose, e.g. robots.txt disallows it or
216    /// its tag was aborted.
217    Skipped(Seed),
218    /// The crawl stopped before the seed finished; a persistent frontier
219    /// should keep it for the next run.
220    Cancelled(Seed),
221    /// The seed should be tried again.
222    Retry {
223        /// The seed that failed.
224        seed: Seed,
225        /// Delay before the next attempt; `None` defers to the frontier.
226        after: Option<Duration>,
227    },
228    /// Seeds with `tag` should not be crawled before `until`.
229    Backoff {
230        /// The tag to pause; `None` pauses untagged seeds.
231        tag: Option<Tag>,
232        /// The earliest instant at which the tag may be crawled again.
233        until: Instant,
234    },
235}
236
237/// What a frontier did with a [`Report`].
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239#[non_exhaustive]
240pub enum Ack {
241    /// The report was applied.
242    Applied,
243    /// A [`Report::Retry`] was refused, because the seed is out of retries or
244    /// the frontier cannot retry; the seed has failed.
245    RetryRefused,
246}
247
248/// Wraps a [`Frontier`] in another, see [`FrontierExt::layer`].
249pub trait FrontierLayer<F> {
250    /// The wrapping frontier.
251    type Frontier: Frontier;
252
253    /// Wraps `inner`.
254    fn layer(self, inner: F) -> Self::Frontier;
255}
256
257/// Chains facades onto a [`Frontier`].
258pub trait FrontierExt: Frontier + Sized {
259    /// Refuses seeds seen before, remembering them in memory.
260    fn dedup(self) -> Dedup<Self> {
261        Dedup::new(self, InMemSeen::default())
262    }
263
264    /// Refuses seeds seen before, remembering them in `seen`.
265    fn dedup_with<S: SeenSet>(self, seen: S) -> Dedup<Self, S> {
266        Dedup::new(self, seen)
267    }
268
269    /// Refuses seeds `predicate` rejects.
270    fn filter<P>(self, predicate: P) -> Filter<Self, P>
271    where
272        P: Fn(&Seed) -> bool + Send + Sync + 'static,
273    {
274        Filter::new(self, predicate)
275    }
276
277    /// Refuses seeds outside `hosts` and their subdomains, e.g. to keep a
278    /// crawl from following links off-site.
279    fn allow_hosts<I>(
280        self,
281        hosts: I,
282    ) -> Filter<Self, impl Fn(&Seed) -> bool + Send + Sync + 'static>
283    where
284        I: IntoIterator<Item: AsRef<str>>,
285    {
286        let hosts = AllowedHosts::new(hosts);
287        Filter::new(self, move |seed: &Seed| hosts.allows(seed))
288    }
289
290    /// Refuses seeds deeper than `depth`.
291    fn max_depth(self, depth: u32) -> MaxDepth<Self> {
292        MaxDepth::new(self, depth)
293    }
294
295    /// Bounds expanding URL prefixes with conservative, configurable
296    /// heuristics. Opt in alongside robots.txt and deduplication; legitimate
297    /// large archives or opaque identifiers may need higher budgets.
298    ///
299    /// Put this outside retry/revisit facades to check queued seeds too.
300    fn avoid_traps(self) -> AvoidTraps<Self> {
301        self.avoid_traps_with(TrapPolicy::default())
302    }
303
304    /// Bounds expanding URL prefixes using `policy`, see [`Self::avoid_traps`].
305    fn avoid_traps_with(self, policy: TrapPolicy) -> AvoidTraps<Self> {
306        AvoidTraps::new(self, policy)
307    }
308
309    /// Retries failed seeds up to `max_retries` times with exponential
310    /// backoff.
311    fn retry(self, max_retries: u32) -> Retry<Self> {
312        Retry::new(self, RetryPolicy::new(max_retries))
313    }
314
315    /// Retries failed seeds according to `policy`.
316    fn retry_with(self, policy: RetryPolicy) -> Retry<Self> {
317        Retry::new(self, policy)
318    }
319
320    /// Holds back seeds of paused tags.
321    fn tag_backoff(self) -> TagBackoff<Self> {
322        TagBackoff::new(self)
323    }
324
325    /// Crawls pages again on a schedule: a seed reported done or failed is
326    /// crawled again after its tag's [`Revisit::interval`], so the crawl
327    /// keeps going, waiting for the next revisit once nothing else is
328    /// queued.
329    ///
330    /// It goes directly on the storage, which keeps the schedule, e.g.
331    /// `InMemFrontier::fifo().revisit::<Page>().dedup()`.
332    fn revisit<T: Revisit>(self) -> Revisits<Self, T>
333    where
334        Self: Schedule,
335    {
336        Revisits::new(self)
337    }
338
339    /// Adds `scorer`'s score to the priority of every seed pushed, so a
340    /// frontier that orders by priority crawls the best seeds first.
341    ///
342    /// The score adds to the seed's own [`priority`](Seed::with_priority),
343    /// e.g. one a handler gave a link, and stacked scorers add up. Seeds
344    /// are scored once, when pushed: retries and revisits keep their
345    /// priority. A FIFO or LIFO frontier ignores priorities.
346    ///
347    /// ```
348    /// use spire_core::frontier::score::{Keywords, ShallowFirst};
349    /// use spire_core::frontier::{FrontierExt, InMemFrontier};
350    ///
351    /// let frontier = InMemFrontier::priority()
352    ///     .dedup()
353    ///     .score(Keywords::new(["laptop", "review"]).with_weight(10))
354    ///     .score(ShallowFirst);
355    /// ```
356    fn score<S: Score>(self, scorer: S) -> Scored<Self, S> {
357        Scored::new(self, scorer)
358    }
359
360    /// Wraps the frontier with a custom facade.
361    fn layer<L: FrontierLayer<Self>>(self, layer: L) -> L::Frontier {
362        layer.layer(self)
363    }
364}
365
366impl<F: Frontier> FrontierExt for F {}