use url::Url;
pub const FURNITURE_FACTOR: f32 = 0.2;
pub const DATED_FACTOR: f32 = 0.1;
pub const HUB_FACTOR: f32 = 0.5;
const WORDS: &[&str] = &[
"news",
"changelog",
"release-notes",
"whats-new",
"newsroom",
"press",
"media",
"blog",
"blogs",
"stories",
"story",
"events",
"event",
"calendar",
"announcements",
"magazine",
"podcast",
"podcasts",
"video",
"videos",
"gallery",
"photos",
"spotlight",
"features",
"tag",
"tags",
"category",
"categories",
"topic",
"topics",
"search",
"archive",
"archives",
"feed",
"rss",
"sitemap",
"site-map",
"about",
"history",
"leadership",
"governance",
"president",
"chancellor",
"provost",
"board",
"trustees",
"mission",
"vision",
"values",
"strategy",
"brand",
"awards",
"honors",
"honours",
"rankings",
"accolades",
"facts",
"figures",
"privacy",
"accessibility",
"legal",
"terms",
"cookies",
"cookie",
"disclaimer",
"imprint",
"impressum",
"data-protection",
"datenschutz",
"legal-notice",
"cookies",
"footer",
"copyright",
"contact",
"contacts",
"directions",
"maps",
"map",
"directory",
"login",
"logout",
"signin",
"sign-in",
"account",
"password",
"sso",
"auth",
"idp",
"cas",
"my",
"portal",
"intranet",
"webmail",
"mail",
"outlook",
"canvas",
"moodle",
"blackboard",
"cart",
"checkout",
"shop",
"store",
"careers",
"jobs",
"vacancies",
"giving",
"donate",
"donations",
"alumni",
"exhibition",
"exhibitions",
"exhibit",
"museum",
"anniversary",
"memorial",
"people",
"staff",
"profiles",
"profile",
"obituaries",
".well-known",
"wp-json",
"wp-admin",
"cgi-bin",
"bin",
"api",
];
fn is_word(s: &str) -> bool {
let s = s.to_ascii_lowercase();
WORDS.contains(&s.as_str())
}
fn is_date_path(segs: &[&str]) -> bool {
let year = |s: &str| {
s.len() == 4 && s.starts_with(['1', '2']) && s.bytes().all(|b| b.is_ascii_digit())
};
let month = |s: &str| (1..=2).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit());
segs.windows(2).any(|w| year(w[0]) && month(w[1])) || segs.iter().any(|s| year(s))
}
pub fn is_dated_url(u: &Url) -> bool {
let segs: Vec<&str> = u.path().split('/').filter(|s| !s.is_empty()).collect();
is_date_path(&segs)
}
const SECTION_MAX_WORDS: usize = 2;
pub fn is_furniture_url(u: &Url, wanted: &dyn Fn(&str) -> bool) -> bool {
let segs: Vec<&str> = u.path().split('/').filter(|s| !s.is_empty()).collect();
if is_date_path(&segs) {
return true;
}
let wanted_section = segs.iter().any(|seg| {
let words: Vec<&str> = seg.split(['-', '_']).filter(|w| !w.is_empty()).collect();
words.len() <= SECTION_MAX_WORDS && words.iter().any(|w| !is_word(w) && wanted(w))
});
if wanted_section {
return false;
}
let hit = |w: &str| is_word(w) && !wanted(w);
let host = u.host_str().unwrap_or_default();
let site = pagesift_content::site_key(host);
let labels = host
.strip_suffix(site.as_str())
.unwrap_or_default()
.split('.')
.filter(|l| !l.is_empty() && *l != "www");
labels.into_iter().any(hit)
|| segs.iter().any(|seg| {
let stem = seg.rsplit_once('.').map_or(*seg, |(stem, _)| stem);
hit(seg) || hit(stem) || stem.split(['-', '_']).any(hit)
})
}
pub fn is_furniture_anchor(anchor: &str, wanted: &dyn Fn(&str) -> bool) -> bool {
let words: Vec<&str> = anchor
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
!words.is_empty() && words.len() <= 3 && words.iter().any(|w| is_word(w) && !wanted(w))
}
pub fn is_hub(links: usize, words: usize) -> bool {
links >= 8 && links * 2 > words
}
#[cfg(test)]
mod tests {
use super::*;
fn no(_: &str) -> bool {
false
}
fn u(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn news_about_legal_and_dated_paths_are_furniture() {
for p in [
"https://e.edu/news/2026/05/story/",
"https://e.edu/about/history/",
"https://e.edu/privacy/",
"https://e.edu/news-and-events/x",
"https://e.edu/blog/2024/",
"https://e.edu/.well-known/openid",
"https://e.edu/exhibition/item-3/",
"https://e.edu/tag/students/",
] {
assert!(is_furniture_url(&u(p), &no), "{p}");
}
for p in [
"https://mail.e.edu/",
"https://my.e.edu/portal/",
"https://auth.e.edu/login",
] {
assert!(is_furniture_url(&u(p), &no), "{p}");
}
for p in [
"https://e.edu/",
"https://e.edu/admissions/international/",
"https://e.edu/study/degrees/",
"https://iss.e.edu/visa/",
"https://e.edu/prospective/tuition/",
] {
assert!(!is_furniture_url(&u(p), &no), "{p}");
}
for p in ["https://international.e.edu/", "https://www.e.edu/study/"] {
assert!(!is_furniture_url(&u(p), &no), "{p}");
}
let housing = |w: &str| w.eq_ignore_ascii_case("housing");
assert!(!is_furniture_url(
&u("https://e.edu/about/housing/"),
&housing
));
assert!(!is_furniture_url(
&u("https://e.edu/about/student-housing/"),
&housing
));
assert!(is_furniture_url(
&u("https://e.edu/news/housing-crisis-hits-the-city/"),
&housing
));
assert!(is_furniture_url(
&u("https://e.edu/news/2026/05/housing/"),
&housing
));
}
#[test]
fn short_anchors_with_a_furniture_word_are_furniture() {
assert!(is_furniture_anchor("News", &no));
assert!(is_furniture_anchor("Contact us", &no));
assert!(is_furniture_anchor("Site map", &no));
assert!(!is_furniture_anchor("International students", &no));
assert!(!is_furniture_anchor(
"International students celebrate at the spring festival",
&no
));
assert!(!is_furniture_anchor("", &no));
}
#[test]
fn hubs_have_more_links_than_words() {
assert!(is_hub(40, 60));
assert!(!is_hub(5, 3));
assert!(!is_hub(10, 400));
}
}