use std::collections::{HashSet, VecDeque};
use codoseo_core::url::url_hash;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Queued {
pub url: Url,
pub depth: Option<u16>,
}
#[derive(Debug)]
pub struct Frontier {
seen: HashSet<u64>,
current: VecDeque<Queued>,
next: VecDeque<Queued>,
sitemap_only: VecDeque<Queued>,
admitted: u32,
cap: u32,
capped: bool,
}
impl Frontier {
pub fn new(max_pages: u32) -> Frontier {
Frontier {
seen: HashSet::new(),
current: VecDeque::new(),
next: VecDeque::new(),
sitemap_only: VecDeque::new(),
admitted: 0,
cap: max_pages,
capped: false,
}
}
pub fn seed(&mut self, url: Url) -> bool {
if !self.try_admit(&url) {
return false;
}
self.current.push_back(Queued {
url,
depth: Some(0),
});
true
}
pub fn push_link(&mut self, url: Url, from_depth: u16) -> bool {
if !self.try_admit(&url) {
return false;
}
self.next.push_back(Queued {
url,
depth: Some(from_depth.saturating_add(1)),
});
true
}
pub fn admit_redirect_target(&mut self, url: &Url) -> bool {
self.seen.insert(url_hash(url))
}
pub fn add_sitemap_urls(&mut self, urls: impl IntoIterator<Item = Url>) {
for url in urls {
if !self.try_admit(&url) {
if self.capped {
break;
}
continue;
}
self.sitemap_only.push_back(Queued { url, depth: None });
}
}
pub fn pop(&mut self) -> Option<Queued> {
self.current.pop_front()
}
pub fn advance(&mut self) -> bool {
if !self.next.is_empty() {
self.current.append(&mut self.next);
} else if !self.sitemap_only.is_empty() {
self.current.append(&mut self.sitemap_only);
}
!self.current.is_empty()
}
pub fn requeue_front(&mut self, q: Queued) {
self.current.push_front(q);
}
pub fn is_seen(&self, url: &Url) -> bool {
self.seen.contains(&url_hash(url))
}
pub fn capped(&self) -> bool {
self.capped
}
pub fn queued(&self) -> usize {
self.current.len() + self.next.len() + self.sitemap_only.len()
}
fn try_admit(&mut self, url: &Url) -> bool {
let hash = url_hash(url);
if self.seen.contains(&hash) {
return false;
}
if self.admitted >= self.cap {
self.capped = true;
return false;
}
self.seen.insert(hash);
self.admitted += 1;
true
}
}
#[cfg(test)]
mod tests {
use codoseo_core::url::normalize;
use super::*;
fn u(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn levels_dedup_sitemap_and_cap() {
let mut f = Frontier::new(5);
assert!(f.seed(u("https://e.com/")));
assert_eq!(f.pop().unwrap().depth, Some(0));
assert!(f.pop().is_none());
assert!(f.push_link(u("https://e.com/a"), 0));
assert!(!f.push_link(u("https://e.com/a"), 0)); assert!(f.pop().is_none()); assert!(f.advance());
assert_eq!(f.pop().unwrap().depth, Some(1));
f.add_sitemap_urls([u("https://e.com/a"), u("https://e.com/s")]); assert!(f.advance());
let s = f.pop().unwrap();
assert_eq!((s.url.path(), s.depth), ("/s", None));
assert!(!f.advance());
for i in 0..10 {
f.push_link(u(&format!("https://e.com/p{i}")), 1); }
assert!(f.capped());
assert_eq!(f.queued(), 2);
}
#[test]
fn trivial_variants_are_one_page() {
let base = u("https://e.com/");
let mut f = Frontier::new(10);
for href in ["/a", "/a?", "/a#x"] {
f.push_link(normalize(&base, href).unwrap(), 0);
}
assert_eq!(f.queued(), 1);
assert!(f.push_link(normalize(&base, "/A").unwrap(), 0));
}
#[test]
fn redirect_targets_are_marked_seen_past_the_cap_without_counting() {
let mut f = Frontier::new(2);
assert!(f.seed(u("https://e.com/")));
assert!(f.admit_redirect_target(&u("https://e.com/new")));
assert!(f.is_seen(&u("https://e.com/new")));
assert!(!f.admit_redirect_target(&u("https://e.com/new"))); assert!(!f.push_link(u("https://e.com/new"), 0));
assert_eq!(f.queued(), 1);
assert!(f.push_link(u("https://e.com/a"), 0));
assert!(!f.capped());
assert!(!f.push_link(u("https://e.com/x"), 0));
assert!(f.capped());
assert!(!f.is_seen(&u("https://e.com/x")));
assert!(f.admit_redirect_target(&u("https://e.com/t")));
assert_eq!(f.queued(), 2);
}
#[test]
fn seen_url_does_not_trip_the_cap() {
let mut f = Frontier::new(1);
assert!(f.seed(u("https://e.com/")));
assert!(!f.push_link(u("https://e.com/"), 0));
assert!(!f.capped());
}
#[test]
fn requeue_front_goes_first_and_is_not_counted_again() {
let mut f = Frontier::new(2);
f.seed(u("https://e.com/"));
f.push_link(u("https://e.com/a"), 0);
f.pop();
f.advance();
let a = f.pop().unwrap();
f.requeue_front(a.clone());
assert_eq!(f.queued(), 1);
assert_eq!(f.pop().unwrap(), a);
assert!(!f.push_link(u("https://e.com/b"), 1));
assert!(f.capped());
}
#[test]
fn sitemap_urls_wait_for_link_levels_and_respect_the_cap() {
let mut f = Frontier::new(3);
f.seed(u("https://e.com/"));
f.add_sitemap_urls((0..10).map(|i| u(&format!("https://e.com/s{i}"))));
assert!(f.capped());
assert_eq!(f.queued(), 3); assert_eq!(f.pop().unwrap().depth, Some(0));
assert!(f.pop().is_none()); assert!(f.advance());
assert_eq!(f.pop().unwrap().depth, None);
assert_eq!(f.pop().unwrap().depth, None);
assert!(!f.advance());
}
#[test]
fn link_levels_come_before_sitemap_urls() {
let mut f = Frontier::new(10);
f.seed(u("https://e.com/"));
f.add_sitemap_urls([u("https://e.com/s")]);
f.pop();
f.push_link(u("https://e.com/a"), 0);
assert!(f.advance());
assert_eq!(f.pop().unwrap().url.path(), "/a");
assert!(f.pop().is_none());
assert!(f.advance());
assert_eq!(f.pop().unwrap().url.path(), "/s");
}
}