use scraper::{Html, Selector};
use tracing::debug;
use url::Url;
pub fn extract_links(html: &str, base_url: &str) -> Result<Vec<String>, crate::domain::CrawlError> {
debug!("Extracting links from HTML (base_url={})", base_url);
let document = Html::parse_document(html);
let selector = Selector::parse("a[href]").map_err(|e| {
crate::domain::CrawlError::Parse(format!("Failed to parse selector: {}", e))
})?;
let base =
Url::parse(base_url).map_err(|e| crate::domain::CrawlError::InvalidUrl(e.to_string()))?;
let mut links = Vec::with_capacity(32);
for element in document.select(&selector) {
if let Some(href) = element.value().attr("href") {
match base.join(href) {
Ok(absolute_url) => {
let normalized = normalize_url(absolute_url.as_str());
if !links.contains(&normalized) {
links.push(normalized);
}
}
Err(e) => {
debug!("Failed to resolve URL '{}': {}", href, e);
}
}
}
}
debug!("Extracted {} links from {}", links.len(), base_url);
Ok(links)
}
#[inline]
#[must_use]
pub fn is_internal_link(url: &str, domain: &str) -> bool {
extract_domain(url)
.map(|url_domain| url_domain == domain || url_domain.ends_with(&format!(".{}", domain)))
.unwrap_or(false)
}
#[inline]
#[must_use]
pub fn normalize_url(url: &str) -> String {
let without_fragment = url.split('#').next().unwrap_or(url);
if let Ok(parsed) = Url::parse(without_fragment) {
let mut normalized = parsed[..url::Position::AfterPath].to_string();
if without_fragment.ends_with('/') && !normalized.ends_with('/') {
normalized.push('/');
}
normalized
} else {
without_fragment.to_string()
}
}
#[inline]
#[must_use]
fn extract_domain(url: &str) -> Option<&str> {
url.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_links_basic() {
let html = r#"
<html>
<body>
<a href="/page1">Link 1</a>
<a href="/page2">Link 2</a>
<a href="https://other.com/external">External</a>
</body>
</html>
"#;
let links = extract_links(html, "https://example.com").unwrap();
assert!(links.contains(&"https://example.com/page1".to_string()));
assert!(links.contains(&"https://example.com/page2".to_string()));
assert!(links.contains(&"https://other.com/external".to_string()));
assert_eq!(links.len(), 3);
}
#[test]
fn test_extract_links_relative_paths() {
let html = r#"
<html>
<body>
<a href="../parent">Parent</a>
<a href="./current">Current</a>
<a href="sub/child">Child</a>
</body>
</html>
"#;
let links = extract_links(html, "https://example.com/dir/page").unwrap();
assert!(links.contains(&"https://example.com/parent".to_string()));
assert!(links.contains(&"https://example.com/dir/current".to_string()));
assert!(links.contains(&"https://example.com/dir/sub/child".to_string()));
}
#[test]
fn test_extract_links_no_duplicates() {
let html = r#"
<html>
<body>
<a href="/page">Link 1</a>
<a href="/page">Link 2</a>
<a href="/page">Link 3</a>
</body>
</html>
"#;
let links = extract_links(html, "https://example.com").unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0], "https://example.com/page");
}
#[test]
fn test_extract_links_empty() {
let html = r#"<html><body>No links here</body></html>"#;
let links = extract_links(html, "https://example.com").unwrap();
assert!(links.is_empty());
}
#[test]
fn test_extract_links_invalid_html() {
let html = "This is not HTML at all";
let links = extract_links(html, "https://example.com").unwrap();
assert!(links.is_empty());
}
#[test]
fn test_is_internal_link() {
assert!(is_internal_link("https://example.com/page", "example.com"));
assert!(is_internal_link(
"https://www.example.com/page",
"example.com"
));
assert!(is_internal_link(
"https://blog.example.com/post",
"example.com"
));
assert!(!is_internal_link("https://other.com/page", "example.com"));
assert!(!is_internal_link("invalid-url", "example.com"));
}
#[test]
fn test_normalize_url_remove_fragment() {
assert_eq!(
normalize_url("https://example.com/page#section"),
"https://example.com/page"
);
assert_eq!(
normalize_url("https://example.com/page#top"),
"https://example.com/page"
);
}
#[test]
fn test_normalize_url_preserve_trailing_slash() {
assert_eq!(
normalize_url("https://example.com/page/"),
"https://example.com/page/"
);
assert_eq!(
normalize_url("https://example.com/page/#section"),
"https://example.com/page/"
);
}
#[test]
fn test_normalize_url_no_change() {
assert_eq!(
normalize_url("https://example.com/page"),
"https://example.com/page"
);
}
#[test]
fn test_normalize_url_invalid() {
let result = normalize_url("not-a-valid-url");
assert_eq!(result, "not-a-valid-url");
}
}