use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use pagesift_api::types::{
CrawlRequest, CrawlScope, Document, Format, MapLink, MapRequest, ScrapeOptions,
};
use pagesift_api::{ApiError, CrawlSink, ErrorCode};
use pagesift_net::Url;
use pagesift_policy::{RequestKind, RobotsMode};
use regex::Regex;
use crate::engine::{Scraper, policy_error};
use crate::frontier::{self, Learner};
mod attachments;
mod runner;
mod sitemap;
#[cfg(test)]
mod tests_sitemap;
#[cfg(test)]
use sitemap::{gunzip_if_needed, parse_sitemap};
mod retry;
mod scope;
#[cfg(test)]
mod tests;
use retry::*;
pub use scope::*;
pub(super) const SITEMAP_MAX_DEPTH: usize = 3;
pub(super) const SITEMAP_MAX_FILES: usize = 50;
pub(super) const SITEMAP_MAX_BYTES: u64 = 50 * 1024 * 1024;
pub(super) const SITEMAP_FALLBACKS: &[&str] =
&["/sitemap.xml", "/sitemap_index.xml", "/wp-sitemap.xml"];
const MAX_RETRIES: u32 = 2;
pub(super) const MAX_WAIT: Duration = Duration::from_secs(60);
const RETRY_BASE: Duration = Duration::from_millis(500);
const SEED_MAX_RETRIES: u32 = 4;
const SEED_RETRY_BASE: Duration = Duration::from_secs(2);
pub fn normalize(url: &Url, ignore_query: bool) -> String {
let mut u = url.clone();
u.set_fragment(None);
if ignore_query {
u.set_query(None);
} else if u.query().is_some() {
let mut pairs: Vec<(String, String)> = u
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
pairs.sort();
u.query_pairs_mut().clear().extend_pairs(pairs);
}
u.to_string()
}
fn dedup_key(url: &Url, ignore_query: bool) -> String {
normalize(url, ignore_query).replacen("://www.", "://", 1)
}
pub(super) fn seen_hash(url: &Url, ignore_query: bool) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
dedup_key(url, ignore_query).hash(&mut h);
h.finish()
}
pub(super) fn push_warning(doc: &mut Document, message: String) {
doc.metadata
.pagesift
.get_or_insert_with(Default::default)
.warnings
.push(message);
}
pub(super) fn candidate(base: &Url, link: &str, learner: &Learner) -> Option<Url> {
let mut u = base.join(link).ok()?;
if u.scheme() == "http"
&& base.scheme() == "https"
&& u.host_str().zip(base.host_str()).is_some_and(|(a, b)| {
a == b || pagesift_content::site_key(a) == pagesift_content::site_key(b)
})
{
let _ = u.set_scheme("https");
}
frontier::clean(&mut u);
learner.rewrite(&mut u);
(!frontier::skip(&u)).then_some(u)
}
impl Scraper {
pub async fn map(&self, req: &MapRequest) -> Result<Vec<MapLink>, ApiError> {
let work = self.map_inner(req);
match req.timeout {
Some(ms) => tokio::time::timeout(Duration::from_millis(ms), work)
.await
.map_err(|_| ApiError::new(ErrorCode::Timeout, "map timed out"))?,
None => work.await,
}
}
async fn map_inner(&self, req: &MapRequest) -> Result<Vec<MapLink>, ApiError> {
let start = Url::parse(&req.url).map_err(|e| ApiError::invalid(e.to_string()))?;
let mode = self
.policy()
.robots_mode(RequestKind::Map, req.ignore_robots_txt)
.map_err(policy_error)?;
self.audit_robots_ignored(RequestKind::Map, mode, &req.url);
let scope = Scope::new(
&start,
req.include_subdomains,
false,
CrawlScope::Domain,
&[],
&[],
)?;
let mut found: Vec<Url> = Vec::new();
if req.sitemap != "skip" {
found.extend(
self.sitemap_urls(&start, mode, req.limit.saturating_mul(2))
.await,
);
}
let mut root_error = None;
if req.sitemap != "only" {
let opts = ScrapeOptions {
formats: vec![Format::new("links")],
engine: Some("http".into()),
..Default::default()
};
match self.scrape_with(start.as_str(), &opts, mode).await {
Ok(doc) => found.extend(
doc.links
.unwrap_or_default()
.iter()
.filter_map(|l| Url::parse(l).ok()),
),
Err(error) => root_error = Some(error),
}
}
if found.is_empty()
&& let Some(error) = root_error
{
return Err(error);
}
let mut seen = HashSet::new();
let mut links: Vec<String> = Vec::new();
for mut u in std::iter::once(start.clone()).chain(found) {
frontier::clean(&mut u);
if scope.allows(&u) && seen.insert(dedup_key(&u, req.ignore_query_parameters)) {
links.push(normalize(&u, req.ignore_query_parameters));
}
}
if let Some(search) = req.search.as_deref().filter(|s| !s.trim().is_empty()) {
let terms: Vec<String> = search.split_whitespace().map(str::to_lowercase).collect();
let hits = |u: &str| {
let l = u.to_lowercase();
terms.iter().filter(|t| l.contains(t.as_str())).count()
};
links.sort_by_key(|u| std::cmp::Reverse(hits(u)));
}
links.truncate(req.limit);
Ok(links
.into_iter()
.map(|url| MapLink {
url,
title: None,
description: None,
})
.collect())
}
pub async fn crawl(
&self,
req: &CrawlRequest,
sink: Arc<dyn CrawlSink>,
) -> Result<(), ApiError> {
if req.adaptive.is_some() && req.query.is_none() {
return Err(ApiError::invalid("adaptive crawling needs a query"));
}
if let Some(a) = &req.adaptive
&& !(a.coverage_threshold > 0.0
&& a.coverage_threshold <= 1.0
&& (0.0..=1.0).contains(&a.min_gain))
{
return Err(ApiError::invalid(
"adaptive.coverageThreshold must be in (0, 1] and adaptive.minGain in [0, 1]",
));
}
let start = Url::parse(&req.url).map_err(|e| ApiError::invalid(e.to_string()))?;
let mode = self
.policy()
.robots_mode(RequestKind::Crawl, req.ignore_robots_txt)
.map_err(policy_error)?;
self.audit_robots_ignored(RequestKind::Crawl, mode, &req.url);
if req.attachments && req.query.is_none() {
return Err(ApiError::invalid("attachments need a query"));
}
let scope = Scope::new(
&start,
allow_subdomains(req),
req.allow_external_links,
if req.crawl_entire_domain {
CrawlScope::Domain
} else {
req.scope.unwrap_or(if req.query.is_some() {
CrawlScope::Domain
} else {
CrawlScope::Auto
})
},
&req.include_paths,
&req.exclude_paths,
)?;
let crawl = Crawl {
req,
sink: sink.as_ref(),
scope: &scope,
mode,
};
match req.query.as_deref() {
Some(q) => self.crawl_focused(crawl, start, q).await,
None => self.crawl_bfs(crawl, start).await,
}
}
pub(super) async fn initial_sitemap(&self, c: &Crawl<'_>, start: &Url) -> Vec<Url> {
if c.req.sitemap == "skip" {
return Vec::new();
}
self.sitemap_urls(start, c.mode, c.req.limit.saturating_mul(2))
.await
}
pub(super) fn crawl_settings(
&self,
req: &CrawlRequest,
extra: &[&str],
) -> (Arc<ScrapeOptions>, usize) {
let mut opts = req.scrape_options.clone();
if opts.image_assets.is_some() && opts.asset_fetch_cache.is_none() {
opts.asset_fetch_cache = Some(Default::default());
}
for kind in std::iter::once("links").chain(extra.iter().copied()) {
if !opts.wants(kind) {
opts.formats.push(Format::new(kind));
}
}
let rate = self.policy().limiter().config();
let default = if allow_subdomains(req) || req.allow_external_links {
rate.global_concurrency
} else {
rate.per_host_concurrency
};
let concurrency = req.max_concurrency.unwrap_or(default).max(1);
(Arc::new(opts), concurrency)
}
}
pub fn allow_subdomains(req: &CrawlRequest) -> bool {
req.allow_subdomains.unwrap_or(req.query.is_some())
}
pub(super) struct Crawl<'a> {
pub(super) req: &'a CrawlRequest,
pub(super) sink: &'a dyn CrawlSink,
pub(super) scope: &'a Scope,
pub(super) mode: RobotsMode,
}