use std::collections::{HashMap, HashSet};
use std::time::Duration;
use futures_util::StreamExt;
use futures_util::stream::FuturesUnordered;
use pagesift_api::ApiError;
use pagesift_api::types::{CrawlSummary, ScrapeOptions};
use pagesift_net::Url;
use pagesift_policy::AdaptiveConcurrencyGate;
use pagesift_policy::RobotsMode;
use super::attachments::{Attachments, is_document_url};
use crate::engine::Scraper;
use crate::focus::{
Adaptive, BestFirst, Focus, Harvest, LinkSignal, MIN_RELEVANCE, Offer, PageSignal, cluster_key,
link_contexts, subtree,
};
use crate::frontier::{Learner, Seen, Tiered};
use pagesift_api::types::CrawlTrace;
use super::{Crawl, MAX_WAIT, Retries, candidate, push_warning, seen_hash};
mod focused;
impl Scraper {
async fn crawl_attempt(
&self,
url: &Url,
opts: &ScrapeOptions,
mode: RobotsMode,
slots: &AdaptiveConcurrencyGate,
) -> Result<pagesift_api::types::Document, ApiError> {
let host = url.host_str().unwrap_or_default();
if let Err(wait) = self.policy().backoff().check(host)
&& wait <= MAX_WAIT
{
tokio::time::sleep(wait).await;
}
let queued_at = std::time::Instant::now();
let _slot = slots.acquire().await;
let queue_wait = queued_at.elapsed();
let result = match self.page_runner() {
Some(r) => r.run("crawl", url.as_str(), opts, mode).await,
None => Box::pin(self.scrape_audited("crawl", url.as_str(), opts, mode)).await,
};
self.observe_crawl(queue_wait, result.is_err());
result
}
pub(super) async fn crawl_bfs(&self, c: Crawl<'_>, start: Url) -> Result<(), ApiError> {
let (req, sink, scope, mode) = (c.req, c.sink, c.scope, c.mode);
let follow_links = req.sitemap != "only";
let max_depth = req.max_discovery_depth.unwrap_or(usize::MAX);
let mut seen: HashSet<u64> = HashSet::new();
let mut queue = Tiered::default();
let mut learner = Learner::default();
let mut retries = Retries::new(self.policy().backoff().enabled());
let mut normal_inflight = 0usize;
let mut host_inflight: HashMap<String, usize> = HashMap::new();
let per_host = self.policy().limiter().config().per_host_concurrency.max(1);
let track = sink.tracks_frontier();
let mut scheduled = 0usize;
let mut delivered = 0usize;
let mut deferred: Vec<Url> = Vec::new();
if let Some(r) = sink.resume() {
let enqueued: Vec<Url> = r
.enqueued
.iter()
.filter_map(|u| Url::parse(u).ok())
.collect();
scope.restore_widened(&enqueued);
for u in &enqueued {
seen.insert(seen_hash(u, req.ignore_query_parameters));
}
for (u, depth) in r.pending {
if let Ok(u) = Url::parse(&u) {
queue.push(&learner, u, depth);
}
}
scheduled = r.done;
} else {
seen.insert(seen_hash(&start, req.ignore_query_parameters));
queue.push(&learner, start.clone(), 0);
for u in self.initial_sitemap(&c, &start).await {
let Some(u) = candidate(&u, "", &learner) else {
continue;
};
if scope.may_widen_to(&u) {
deferred.push(u);
} else if queue.len() < req.limit
&& scope.allows(&u)
&& seen.insert(seen_hash(&u, req.ignore_query_parameters))
{
queue.push(&learner, u, 0);
}
}
if track {
sink.enqueued(queue.iter().map(|(u, d)| (u.to_string(), d)).collect())
.await;
}
}
let wants_links = req.scrape_options.wants("links");
let wants_md = req.scrape_options.wants("markdown");
let (opts, _) = self.crawl_settings(req, &["markdown"]);
let concurrency_ceiling = req.max_concurrency.unwrap_or(usize::MAX).max(1);
let slots = self.crawl_slots();
let delay = req.delay.map(Duration::from_secs_f64);
let mut inflight = FuturesUnordered::new();
sink.total((scheduled + queue.len()).min(req.limit)).await;
let mut overflow = false;
let mut cancelled = false;
loop {
if sink.cancelled().await {
cancelled = true;
break;
}
while inflight.len() < self.concurrency().min(concurrency_ceiling) {
let pick = loop {
if let Some((u, d, a)) = retries.due() {
break Some((u, d, a, false));
}
if scheduled >= req.limit {
break None;
}
let allow_low = normal_inflight == 0 && retries.is_empty();
let busy = |h: &str| host_inflight.get(h).is_some_and(|&n| n >= per_host);
let Some((mut url, depth, low)) = queue.pop(&learner, allow_low, busy) else {
break None;
};
if learner.rewrite(&mut url)
&& !seen.insert(seen_hash(&url, req.ignore_query_parameters))
{
continue;
}
scheduled += 1;
break Some((url, depth, 0, low));
};
let Some((url, depth, attempt, low)) = pick else {
break;
};
normal_inflight += usize::from(!low);
*host_inflight.entry(frontier_host(&url)).or_default() += 1;
let opts = opts.clone();
inflight.push(async move {
let result = self.crawl_attempt(&url, &opts, mode, slots).await;
(url, depth, attempt, low, result)
});
if let Some(d) = delay {
tokio::time::sleep(d).await;
}
}
let (url, depth, attempt, low, result) = match inflight.next().await {
Some(done) => done,
None => match retries.next_due() {
Some(t) => {
tokio::time::sleep_until(t).await;
continue;
}
None => break,
},
};
normal_inflight -= usize::from(!low);
if let Some(n) = host_inflight.get_mut(&frontier_host(&url)) {
*n -= 1;
if *n == 0 {
host_inflight.remove(&frontier_host(&url));
}
}
if retries.offer(&result, &url, depth, attempt, depth == 0 && url == start) {
continue;
}
match result {
Ok(mut doc) => {
let base = doc
.metadata
.url
.as_deref()
.and_then(|u| Url::parse(u).ok())
.unwrap_or(url.clone());
note_duplicate(&mut learner, &url, &mut doc);
let mut fresh: Vec<(String, usize)> = Vec::new();
if depth == 0 && url == start && scope.can_widen() {
let links: Vec<Url> = doc
.links
.iter()
.flatten()
.filter_map(|l| candidate(&base, l, &learner))
.collect();
if let Some(w) = scope.widen_from_seed(
&start,
doc.markdown.as_deref().unwrap_or_default(),
&links,
) {
push_warning(&mut doc, w);
for u in deferred.drain(..) {
if scope.allows(&u)
&& scheduled + queue.len() < req.limit
&& seen.insert(seen_hash(&u, req.ignore_query_parameters))
{
if track {
fresh.push((u.to_string(), 0));
}
queue.push(&learner, u, 0);
}
}
}
}
if follow_links && depth < max_depth {
let mut discovered = 0usize;
let mut followed = 0usize;
for link in doc.links.iter().flatten() {
let Some(u) = candidate(&base, link, &learner) else {
continue;
};
discovered += 1;
let h = seen_hash(&u, req.ignore_query_parameters);
if !scope.allows(&u) || seen.contains(&h) {
continue;
}
if scheduled + queue.len() >= req.limit {
overflow = true;
if learner.demoted(&u) {
continue;
}
match queue.evict_low(&learner) {
Some(old) => {
seen.remove(&seen_hash(&old, req.ignore_query_parameters));
}
None => break,
}
}
seen.insert(h);
followed += 1;
if track {
fresh.push((u.to_string(), depth + 1));
}
queue.push(&learner, u, depth + 1);
}
if depth == 0 && discovered > 0 && followed == 0 {
push_warning(
&mut doc,
format!(
"crawl_followed_zero: discovered {discovered} link(s) on the start page but followed none; the start page looks like a leaf page; use --scope parent for its section, --entire-domain for the whole site, or check --include/--exclude"
),
);
}
}
if !fresh.is_empty() {
sink.enqueued(fresh).await;
}
if !wants_links {
doc.links = None;
}
if !wants_md {
doc.markdown = None;
}
delivered += 1;
sink.page(doc).await;
}
Err(e) => sink.error(url.as_str(), e).await,
}
sink.total((scheduled + queue.len()).min(req.limit)).await;
}
let stop_reason = if cancelled {
"cancelled"
} else if scheduled >= req.limit && (overflow || !queue.is_empty()) {
"limit_reached"
} else {
"exhausted"
};
sink.summary(CrawlSummary {
stop_reason: stop_reason.into(),
coverage: None,
term_evidence: Default::default(),
pages: delivered,
})
.await;
Ok(())
}
}
fn frontier_host(u: &Url) -> String {
u.host_str().unwrap_or_default().to_string()
}
const DEMOTED_SCORE: f32 = 0.01;
const PATIENCE_RATIO: f32 = 0.6;
fn room_left(limit: usize, scheduled: usize, refunds: usize) -> usize {
limit.saturating_sub(scheduled) + limit.saturating_sub(refunds)
}
fn failure_weight(e: &ApiError) -> u32 {
match e.code {
pagesift_api::ErrorCode::AccessBarrier => HostFailures::IMMEDIATE,
pagesift_api::ErrorCode::RobotsDisallowed => 0,
_ => 1,
}
}
#[derive(Default)]
struct HostFailures {
per_host: HashMap<String, u32>,
attempts: u32,
failed: u32,
}
impl HostFailures {
const IMMEDIATE: u32 = u32::MAX / 2;
fn add(&mut self, host: &str, weight: u32) {
let n = self.per_host.entry(host.to_string()).or_default();
*n = n.saturating_add(weight);
}
fn observe_alive(&mut self, failed: bool) {
self.attempts += 1;
self.failed += u32::from(failed);
}
fn limit(&self) -> u32 {
let delta = pagesift_stat::Delta::DEFAULT;
let d = delta.get();
let a = (1.0 - d) / d;
let prior = (d / (1.0 - d)).sqrt();
let n = f64::from(self.attempts) + 1.0;
let post = (f64::from(self.failed) + prior) / n;
let se = (post * (1.0 - post) / n).sqrt();
let q = if self.attempts > 0
&& post > prior
&& pagesift_stat::shrink::significant(prior, post, se, delta)
{
post
} else {
prior
};
let f = (a.ln() / (1.0 / q).ln() - 1e-9).ceil();
f.clamp(1.0, f64::from(Self::IMMEDIATE)) as u32
}
fn blocked(&self, u: &Url) -> bool {
self.per_host
.get(u.host_str().unwrap_or_default())
.is_some_and(|&n| n >= self.limit())
}
}
fn note_duplicate(
learner: &mut Learner,
url: &Url,
doc: &mut pagesift_api::types::Document,
) -> bool {
let md = doc.markdown.as_deref().unwrap_or_default();
if let Seen::Duplicate(first) = learner.observe(url, md) {
doc.metadata
.pagesift
.get_or_insert_with(Default::default)
.duplicate_of = Some(first);
return true;
}
false
}
#[cfg(test)]
mod host_failure_tests {
use super::HostFailures;
#[test]
fn sprt_limit_follows_the_transient_failure_rate() {
let mut h = HostFailures::default();
assert_eq!(h.limit(), 2);
h.observe_alive(true);
assert_eq!(h.limit(), 2, "one sample is not significant");
let mut clean = HostFailures::default();
(0..100).for_each(|_| clean.observe_alive(false));
assert_eq!(clean.limit(), 2);
let mut flaky = HostFailures::default();
(0..100).for_each(|i| flaky.observe_alive(i % 2 == 0));
assert!(flaky.limit() >= 4, "{}", flaky.limit());
let mut b = HostFailures::default();
b.add("x.com", HostFailures::IMMEDIATE);
assert!(b.blocked(&pagesift_net::Url::parse("https://x.com/a").unwrap()));
}
}