use std::collections::HashMap;
use fancy_regex::Regex;
use crate::result::status::Status;
use crate::result::visited_url::{SOURCE_INIT_URL, SOURCE_SITEMAP, VisitedUrl};
use crate::types::ContentTypeId;
#[derive(Debug, Clone)]
pub struct RankedPage {
pub uq_id: String,
pub url: String,
pub score: f64,
}
pub struct Selection {
pub selected: Vec<RankedPage>,
pub total_candidates_before_cap: usize,
pub total_html_pages: usize,
pub excluded_by_mask: usize,
}
pub fn select_pages(status: &Status, include: &[String], exclude: &[String], max_pages: usize) -> Selection {
let visited = status.get_visited_urls();
let include_res = compile(include, "include");
let exclude_res = compile(exclude, "exclude");
let html_pages: Vec<&VisitedUrl> = visited
.iter()
.filter(|u| u.status_code == 200 && u.is_allowed_for_crawling && u.content_type == ContentTypeId::Html)
.collect();
let total_html_pages = html_pages.len();
let mut excluded_by_mask = 0usize;
let candidates: Vec<&VisitedUrl> = html_pages
.into_iter()
.filter(|u| {
if !include_res.is_empty() && !include_res.iter().any(|re| re.is_match(&u.url).unwrap_or(false)) {
excluded_by_mask += 1;
return false;
}
if exclude_res.iter().any(|re| re.is_match(&u.url).unwrap_or(true)) {
excluded_by_mask += 1;
return false;
}
true
})
.collect();
let total_candidates_before_cap = candidates.len();
let init_uq = visited
.iter()
.find(|u| u.source_attr == SOURCE_INIT_URL)
.map(|u| u.uq_id.clone());
let depths = compute_depths(&visited, init_uq.as_deref());
let mut fanout: HashMap<String, u32> = HashMap::new();
for u in &visited {
*fanout.entry(u.source_uq_id.clone()).or_insert(0) += 1;
}
let mut ranked: Vec<RankedPage> = candidates
.iter()
.map(|u| {
let score = score_page(u, init_uq.as_deref(), &depths, &fanout);
RankedPage {
uq_id: u.uq_id.clone(),
url: u.url.clone(),
score,
}
})
.collect();
ranked.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
ranked.truncate(max_pages);
Selection {
selected: ranked,
total_candidates_before_cap,
total_html_pages,
excluded_by_mask,
}
}
fn compile(patterns: &[String], kind: &str) -> Vec<Regex> {
patterns
.iter()
.filter_map(|p| match Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
eprintln!(
"{}",
crate::utils::get_color_text(
&format!(
"AI --ai-{} pattern '{}' could not be compiled and was IGNORED: {}",
kind, p, e
),
"yellow",
true,
)
);
None
}
})
.collect()
}
fn compute_depths(visited: &[VisitedUrl], init_uq: Option<&str>) -> HashMap<String, u32> {
let mut children: HashMap<String, Vec<String>> = HashMap::new();
for u in visited {
children
.entry(u.source_uq_id.clone())
.or_default()
.push(u.uq_id.clone());
}
let mut depths: HashMap<String, u32> = HashMap::new();
if let Some(root) = init_uq {
let mut queue = std::collections::VecDeque::new();
depths.insert(root.to_string(), 0);
queue.push_back(root.to_string());
while let Some(node) = queue.pop_front() {
let d = *depths.get(&node).unwrap_or(&0);
if let Some(kids) = children.get(&node) {
for kid in kids {
if !depths.contains_key(kid) {
depths.insert(kid.clone(), d + 1);
queue.push_back(kid.clone());
}
}
}
}
}
depths
}
fn score_page(
u: &VisitedUrl,
init_uq: Option<&str>,
depths: &HashMap<String, u32>,
fanout: &HashMap<String, u32>,
) -> f64 {
let depth = *depths.get(&u.uq_id).unwrap_or(&99);
let homepage_linked = Some(u.uq_id.as_str()) == init_uq || Some(u.source_uq_id.as_str()) == init_uq || depth <= 1;
let homepage_score = if homepage_linked { 40.0 } else { 0.0 };
let depth_score = 40.0 / (1.0 + depth as f64);
let fo = *fanout.get(&u.uq_id).unwrap_or(&0) as f64;
let fanout_score = (5.0 * (1.0 + fo).log2()).min(25.0);
let sitemap_score = if u.source_attr == SOURCE_SITEMAP { 15.0 } else { 0.0 };
let segments = url::Url::parse(&u.url)
.ok()
.map(|p| p.path().trim_matches('/').split('/').filter(|s| !s.is_empty()).count())
.unwrap_or(3) as f64;
let shallow_score = (10.0 - 2.0 * segments).max(0.0);
homepage_score + depth_score + fanout_score + sitemap_score + shallow_score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_accepts_pcre_lookahead_like_the_cli() {
let res = compile(&[r"^(?!.*/press/).*$".to_string()], "exclude");
assert_eq!(
res.len(),
1,
"PCRE lookahead pattern must compile with the same engine as the CLI"
);
let re = &res[0];
assert!(re.is_match("https://x/products/").unwrap()); assert!(!re.is_match("https://x/press/release").unwrap()); }
#[test]
fn compile_warns_but_drops_truly_invalid_pattern() {
let res = compile(&["(unclosed".to_string()], "include");
assert!(res.is_empty());
}
}