Skip to main content

millipede_core/
link_extraction.rs

1//! Scraper-independent link extraction types, URL matching, and crawl policy.
2//!
3//! This module contains the pure-logic half of link extraction. DOM-specific
4//! implementations can provide [`LinkExtractor`](crate::link_extraction::LinkExtractor), while enqueueing code can apply
5//! the strategies, patterns, overrides, and policy defined here.
6//!
7//! `RobotsPolicy` from `INTERFACE.md` section 7.1 is deliberately deferred to a
8//! later phase: Phase 5 locks no robots dependency, and the Phase 5 roadmap scope
9//! does not include robots handling. [`CrawlPolicy`](crate::link_extraction::CrawlPolicy) is `#[non_exhaustive]` so a
10//! robots policy can be added without breaking downstream construction patterns.
11
12use crate::{
13    enqueue::SkipReason,
14    errors::CrawlError,
15    request::{HeaderMap, Method, UserData},
16};
17use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
18use regex::Regex;
19use std::{fmt, sync::Arc};
20use url::{Host, Url};
21
22/// Controls how closely a discovered URL must relate to its parent URL.
23///
24/// The strategy applies to extractor and raw-link candidates. Explicit [`url::Url`] values passed
25/// through `EnqueueLinksOptions::urls` are caller-selected inputs and bypass relationship
26/// filtering.
27///
28/// # Examples
29///
30/// ```
31/// use millipede_core::link_extraction::EnqueueStrategy;
32///
33/// assert_eq!(EnqueueStrategy::default(), EnqueueStrategy::SameHostname);
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum EnqueueStrategy {
37    /// Allows every HTTP or HTTPS URL.
38    All,
39    /// Allows URLs whose hostname exactly matches the parent hostname.
40    #[default]
41    SameHostname,
42    /// Allows URLs with the same registrable domain as the parent.
43    SameDomain,
44    /// Allows URLs with the same scheme, hostname, and effective port as the parent.
45    SameOrigin,
46}
47
48/// Returns whether `candidate` is allowed by `strategy` relative to `parent`.
49///
50/// Candidate URLs using schemes other than HTTP and HTTPS are always rejected,
51/// including under [`EnqueueStrategy::All`].
52///
53/// # Examples
54///
55/// ```
56/// use millipede_core::link_extraction::{strategy_allows, EnqueueStrategy};
57/// use url::Url;
58///
59/// let parent = Url::parse("http://example.com/catalog").unwrap();
60/// let secure = Url::parse("https://example.com/product").unwrap();
61/// assert!(strategy_allows(
62///     EnqueueStrategy::SameHostname,
63///     &parent,
64///     &secure,
65/// ));
66/// assert!(!strategy_allows(
67///     EnqueueStrategy::SameOrigin,
68///     &parent,
69///     &secure,
70/// ));
71/// ```
72pub fn strategy_allows(strategy: EnqueueStrategy, parent: &Url, candidate: &Url) -> bool {
73    if !matches!(candidate.scheme(), "http" | "https") {
74        return false;
75    }
76
77    match strategy {
78        EnqueueStrategy::All => true,
79        EnqueueStrategy::SameHostname => hosts_equal(parent.host_str(), candidate.host_str()),
80        EnqueueStrategy::SameDomain => same_domain(parent, candidate),
81        EnqueueStrategy::SameOrigin => {
82            parent.scheme() == candidate.scheme()
83                && hosts_equal(parent.host_str(), candidate.host_str())
84                && parent.port_or_known_default() == candidate.port_or_known_default()
85        }
86    }
87}
88
89fn hosts_equal(left: Option<&str>, right: Option<&str>) -> bool {
90    match (left, right) {
91        (Some(left), Some(right)) => left.eq_ignore_ascii_case(right),
92        _ => false,
93    }
94}
95
96fn same_domain(parent: &Url, candidate: &Url) -> bool {
97    let (Some(parent_host), Some(candidate_host)) = (parent.host(), candidate.host()) else {
98        return false;
99    };
100
101    match (parent_host, candidate_host) {
102        (Host::Domain(parent_host), Host::Domain(candidate_host)) => {
103            match (
104                psl::domain_str(parent_host),
105                psl::domain_str(candidate_host),
106            ) {
107                (Some(parent_domain), Some(candidate_domain)) => {
108                    parent_domain.eq_ignore_ascii_case(candidate_domain)
109                }
110                _ => parent_host.eq_ignore_ascii_case(candidate_host),
111            }
112        }
113        (parent_host, candidate_host) => parent_host == candidate_host,
114    }
115}
116
117/// A URL include or exclude pattern.
118///
119/// String conversions create [`UrlPattern::Glob`] values, while compiled regular
120/// expressions can be converted without recompilation.
121///
122/// # Examples
123///
124/// ```
125/// use millipede_core::link_extraction::UrlPattern;
126///
127/// let pattern = UrlPattern::from("**/products/*");
128/// assert!(matches!(pattern, UrlPattern::Glob(_)));
129/// ```
130#[derive(Debug, Clone)]
131#[non_exhaustive]
132pub enum UrlPattern {
133    /// A minimatch-style glob matched against the complete URL string.
134    Glob(String),
135    /// A regular expression matched against the complete URL string.
136    Regex(Regex),
137}
138
139impl From<&str> for UrlPattern {
140    fn from(pattern: &str) -> Self {
141        Self::Glob(pattern.to_owned())
142    }
143}
144
145impl From<String> for UrlPattern {
146    fn from(pattern: String) -> Self {
147        Self::Glob(pattern)
148    }
149}
150
151impl From<Regex> for UrlPattern {
152    fn from(pattern: Regex) -> Self {
153        Self::Regex(pattern)
154    }
155}
156
157/// Compiles URL globs with separators treated as ordinary characters.
158#[allow(dead_code)] // Consumed by the later Phase 5 enqueue-pipeline commit.
159pub(crate) fn compile_globs(patterns: &[String]) -> Result<GlobSet, LinkPatternError> {
160    let mut builder = GlobSetBuilder::new();
161    for pattern in patterns {
162        let glob = GlobBuilder::new(pattern)
163            .literal_separator(false)
164            .build()
165            .map_err(|source| LinkPatternError::InvalidGlob {
166                pattern: pattern.clone(),
167                source,
168            })?;
169        builder.add(glob);
170    }
171
172    builder
173        .build()
174        .map_err(|source| LinkPatternError::InvalidGlob {
175            pattern: patterns.last().cloned().unwrap_or_default(),
176            source,
177        })
178}
179
180/// An error produced while compiling link patterns.
181#[derive(Debug, thiserror::Error)]
182#[non_exhaustive]
183pub enum LinkPatternError {
184    /// A glob cannot be parsed or compiled.
185    #[error("invalid glob pattern {pattern:?}: {source}")]
186    InvalidGlob {
187        /// The invalid source pattern.
188        pattern: String,
189        /// The glob parser or compiler error.
190        #[source]
191        source: globset::Error,
192    },
193}
194
195/// A URL pattern with request fields to apply when it matches.
196///
197/// # Examples
198///
199/// ```
200/// use millipede_core::{
201///     link_extraction::UrlMatch,
202///     request::{HeaderMap, Method, UserData},
203/// };
204///
205/// let matched = UrlMatch::new("**/products/*")
206///     .label("product")
207///     .user_data(UserData::default())
208///     .method(Method::POST)
209///     .headers(HeaderMap::new());
210/// assert_eq!(matched.label.as_deref(), Some("product"));
211/// ```
212#[derive(Debug, Clone)]
213pub struct UrlMatch {
214    /// The URL pattern to match.
215    pub pattern: UrlPattern,
216    /// An optional route label applied to matching requests.
217    pub label: Option<String>,
218    /// Optional user data applied to matching requests.
219    pub user_data: Option<UserData>,
220    /// An optional HTTP method applied to matching requests.
221    pub method: Option<Method>,
222    /// Optional HTTP headers applied to matching requests.
223    pub headers: Option<HeaderMap>,
224}
225
226impl UrlMatch {
227    /// Creates a pattern with no request-field overrides.
228    pub fn new(pattern: impl Into<UrlPattern>) -> Self {
229        Self {
230            pattern: pattern.into(),
231            label: None,
232            user_data: None,
233            method: None,
234            headers: None,
235        }
236    }
237
238    /// Sets the route label for matching requests.
239    pub fn label(mut self, label: impl Into<String>) -> Self {
240        self.label = Some(label.into());
241        self
242    }
243
244    /// Sets the user data for matching requests.
245    pub fn user_data(mut self, user_data: UserData) -> Self {
246        self.user_data = Some(user_data);
247        self
248    }
249
250    /// Sets the HTTP method for matching requests.
251    pub fn method(mut self, method: Method) -> Self {
252        self.method = Some(method);
253        self
254    }
255
256    /// Sets the HTTP headers for matching requests.
257    pub fn headers(mut self, headers: HeaderMap) -> Self {
258        self.headers = Some(headers);
259        self
260    }
261}
262
263/// An include glob or regular expression with optional per-pattern overrides.
264///
265/// Use [`UrlMatch`] when a matching link should override request fields.
266///
267/// # Examples
268///
269/// ```
270/// use millipede_core::link_extraction::{GlobPattern, UrlMatch};
271/// use regex::Regex;
272///
273/// let plain = GlobPattern::from("https://example.com/**");
274/// let regex = GlobPattern::from(Regex::new(r"/items/\\d+$").unwrap());
275/// let labeled = GlobPattern::from(UrlMatch::new("**/items/*").label("item"));
276/// # let _ = (plain, regex, labeled);
277/// ```
278#[derive(Debug, Clone)]
279pub struct GlobPattern {
280    matched: UrlMatch,
281}
282
283impl From<&str> for GlobPattern {
284    fn from(pattern: &str) -> Self {
285        Self {
286            matched: UrlMatch::new(pattern),
287        }
288    }
289}
290
291impl From<String> for GlobPattern {
292    fn from(pattern: String) -> Self {
293        Self {
294            matched: UrlMatch::new(pattern),
295        }
296    }
297}
298
299/// Converts a compiled regular expression into a pattern with no request-field overrides.
300impl From<Regex> for GlobPattern {
301    fn from(pattern: Regex) -> Self {
302        Self {
303            matched: UrlMatch::new(pattern),
304        }
305    }
306}
307
308impl From<UrlMatch> for GlobPattern {
309    fn from(matched: UrlMatch) -> Self {
310        Self { matched }
311    }
312}
313
314// These accessors form the handoff to the later enqueue-pipeline commit.
315#[allow(dead_code)]
316impl GlobPattern {
317    /// Returns the wrapped URL pattern.
318    pub(crate) fn pattern(&self) -> &UrlPattern {
319        &self.matched.pattern
320    }
321
322    /// Returns the optional routing-label override.
323    pub(crate) fn label(&self) -> Option<&str> {
324        self.matched.label.as_deref()
325    }
326
327    /// Returns the optional user-data override.
328    pub(crate) fn user_data(&self) -> Option<&UserData> {
329        self.matched.user_data.as_ref()
330    }
331
332    /// Returns the optional HTTP-method override.
333    pub(crate) fn method(&self) -> Option<&Method> {
334        self.matched.method.as_ref()
335    }
336
337    /// Returns the optional HTTP-headers override.
338    pub(crate) fn headers(&self) -> Option<&HeaderMap> {
339        self.matched.headers.as_ref()
340    }
341}
342
343/// A raw extracted link and the optional document base used to resolve it.
344///
345/// Resolution is intentionally deferred until enqueue time, preserving invalid
346/// raw values for skip reporting.
347///
348/// # Examples
349///
350/// ```
351/// use millipede_core::link_extraction::ExtractedLink;
352/// use url::Url;
353///
354/// let link = ExtractedLink {
355///     url: "../about".to_owned(),
356///     base: Some(Url::parse("https://example.com/docs/").unwrap()),
357/// };
358/// assert_eq!(link.url, "../about");
359/// ```
360#[derive(Debug, Clone)]
361pub struct ExtractedLink {
362    /// The unmodified link text, such as an element's `href` value.
363    pub url: String,
364    /// A per-document base URL, typically sourced from `<base href>`.
365    pub base: Option<Url>,
366}
367
368/// Extracts links from a static document or live browser page.
369///
370/// Passing `None` selects the implementation's default selector, normally
371/// `a[href]`. Extraction is asynchronous because a DOM-level browser extractor
372/// evaluates JavaScript against a live page over CDP (`millipede-browser`, Phase
373/// 6), while static-document extractors such as `millipede-html` simply have no
374/// await points. The trait remains object-safe so crawler implementations can
375/// erase their extractor.
376#[async_trait::async_trait]
377pub trait LinkExtractor: Send + Sync {
378    /// Extracts raw links selected by `selector` or the implementation default.
379    async fn extract(&self, selector: Option<&str>) -> Result<Vec<ExtractedLink>, CrawlError>;
380}
381
382/// The outcome of transforming a candidate request before enqueueing.
383#[derive(Debug)]
384#[non_exhaustive]
385pub enum TransformResult {
386    /// Enqueue the candidate, including any mutations made by the transform.
387    Enqueue,
388    /// Reject the candidate and report the supplied reason.
389    Skip {
390        /// A user-facing explanation for rejecting the candidate.
391        reason: String,
392    },
393}
394
395/// Receives notifications for URL candidates skipped during enqueueing.
396///
397/// This intentionally differs from `INTERFACE.md` section 7.1: skips often happen
398/// before a [`crate::request::Request`] can be built (for example, a glob-excluded
399/// raw URL), so the hook receives the URL string instead of an owned request. It is
400/// synchronous to avoid allocating and boxing a future for every skip.
401pub trait SkippedHandler: Send + Sync + 'static {
402    /// Handles one skipped URL and its reason.
403    fn on_skip(&self, url: &str, reason: &SkipReason);
404}
405
406impl<F> SkippedHandler for F
407where
408    F: Fn(&str, &SkipReason) + Send + Sync + 'static,
409{
410    fn on_skip(&self, url: &str, reason: &SkipReason) {
411        self(url, reason);
412    }
413}
414
415/// Long-lived limits and URL admission policy applied during a crawl.
416///
417/// The type is non-exhaustive so later phases can add robots handling without
418/// breaking callers.
419///
420/// # Examples
421///
422/// ```
423/// use millipede_core::link_extraction::{CrawlPolicy, EnqueueStrategy};
424///
425/// let policy = CrawlPolicy::new()
426///     .strategy(EnqueueStrategy::SameDomain)
427///     .max_crawl_depth(4)
428///     .max_requests_per_crawl(10_000);
429/// assert_eq!(policy.max_crawl_depth, Some(4));
430/// ```
431#[non_exhaustive]
432#[derive(Default)]
433pub struct CrawlPolicy {
434    /// The default relationship required between parent and candidate URLs.
435    pub strategy: EnqueueStrategy,
436    /// The maximum child crawl depth, or `None` for no depth limit.
437    pub max_crawl_depth: Option<u32>,
438    /// The maximum number of requests accepted in one crawl, or `None` for no limit.
439    pub max_requests_per_crawl: Option<u64>,
440    /// An optional callback invoked for every skipped URL.
441    pub on_skipped: Option<Arc<dyn SkippedHandler>>,
442}
443
444impl CrawlPolicy {
445    /// Creates the default crawl policy.
446    pub fn new() -> Self {
447        Self::default()
448    }
449
450    /// Sets the default URL admission strategy.
451    pub fn strategy(mut self, strategy: EnqueueStrategy) -> Self {
452        self.strategy = strategy;
453        self
454    }
455
456    /// Sets the maximum crawl depth.
457    pub fn max_crawl_depth(mut self, max_crawl_depth: u32) -> Self {
458        self.max_crawl_depth = Some(max_crawl_depth);
459        self
460    }
461
462    /// Sets the maximum number of requests accepted during the crawl.
463    pub fn max_requests_per_crawl(mut self, max_requests_per_crawl: u64) -> Self {
464        self.max_requests_per_crawl = Some(max_requests_per_crawl);
465        self
466    }
467
468    /// Sets the skipped-URL callback.
469    pub fn on_skipped<H: SkippedHandler>(mut self, handler: H) -> Self {
470        self.on_skipped = Some(Arc::new(handler));
471        self
472    }
473}
474
475impl fmt::Debug for CrawlPolicy {
476    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
477        formatter
478            .debug_struct("CrawlPolicy")
479            .field("strategy", &self.strategy)
480            .field("max_crawl_depth", &self.max_crawl_depth)
481            .field("max_requests_per_crawl", &self.max_requests_per_crawl)
482            .field(
483                "on_skipped",
484                &self.on_skipped.as_ref().map(|_| "<dyn SkippedHandler>"),
485            )
486            .finish()
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn compiled_globs_match_complete_url_strings() {
496        let patterns = vec![
497            "**/products/*".to_owned(),
498            "https://example.com/**".to_owned(),
499        ];
500        let compiled = compile_globs(&patterns).expect("valid globs");
501
502        assert!(compiled.is_match("https://shop.test/products/p1"));
503        assert!(compiled.is_match("https://example.com/anything/here"));
504        assert!(!compiled.is_match("https://shop.test/categories/c1"));
505    }
506
507    #[test]
508    fn invalid_glob_retains_source_pattern() {
509        let error = compile_globs(&["[".to_owned()]).expect_err("glob should be invalid");
510        assert!(matches!(
511            error,
512            LinkPatternError::InvalidGlob { ref pattern, .. } if pattern == "["
513        ));
514    }
515
516    #[test]
517    fn glob_pattern_accessors_expose_overrides() {
518        let mut user_data = UserData::default();
519        user_data
520            .set_typed("kind", &"product")
521            .expect("serializable data");
522        let mut headers = HeaderMap::new();
523        headers.insert("x-test", "yes".parse().expect("valid header value"));
524        let pattern = GlobPattern::from(
525            UrlMatch::new(Regex::new("products").expect("valid regex"))
526                .label("product")
527                .user_data(user_data)
528                .method(Method::POST)
529                .headers(headers),
530        );
531
532        assert!(matches!(pattern.pattern(), UrlPattern::Regex(_)));
533        assert_eq!(pattern.label(), Some("product"));
534        assert_eq!(
535            pattern.user_data().and_then(|data| data.get("kind")),
536            Some(&serde_json::json!("product"))
537        );
538        assert_eq!(pattern.method(), Some(&Method::POST));
539        assert_eq!(
540            pattern.headers().and_then(|map| map.get("x-test")),
541            Some(&"yes".parse().expect("valid header value"))
542        );
543    }
544}