use super::*;
fn scope_path(start: &Url) -> Option<String> {
let path = start.path();
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
if path.ends_with('/') {
return Some(trimmed.to_string());
}
match trimmed.rsplit_once('/') {
Some(("", last)) => {
(!last.contains('.')).then(|| trimmed.to_string())
}
None => Some(trimmed.to_string()),
Some((parent, last)) if last.contains('.') => Some(parent.to_string()),
Some(_) => Some(trimmed.to_string()),
}
}
pub struct Scope {
host: String,
allow_subdomains: bool,
allow_external: bool,
path_prefix: Option<String>,
ancestors: Vec<String>,
widened: OnceLock<String>,
include: Vec<Regex>,
exclude: Vec<Regex>,
}
impl Scope {
pub(super) fn new(
start: &Url,
allow_subdomains: bool,
allow_external: bool,
mode: CrawlScope,
include: &[String],
exclude: &[String],
) -> Result<Self, ApiError> {
let compile = |ps: &[String]| -> Result<Vec<Regex>, ApiError> {
ps.iter()
.map(|p| {
Regex::new(p)
.map_err(|e| ApiError::invalid(format!("invalid path pattern {p:?}: {e}")))
})
.collect()
};
let narrow = (mode != CrawlScope::Domain)
.then(|| scope_path(start))
.flatten()
.filter(|p| !p.is_empty());
let mut ancestors = Vec::new();
if let Some(mut dir) = narrow.as_deref().filter(|_| seed_names_page(start)) {
while let Some((up, _)) = dir.rsplit_once('/').filter(|(up, _)| !up.is_empty()) {
ancestors.push(up.to_string());
dir = up;
}
}
let (path_prefix, ancestors) = match mode {
CrawlScope::Domain => (None, Vec::new()),
CrawlScope::Exact => (narrow, Vec::new()),
CrawlScope::Auto => (narrow, ancestors),
CrawlScope::Parent if !ancestors.is_empty() => (
ancestors.into_iter().find(|dir| !is_date_dir(dir)),
Vec::new(),
),
CrawlScope::Parent => (narrow, Vec::new()),
};
Ok(Self {
host: start
.host_str()
.unwrap_or_default()
.trim_start_matches("www.")
.to_ascii_lowercase(),
allow_subdomains,
allow_external,
path_prefix,
ancestors,
widened: OnceLock::new(),
include: compile(include)?,
exclude: compile(exclude)?,
})
}
pub fn allows(&self, u: &Url) -> bool {
if !matches!(u.scheme(), "http" | "https") {
return false;
}
let host = u.host_str().unwrap_or_default().to_ascii_lowercase();
let bare = host.trim_start_matches("www.");
let same = bare == self.host;
let sub = pagesift_content::site_key(bare) == pagesift_content::site_key(&self.host);
if !(same || (self.allow_subdomains && sub) || self.allow_external) {
return false;
}
let path = u.path();
if same
&& let Some(prefix) = self.widened.get().or(self.path_prefix.as_ref())
&& !under(path, prefix)
{
return false;
}
if !self.include.is_empty() && !self.include.iter().any(|r| r.is_match(path)) {
return false;
}
!self.exclude.iter().any(|r| r.is_match(path))
}
pub(super) fn can_widen(&self) -> bool {
!self.ancestors.is_empty() && self.widened.get().is_none()
}
pub(super) fn may_widen_to(&self, u: &Url) -> bool {
let (Some(narrow), Some(top)) = (&self.path_prefix, self.ancestors.last()) else {
return false;
};
if self.widened.get().is_some() || self.allows(u) {
return false;
}
let host = u.host_str().unwrap_or_default();
let path = u.path();
host.trim_start_matches("www.")
.eq_ignore_ascii_case(&self.host)
&& under(path, top)
&& !under(path, narrow)
&& (self.include.is_empty() || self.include.iter().any(|r| r.is_match(path)))
&& !self.exclude.iter().any(|r| r.is_match(path))
}
pub(super) fn widen_from_seed<'a>(
&self,
seed: &Url,
markdown: &str,
links: impl IntoIterator<Item = &'a Url>,
) -> Option<String> {
let Some(narrow) = &self.path_prefix else {
return None;
};
if self.ancestors.is_empty() || prose_chars(markdown) < WIDEN_MIN_PROSE {
return None;
}
let seed_path = seed.path().trim_end_matches('/');
let mut children: HashSet<&str> = HashSet::new();
let mut outside: HashSet<&str> = HashSet::new();
for u in links {
let host = u.host_str().unwrap_or_default();
if !host
.trim_start_matches("www.")
.eq_ignore_ascii_case(&self.host)
{
continue;
}
let path = u.path().trim_end_matches('/');
if path == seed_path {
continue;
}
if under(path, narrow) {
children.insert(path);
} else {
outside.insert(path);
}
}
if children.len() >= WIDEN_MAX_CHILDREN {
return None;
}
let (dir, siblings) = self.ancestors.iter().find_map(|dir| {
let n = outside
.iter()
.filter(|p| under(p, dir) && **p != dir.as_str() && !is_index_of(p, dir))
.count();
(n >= WIDEN_MIN_SIBLINGS).then_some((dir, n))
})?;
self.widened.set(dir.clone()).ok()?;
Some(format!(
"crawl_scope: widened path scope from {narrow}/ to {dir}/ (start page links {} page(s) below it and {siblings} sibling page(s)); use --scope exact to keep it narrow",
children.len(),
))
}
pub(super) fn restore_widened<'a>(&self, enqueued: impl IntoIterator<Item = &'a Url>) {
let outside: Vec<&Url> = enqueued
.into_iter()
.filter(|u| self.may_widen_to(u))
.collect();
if outside.is_empty() {
return;
}
if let Some(dir) = self
.ancestors
.iter()
.find(|dir| outside.iter().all(|u| under(u.path(), dir)))
{
let _ = self.widened.set(dir.clone());
}
}
}
const WIDEN_MAX_CHILDREN: usize = 3;
const WIDEN_MIN_SIBLINGS: usize = 3;
const WIDEN_MIN_PROSE: usize = 200;
pub(super) fn prose_chars(md: &str) -> usize {
let b = md.as_bytes();
let (mut i, mut n) = (0, 0);
while i < md.len() {
if b[i] == b'[' || (b[i] == b'!' && b.get(i + 1) == Some(&b'[')) {
let open = if b[i] == b'!' { i + 1 } else { i };
if let Some(close) = md[open..].find("](").map(|k| open + k)
&& let Some(end) = md[close + 2..].find(')').map(|k| close + 2 + k)
&& !md[open..close].contains('\n')
{
i = end + 1;
continue;
}
}
let c = md[i..].chars().next().unwrap_or(' ');
n += usize::from(c.is_alphanumeric());
i += c.len_utf8();
}
n
}
fn under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn is_date_dir(dir: &str) -> bool {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let last = dir.rsplit('/').next().unwrap_or_default();
(last.bytes().all(|b| b.is_ascii_digit()) && matches!(last.len(), 1 | 2 | 4))
|| MONTHS.contains(&last.to_ascii_lowercase().as_str())
}
fn is_index_of(path: &str, dir: &str) -> bool {
path.strip_prefix(dir)
.and_then(|rest| rest.strip_prefix('/'))
.is_some_and(|name| name.starts_with("index.") || name.starts_with("default."))
}
fn seed_names_page(start: &Url) -> bool {
start
.path()
.trim_end_matches('/')
.rsplit('/')
.next()
.is_some_and(|last| !last.is_empty() && !last.contains('.'))
}