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}