use crate::{
crawler::{CrawlerHandle, EnqueueAdmissionReservation},
errors::CrawlError,
link_extraction::{
CrawlPolicy, EnqueueStrategy, ExtractedLink, GlobPattern, LinkExtractor, TransformResult,
UrlPattern, compile_globs, strategy_allows,
},
request::{Request, RequestId, UserData},
storage::{AddOptions, ProcessedRequest},
};
use futures_util::future::BoxFuture;
use globset::GlobSet;
use regex::Regex;
use std::{collections::HashSet, fmt, sync::Arc};
use url::Url;
type Transform = dyn for<'r> Fn(&'r mut Request) -> BoxFuture<'r, TransformResult> + Send + Sync;
#[derive(Clone)]
#[must_use = "enqueue pipelines do nothing unless send is awaited"]
pub struct EnqueueLinker {
crawler: CrawlerHandle,
parent_url: Url,
parent_depth: u32,
extractor: Option<Arc<dyn LinkExtractor>>,
}
impl EnqueueLinker {
pub fn new(crawler: CrawlerHandle, parent: &Request) -> Self {
Self {
crawler,
parent_url: parent.url.clone(),
parent_depth: parent.crawl_depth,
extractor: None,
}
}
pub fn with_extractor(
crawler: CrawlerHandle,
parent: &Request,
extractor: Arc<dyn LinkExtractor>,
) -> Self {
Self {
crawler,
parent_url: parent.url.clone(),
parent_depth: parent.crawl_depth,
extractor: Some(extractor),
}
}
pub fn options(&self) -> EnqueueLinksOptions<'_> {
EnqueueLinksOptions::new(self)
}
pub async fn urls(
&self,
urls: impl IntoIterator<Item = Url>,
) -> Result<EnqueueResult, CrawlError> {
self.options().urls(urls).send().await
}
pub async fn all(&self) -> Result<EnqueueResult, CrawlError> {
self.options().strategy(EnqueueStrategy::All).send().await
}
pub async fn same_origin(&self) -> Result<EnqueueResult, CrawlError> {
self.options()
.strategy(EnqueueStrategy::SameOrigin)
.send()
.await
}
pub async fn same_hostname(&self) -> Result<EnqueueResult, CrawlError> {
self.options()
.strategy(EnqueueStrategy::SameHostname)
.send()
.await
}
pub async fn same_domain(&self) -> Result<EnqueueResult, CrawlError> {
self.options()
.strategy(EnqueueStrategy::SameDomain)
.send()
.await
}
}
impl fmt::Debug for EnqueueLinker {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("EnqueueLinker")
.field("crawler", &self.crawler)
.field("parent_url", &self.parent_url)
.field("parent_depth", &self.parent_depth)
.field(
"extractor",
&self.extractor.as_ref().map(|_| "<dyn LinkExtractor>"),
)
.finish()
}
}
#[non_exhaustive]
#[must_use = "enqueue options do nothing unless passed through the enqueue pipeline"]
pub struct EnqueueLinksOptions<'a> {
linker: &'a EnqueueLinker,
candidates: Vec<UrlCandidate>,
base_url: Option<Url>,
label: Option<String>,
user_data: Option<UserData>,
selector: Option<String>,
strategy: Option<EnqueueStrategy>,
globs: Vec<GlobPattern>,
regex: Vec<Regex>,
exclude: Vec<UrlPattern>,
transform: Option<Arc<Transform>>,
limit: Option<usize>,
forefront: bool,
}
enum UrlCandidate {
Absolute(Url),
Raw { url: String, base: Option<Url> },
}
impl<'a> EnqueueLinksOptions<'a> {
fn new(linker: &'a EnqueueLinker) -> Self {
Self {
linker,
candidates: Vec::new(),
base_url: None,
label: None,
user_data: None,
selector: None,
strategy: None,
globs: Vec::new(),
regex: Vec::new(),
exclude: Vec::new(),
transform: None,
limit: None,
forefront: false,
}
}
pub fn urls(mut self, urls: impl IntoIterator<Item = Url>) -> Self {
self.candidates
.extend(urls.into_iter().map(UrlCandidate::Absolute));
self
}
pub fn raw_urls<S: Into<String>>(mut self, urls: impl IntoIterator<Item = S>) -> Self {
self.candidates
.extend(urls.into_iter().map(|url| UrlCandidate::Raw {
url: url.into(),
base: None,
}));
self
}
pub fn base_url(mut self, base_url: Url) -> Self {
self.base_url = Some(base_url);
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn user_data(mut self, user_data: UserData) -> Self {
self.user_data = Some(user_data);
self
}
pub fn selector(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(selector.into());
self
}
pub fn strategy(mut self, strategy: EnqueueStrategy) -> Self {
self.strategy = Some(strategy);
self
}
pub fn globs<G: Into<GlobPattern>>(mut self, globs: impl IntoIterator<Item = G>) -> Self {
self.globs.extend(globs.into_iter().map(Into::into));
self
}
pub fn regex(mut self, patterns: impl IntoIterator<Item = Regex>) -> Self {
self.regex.extend(patterns);
self
}
pub fn exclude<P: Into<UrlPattern>>(mut self, patterns: impl IntoIterator<Item = P>) -> Self {
self.exclude.extend(patterns.into_iter().map(Into::into));
self
}
pub fn transform<F>(mut self, transform: F) -> Self
where
F: for<'r> Fn(&'r mut Request) -> BoxFuture<'r, TransformResult> + Send + Sync + 'static,
{
self.transform = Some(Arc::new(transform));
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
pub fn forefront(mut self, forefront: bool) -> Self {
self.forefront = forefront;
self
}
pub async fn send(mut self) -> Result<EnqueueResult, CrawlError> {
let should_extract = self.selector.is_some()
|| (self.candidates.is_empty() && self.linker.extractor.is_some());
if should_extract {
let extractor = self.linker.extractor.as_ref().ok_or_else(|| {
CrawlError::non_retryable(anyhow::anyhow!(
"selector-based enqueue requires an HTML or browser context; use urls()/raw_urls() on HTTP contexts"
))
})?;
let extracted = extractor.extract(self.selector.as_deref()).await?;
self.candidates.extend(
extracted
.into_iter()
.map(|ExtractedLink { url, base }| UrlCandidate::Raw { url, base }),
);
}
let policy = self
.linker
.crawler
.crawl_policy()
.unwrap_or_else(|| Arc::new(CrawlPolicy::default()));
let strategy = self.strategy.unwrap_or(policy.strategy);
let compiled_globs = compile_include_patterns(self.globs)?;
let compiled_excludes = compile_patterns(self.exclude)?;
let has_globs = !compiled_globs.is_empty();
let has_regex = !self.regex.is_empty();
let mut skipped = Vec::new();
let mut candidates = Vec::with_capacity(self.candidates.len());
for candidate in self.candidates {
let (resolved, apply_strategy) = match candidate {
UrlCandidate::Absolute(url) => (Ok(url), false),
UrlCandidate::Raw { url, base } => {
let base = base
.as_ref()
.or(self.base_url.as_ref())
.unwrap_or(&self.linker.parent_url);
(resolve_raw_url(base, &url).map_err(|_| url), true)
}
};
let url = match resolved {
Ok(candidate) => candidate,
Err(raw) => {
report_skip(&policy, &mut skipped, raw, SkipReason::InvalidUrl);
continue;
}
};
let url_text = url.to_string();
if apply_strategy && !strategy_allows(strategy, &self.linker.parent_url, &url) {
report_skip(
&policy,
&mut skipped,
url_text,
SkipReason::StrategyExcluded,
);
continue;
}
if let Some(reason) = first_exclusion(&compiled_excludes, &url_text) {
report_skip(&policy, &mut skipped, url_text, reason);
continue;
}
let include_override = compiled_globs
.iter()
.find(|pattern| pattern.matches(&url_text))
.map(|pattern| pattern.overrides());
if has_globs || has_regex {
let regex_matches = self.regex.iter().any(|pattern| pattern.is_match(&url_text));
if include_override.is_none() && !regex_matches {
let reason = if has_globs {
SkipReason::GlobExcluded
} else {
SkipReason::RegexExcluded
};
report_skip(&policy, &mut skipped, url_text, reason);
continue;
}
}
candidates.push((url, include_override.unwrap_or_default()));
}
let mut seen = HashSet::new();
candidates.retain(|(url, _)| seen.insert(url.to_string()));
if let Some(limit) = self.limit {
candidates.truncate(limit);
}
let mut requests = Vec::with_capacity(candidates.len());
let mut admission_reservations = Vec::new();
let child_depth = self.linker.parent_depth.saturating_add(1);
for (url, overrides) in candidates {
let url_text = url.to_string();
let mut builder = Request::builder().url(url).crawl_depth(child_depth);
if let Some(label) = overrides.label.as_deref().or(self.label.as_deref()) {
builder = builder.label(label.to_owned());
}
if let Some(user_data) = overrides.user_data.as_ref().or(self.user_data.as_ref()) {
builder = builder.user_data(user_data.clone());
}
if let Some(method) = overrides.method {
builder = builder.method(method);
}
if let Some(headers) = overrides.headers {
builder = builder.headers(headers);
}
let mut request = match builder.build() {
Ok(request) => request,
Err(_) => {
report_skip(&policy, &mut skipped, url_text, SkipReason::InvalidUrl);
continue;
}
};
if let Some(limit) = policy.max_crawl_depth {
if child_depth > limit {
report_skip(
&policy,
&mut skipped,
url_text,
SkipReason::MaxDepthExceeded {
depth: child_depth,
limit,
},
);
continue;
}
}
let admission_reservation = if let Some(limit) = policy.max_requests_per_crawl {
match reserve_request_slot(&self.linker.crawler, limit).await? {
Admission::Reserved(reservation) => Some(reservation),
Admission::Untracked => None,
Admission::Rejected => {
report_skip(
&policy,
&mut skipped,
url_text,
SkipReason::MaxRequestsReached { limit },
);
continue;
}
}
} else {
None
};
let pre_transform_unique_key = request.unique_key.clone();
if let Some(transform) = &self.transform {
match transform(&mut request).await {
TransformResult::Enqueue => {}
TransformResult::Skip { reason } => {
report_skip(
&policy,
&mut skipped,
url_text,
SkipReason::TransformRejected { reason },
);
continue;
}
}
}
if request.unique_key == pre_transform_unique_key {
request.unique_key = Request::compute_unique_key(
&request.url,
&request.method,
request.body.as_ref(),
);
request.id = RequestId::from_unique_key(&request.unique_key);
}
requests.push(request);
admission_reservations.push(admission_reservation);
}
let request_urls: Vec<_> = requests
.iter()
.map(|request| request.url.to_string())
.collect();
let batch = self
.linker
.crawler
.add_requests_with_options(
requests,
AddOptions {
forefront: self.forefront,
},
)
.await?;
let batch = batch.wait().await?;
let mut added = Vec::new();
let mut admission_reservations = admission_reservations.into_iter();
for (index, request) in batch.processed.into_iter().enumerate() {
let admission_reservation = admission_reservations.next().flatten();
if request.was_already_present || request.was_already_handled {
let url = request_urls
.get(index)
.cloned()
.unwrap_or_else(|| request.unique_key.clone());
report_skip(&policy, &mut skipped, url, SkipReason::DuplicateUniqueKey);
} else {
if let Some(reservation) = admission_reservation {
reservation.commit();
}
added.push(request);
}
}
Ok(EnqueueResult { added, skipped })
}
}
enum Admission {
Reserved(EnqueueAdmissionReservation),
Untracked,
Rejected,
}
async fn reserve_request_slot(
crawler: &CrawlerHandle,
limit: u64,
) -> Result<Admission, CrawlError> {
let _admission = crawler.lock_enqueue_admission().await?;
let queue = match crawler.request_queue() {
Some(queue) => queue,
None => return Ok(Admission::Untracked),
};
let Some(queue_count) = request_count(queue.as_ref()).await else {
return Ok(Admission::Untracked);
};
let admitted = crawler.synchronize_enqueue_admissions(queue_count)?;
if admitted >= limit {
return Ok(Admission::Rejected);
}
Ok(Admission::Reserved(crawler.reserve_enqueue_admission()?))
}
async fn request_count(queue: &dyn crate::storage::RequestQueue) -> Option<u64> {
let handled = match queue.handled_count().await {
Ok(count) => count,
Err(error) => {
tracing::debug!(%error, "could not read handled request count; skipping max-request admission check");
return None;
}
};
let pending = match queue.pending_count().await {
Ok(count) => count,
Err(error) => {
tracing::debug!(%error, "could not read pending request count; skipping max-request admission check");
return None;
}
};
Some(handled.saturating_add(pending))
}
#[derive(Default)]
struct PatternOverrides {
label: Option<String>,
user_data: Option<UserData>,
method: Option<crate::request::Method>,
headers: Option<crate::request::HeaderMap>,
}
enum CompiledPattern {
Glob(GlobSet),
Regex(Regex),
}
impl CompiledPattern {
fn matches(&self, url: &str) -> bool {
match self {
Self::Glob(pattern) => pattern.is_match(url),
Self::Regex(pattern) => pattern.is_match(url),
}
}
}
struct CompiledInclude {
pattern: CompiledPattern,
source: GlobPattern,
}
impl CompiledInclude {
fn matches(&self, url: &str) -> bool {
self.pattern.matches(url)
}
fn overrides(&self) -> PatternOverrides {
PatternOverrides {
label: self.source.label().map(str::to_owned),
user_data: self.source.user_data().cloned(),
method: self.source.method().cloned(),
headers: self.source.headers().cloned(),
}
}
}
fn compile_include_patterns(
patterns: Vec<GlobPattern>,
) -> Result<Vec<CompiledInclude>, CrawlError> {
patterns
.into_iter()
.map(|source| {
let pattern = compile_pattern(source.pattern().clone())?;
Ok(CompiledInclude { pattern, source })
})
.collect()
}
fn compile_patterns(patterns: Vec<UrlPattern>) -> Result<Vec<CompiledPattern>, CrawlError> {
patterns.into_iter().map(compile_pattern).collect()
}
fn compile_pattern(pattern: UrlPattern) -> Result<CompiledPattern, CrawlError> {
match pattern {
UrlPattern::Glob(pattern) => compile_globs(std::slice::from_ref(&pattern))
.map(CompiledPattern::Glob)
.map_err(CrawlError::non_retryable),
UrlPattern::Regex(pattern) => Ok(CompiledPattern::Regex(pattern)),
}
}
fn first_exclusion(patterns: &[CompiledPattern], url: &str) -> Option<SkipReason> {
patterns.iter().find_map(|pattern| {
if !pattern.matches(url) {
return None;
}
Some(match pattern {
CompiledPattern::Glob(_) => SkipReason::GlobExcluded,
CompiledPattern::Regex(_) => SkipReason::RegexExcluded,
})
})
}
fn report_skip(
policy: &CrawlPolicy,
skipped: &mut Vec<SkippedUrl>,
url: String,
reason: SkipReason,
) {
if let Some(handler) = &policy.on_skipped {
handler.on_skip(&url, &reason);
}
skipped.push(SkippedUrl { url, reason });
}
fn resolve_raw_url(base: &Url, raw: &str) -> Result<Url, url::ParseError> {
if let Ok(absolute) = Url::parse(raw) {
return Ok(absolute);
}
let first_path_segment = raw
.split_once(['?', '#'])
.map_or(raw, |(path, _)| path)
.split('/')
.next()
.unwrap_or_default();
if first_path_segment.contains(':') {
return Err(url::ParseError::RelativeUrlWithoutBase);
}
base.join(raw)
}
#[derive(Debug)]
#[non_exhaustive]
#[must_use = "enqueue results report both accepted and skipped URLs"]
pub struct EnqueueResult {
pub added: Vec<ProcessedRequest>,
pub skipped: Vec<SkippedUrl>,
}
impl EnqueueResult {
pub fn added_count(&self) -> usize {
self.added.len()
}
pub fn skipped_count(&self) -> usize {
self.skipped.len()
}
}
#[derive(Debug, Clone)]
pub struct SkippedUrl {
pub url: String,
pub reason: SkipReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkipReason {
MaxDepthExceeded {
depth: u32,
limit: u32,
},
MaxRequestsReached {
limit: u64,
},
StrategyExcluded,
GlobExcluded,
RegexExcluded,
TransformRejected {
reason: String,
},
DuplicateUniqueKey,
InvalidUrl,
}
impl fmt::Display for SkipReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MaxDepthExceeded { depth, limit } => {
write!(formatter, "crawl depth {depth} exceeds limit {limit}")
}
Self::MaxRequestsReached { limit } => {
write!(formatter, "maximum request count {limit} reached")
}
Self::StrategyExcluded => formatter.write_str("excluded by enqueue strategy"),
Self::GlobExcluded => formatter.write_str("excluded by glob patterns"),
Self::RegexExcluded => formatter.write_str("excluded by regex patterns"),
Self::TransformRejected { reason } => {
write!(formatter, "rejected by transform: {reason}")
}
Self::DuplicateUniqueKey => formatter.write_str("duplicate unique key"),
Self::InvalidUrl => formatter.write_str("invalid URL"),
}
}
}