1use scraper::{ElementRef, Html};
7use std::collections::HashSet;
8use url::Url;
9
10use crate::selector::SELECTORS;
11use crate::types::{Link, LinkRel, LinkType, ParserConfig, ParserResult};
12
13#[derive(Debug, Clone, Default)]
19pub struct LinkStats {
20 pub total: usize,
22 pub internal: usize,
24 pub external: usize,
26 pub nofollow: usize,
28 pub sponsored: usize,
30 pub ugc: usize,
32 pub followable: usize,
34 pub unique_external_domains: usize,
36 pub external_domains: Vec<String>,
38}
39
40pub fn extract_links(document: &Html, config: &ParserConfig) -> ParserResult<Vec<Link>> {
49 let selector = &SELECTORS.a;
50 let mut links = Vec::new();
51 let mut seen = HashSet::new();
52
53 for element in document.select(selector) {
54 if let Some(link) = extract_link(&element, config.base_url.as_ref())
55 && seen.insert(link.href.clone()) {
56 links.push(link);
57 }
58 }
59
60 Ok(links)
61}
62
63pub fn extract_link(element: &ElementRef, base_url: Option<&Url>) -> Option<Link> {
68 let href = element.value().attr("href")?;
69 let href = href.trim();
70 if href.is_empty()
71 || href.starts_with('#')
72 || href.starts_with("javascript:")
73 || href.starts_with("mailto:")
74 || href.starts_with("tel:")
75 || href.starts_with("data:")
76 {
77 return None;
78 }
79
80 let text: String = element.text().collect::<Vec<_>>().join(" ").trim().to_string();
81
82 let resolved = resolve_url(href, base_url);
83 let normalized = resolved.as_deref().and_then(normalize_url);
84
85 let rel_attr = element.value().attr("rel").unwrap_or("");
86 let rel = parse_rel_attribute(rel_attr);
87 let is_nofollow = is_nofollow(&rel);
88
89 let link_type = match normalized.as_deref() {
90 Some(url) => determine_link_type(url, base_url),
91 None => LinkType::Unknown,
92 };
93
94 let title = element.value().attr("title").map(ToString::to_string);
95 let target = element.value().attr("target").map(ToString::to_string);
96 let hreflang = element.value().attr("hreflang").map(ToString::to_string);
97
98 Some(Link {
99 href: href.to_string(),
100 url: normalized,
101 text: if text.is_empty() { href.to_string() } else { text },
102 title,
103 rel,
104 link_type,
105 is_nofollow,
106 target,
107 hreflang,
108 })
109}
110
111pub fn resolve_url(href: &str, base_url: Option<&Url>) -> Option<String> {
119 let href = href.trim();
120 if href.is_empty() {
121 return None;
122 }
123
124 if href.starts_with("//") {
126 let scheme = base_url.map_or("https", url::Url::scheme);
127 return Some(format!("{scheme}:{href}"));
128 }
129
130 if href.starts_with("http://") || href.starts_with("https://") {
132 return Url::parse(href).ok().map(|u| u.to_string());
133 }
134
135 let base = base_url?;
137 base.join(href).ok().map(|u| u.to_string())
138}
139
140pub fn normalize_url(url: &str) -> Option<String> {
142 let mut parsed = Url::parse(url).ok()?;
143
144 if parsed.scheme() != "http" && parsed.scheme() != "https" {
146 return Some(url.to_string());
147 }
148
149 parsed.set_fragment(None);
151
152 match parsed.port() {
154 Some(80) if parsed.scheme() == "http" => {
155 let _ = parsed.set_port(None);
156 }
157 Some(443) if parsed.scheme() == "https" => {
158 let _ = parsed.set_port(None);
159 }
160 _ => {}
161 }
162
163 Some(parsed.to_string())
164}
165
166pub fn determine_link_type(url: &str, base_url: Option<&Url>) -> LinkType {
176 let parsed = match Url::parse(url) {
177 Ok(u) => u,
178 Err(_) => return LinkType::Unknown,
179 };
180
181 let base = match base_url {
182 Some(b) => b,
183 None => return LinkType::External,
184 };
185
186 let url_host = match parsed.host_str() {
187 Some(h) => h,
188 None => return LinkType::Internal,
189 };
190
191 let base_host = match base.host_str() {
192 Some(h) => h,
193 None => return LinkType::External,
194 };
195
196 if url_host == base_host {
197 return LinkType::Internal;
198 }
199
200 if let Some(rest) = url_host.strip_suffix(base_host)
202 && rest.ends_with('.')
203 {
204 return LinkType::Internal;
205 }
206
207 LinkType::External
208}
209
210pub fn parse_rel_attribute(rel: &str) -> Vec<LinkRel> {
216 if rel.is_empty() {
217 return Vec::new();
218 }
219
220 rel.split_whitespace()
221 .map(|token| match token.to_lowercase().as_str() {
222 "nofollow" => LinkRel::NoFollow,
223 "ugc" => LinkRel::Ugc,
224 "sponsored" => LinkRel::Sponsored,
225 "external" => LinkRel::External,
226 "noopener" => LinkRel::NoOpener,
227 "noreferrer" => LinkRel::NoReferrer,
228 "follow" => LinkRel::Follow,
229 _ => LinkRel::Other,
230 })
231 .collect()
232}
233
234pub fn is_nofollow(rel: &[LinkRel]) -> bool {
236 rel.iter().any(|r| matches!(r, LinkRel::NoFollow))
237}
238
239pub fn is_sponsored(rel: &[LinkRel]) -> bool {
241 rel.iter().any(|r| matches!(r, LinkRel::Sponsored))
242}
243
244pub fn is_ugc(rel: &[LinkRel]) -> bool {
246 rel.iter().any(|r| matches!(r, LinkRel::Ugc))
247}
248
249pub fn filter_internal_links(links: &[Link]) -> Vec<&Link> {
255 links.iter().filter(|l| l.link_type == LinkType::Internal).collect()
256}
257
258pub fn filter_external_links(links: &[Link]) -> Vec<&Link> {
260 links.iter().filter(|l| l.link_type == LinkType::External).collect()
261}
262
263pub fn filter_followable_links(links: &[Link]) -> Vec<&Link> {
265 links.iter().filter(|l| l.should_follow()).collect()
266}
267
268pub fn get_external_domains(links: &[Link]) -> Vec<String> {
272 let mut domains = HashSet::new();
273
274 for link in links {
275 if link.link_type != LinkType::External {
276 continue;
277 }
278 if let Some(ref url_str) = link.url
279 && let Ok(parsed) = Url::parse(url_str)
280 && let Some(host) = parsed.host_str() {
281 domains.insert(host.to_string());
282 }
283 }
284
285 let mut result: Vec<_> = domains.into_iter().collect();
286 result.sort();
287 result
288}
289
290pub fn calculate_link_stats(links: &[Link]) -> LinkStats {
296 let total = links.len();
297 let internal = links.iter().filter(|l| l.link_type == LinkType::Internal).count();
298 let external = links.iter().filter(|l| l.link_type == LinkType::External).count();
299
300 let nofollow = links.iter().filter(|l| l.is_nofollow).count();
301 let sponsored = links.iter().filter(|l| is_sponsored(&l.rel)).count();
302 let ugc = links.iter().filter(|l| is_ugc(&l.rel)).count();
303 let followable = links.iter().filter(|l| l.should_follow()).count();
304
305 let external_domains = get_external_domains(links);
306 let unique_external_domains = external_domains.len();
307
308 LinkStats {
309 total,
310 internal,
311 external,
312 nofollow,
313 sponsored,
314 ugc,
315 followable,
316 unique_external_domains,
317 external_domains,
318 }
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328
329 use scraper::Selector;
330
331 fn test_config(base_url: Option<&str>) -> ParserConfig {
333 ParserConfig {
334 base_url: base_url.and_then(|u| Url::parse(u).ok()),
335 ..ParserConfig::default()
336 }
337 }
338
339 fn extract_from_html(html: &str, base_url: Option<&str>) -> Vec<Link> {
341 let document = Html::parse_document(html);
342 let config = test_config(base_url);
343 extract_links(&document, &config).unwrap()
344 }
345
346 #[test]
347 fn test_extract_links_basic() {
348 let html = r#"<html><body><a href="https://example.com">Example</a></body></html>"#;
349 let links = extract_from_html(html, None);
350 assert_eq!(links.len(), 1);
351 assert_eq!(links[0].href, "https://example.com");
352 assert_eq!(links[0].text, "Example");
353 }
354
355 #[test]
356 fn test_extract_links_filters_anchors_and_javascript() {
357 let html = r##"<html><body>
358 <a href="#section1">锚点</a>
359 <a href="javascript:void(0)">脚本</a>
360 <a href="mailto:user at host">邮箱</a>
361 <a href="tel:+123456789">电话</a>
362 <a href="data:text/plain,hello">数据</a>
363 <a href="https://example.com/valid">有效</a>
364 </body></html>"##;
365 let links = extract_from_html(html, None);
366 assert_eq!(links.len(), 1);
367 assert_eq!(links[0].href, "https://example.com/valid");
368 }
369
370 #[test]
371 fn test_extract_links_deduplicates() {
372 let html = r#"<html><body>
373 <a href="https://example.com">A</a>
374 <a href="https://example.com">B</a>
375 </body></html>"#;
376 let links = extract_from_html(html, None);
377 assert_eq!(links.len(), 1);
378 }
379
380 #[test]
381 fn test_extract_links_resolves_relative_with_base() {
382 let html = r#"<html><body><a href="/page">相对链接</a></body></html>"#;
383 let links = extract_from_html(html, Some("https://example.com"));
384 assert_eq!(links.len(), 1);
385 assert_eq!(links[0].url.as_deref(), Some("https://example.com/page"));
386 assert_eq!(links[0].link_type, LinkType::Internal);
387 }
388
389 #[test]
390 fn test_extract_links_protocol_relative() {
391 let html = r#"<html><body><a href="//cdn.example.com/file.js">CDN</a></body></html>"#;
392 let links = extract_from_html(html, Some("https://example.com"));
393 assert_eq!(links.len(), 1);
394 assert_eq!(links[0].url.as_deref(), Some("https://cdn.example.com/file.js"));
395 }
396
397 #[test]
398 fn test_extract_link_returns_none_for_element_without_href() {
399 let html = Html::parse_document(r"<html><body><span>不是链接</span></body></html>");
400 let selector = Selector::parse("span").unwrap();
401 let element = html.select(&selector).next().unwrap();
402 let result = extract_link(&element, None);
403 assert!(result.is_none());
404 }
405
406 #[test]
407 fn test_resolve_url_absolute() {
408 let base = Url::parse("https://example.com").ok();
409 let result = resolve_url("https://other.com/path", base.as_ref());
410 assert_eq!(result.as_deref(), Some("https://other.com/path"));
411 }
412
413 #[test]
414 fn test_resolve_url_relative() {
415 let base = Url::parse("https://example.com/base/").ok();
416 let result = resolve_url("../page", base.as_ref());
417 assert_eq!(result.as_deref(), Some("https://example.com/page"));
418 }
419
420 #[test]
421 fn test_resolve_url_protocol_relative() {
422 let base = Url::parse("https://example.com").ok();
423 let result = resolve_url("//other.com/path", base.as_ref());
424 assert_eq!(result.as_deref(), Some("https://other.com/path"));
425 }
426
427 #[test]
428 fn test_resolve_url_empty_or_skip() {
429 assert!(resolve_url("", None).is_none());
430 assert!(resolve_url(" ", None).is_none());
431 }
432
433 #[test]
434 fn test_normalize_url_removes_fragment() {
435 let result = normalize_url("https://example.com/page#section");
436 assert_eq!(result.as_deref(), Some("https://example.com/page"));
437 }
438
439 #[test]
440 fn test_normalize_url_removes_default_port() {
441 let result = normalize_url("https://example.com:443/page");
442 assert_eq!(result.as_deref(), Some("https://example.com/page"));
443 }
444
445 #[test]
446 fn test_normalize_url_preserves_non_standard_port() {
447 let result = normalize_url("https://example.com:8080/page");
448 assert_eq!(result.as_deref(), Some("https://example.com:8080/page"));
449 }
450
451 #[test]
452 fn test_normalize_url_keeps_http_and_https() {
453 let result = normalize_url("ftp://example.com/file");
454 assert_eq!(result.as_deref(), Some("ftp://example.com/file"));
455 }
456
457 #[test]
458 fn test_determine_link_type_internal_exact() {
459 let base = Url::parse("https://example.com").ok();
460 let result = determine_link_type("https://example.com/page", base.as_ref());
461 assert_eq!(result, LinkType::Internal);
462 }
463
464 #[test]
465 fn test_determine_link_type_internal_subdomain() {
466 let base = Url::parse("https://example.com").ok();
467 let result = determine_link_type("https://blog.example.com/page", base.as_ref());
468 assert_eq!(result, LinkType::Internal);
469 }
470
471 #[test]
472 fn test_determine_link_type_external() {
473 let base = Url::parse("https://example.com").ok();
474 let result = determine_link_type("https://other.com/page", base.as_ref());
475 assert_eq!(result, LinkType::External);
476 }
477
478 #[test]
479 fn test_determine_link_type_no_base() {
480 let result = determine_link_type("https://example.com/page", None);
481 assert_eq!(result, LinkType::External);
482 }
483
484 #[test]
485 fn test_parse_rel_attribute_nofollow() {
486 let rel = parse_rel_attribute("nofollow");
487 assert!(is_nofollow(&rel));
488 assert!(!is_sponsored(&rel));
489 assert!(!is_ugc(&rel));
490 }
491
492 #[test]
493 fn test_parse_rel_attribute_multiple() {
494 let rel = parse_rel_attribute("nofollow ugc sponsored");
495 assert!(is_nofollow(&rel));
496 assert!(is_sponsored(&rel));
497 assert!(is_ugc(&rel));
498 }
499
500 #[test]
501 fn test_parse_rel_attribute_empty() {
502 let rel = parse_rel_attribute("");
503 assert!(rel.is_empty());
504 }
505
506 #[test]
507 fn test_parse_rel_attribute_case_insensitive() {
508 let rel = parse_rel_attribute("NoFollow UGC Sponsored");
509 assert!(is_nofollow(&rel));
510 assert!(is_sponsored(&rel));
511 assert!(is_ugc(&rel));
512 }
513
514 #[test]
515 fn test_filter_internal_links() {
516 let mut a = Link::new("https://example.com/internal", "内部");
517 a.link_type = LinkType::Internal;
518 let mut b = Link::new("https://external.com/external", "外部");
519 b.link_type = LinkType::External;
520 let links = vec![a, b];
521 let internal = filter_internal_links(&links);
522 assert_eq!(internal.len(), 1);
523 assert_eq!(internal[0].href, "https://example.com/internal");
524 }
525
526 #[test]
527 fn test_filter_external_links() {
528 let mut a = Link::new("https://example.com/internal", "内部");
529 a.link_type = LinkType::Internal;
530 let mut b = Link::new("https://external.com/external", "外部");
531 b.link_type = LinkType::External;
532 let links = vec![a, b];
533 let external = filter_external_links(&links);
534 assert_eq!(external.len(), 1);
535 assert_eq!(external[0].href, "https://external.com/external");
536 }
537
538 #[test]
539 fn test_filter_followable_links() {
540 let mut follow = Link::new("https://example.com/follow", "可跟随");
541 follow.link_type = LinkType::Internal;
542 let mut nofollow = Link::new("https://example.com/nofollow", "不可跟随");
543 nofollow.link_type = LinkType::Internal;
544 nofollow.is_nofollow = true;
545
546 let links = vec![follow, nofollow];
547 let followable = filter_followable_links(&links);
548 assert_eq!(followable.len(), 1);
549 assert_eq!(followable[0].href, "https://example.com/follow");
550 }
551
552 #[test]
553 fn test_get_external_domains() {
554 let mut a = Link::new("https://example.com/internal", "内部");
555 a.url = Some("https://example.com/internal".to_string());
556 a.link_type = LinkType::Internal;
557 let mut b = Link::new("https://external.com/page1", "外部1");
558 b.url = Some("https://external.com/page1".to_string());
559 b.link_type = LinkType::External;
560 let mut c = Link::new("https://external.com/page2", "外部2");
561 c.url = Some("https://external.com/page2".to_string());
562 c.link_type = LinkType::External;
563 let mut d = Link::new("https://other.org/page", "外部3");
564 d.url = Some("https://other.org/page".to_string());
565 d.link_type = LinkType::External;
566 let links = vec![a, b, c, d];
567 let domains = get_external_domains(&links);
568 assert_eq!(domains, vec!["external.com", "other.org"]);
569 }
570
571 #[test]
572 fn test_calculate_link_stats() {
573 let mut ext = Link::new("https://external.com/page", "外部链接");
574 ext.url = Some("https://external.com/page".to_string());
575 ext.link_type = LinkType::External;
576
577 let mut int = Link::new("https://example.com/page", "内部链接");
578 int.url = Some("https://example.com/page".to_string());
579 int.link_type = LinkType::Internal;
580 int.is_nofollow = true;
581
582 let links = vec![ext, int];
583
584 let stats = calculate_link_stats(&links);
585 assert_eq!(stats.total, 2);
586 assert_eq!(stats.internal, 1);
587 assert_eq!(stats.external, 1);
588 assert_eq!(stats.nofollow, 1);
589 assert_eq!(stats.followable, 1);
590 assert_eq!(stats.unique_external_domains, 1);
591 }
592
593 #[test]
594 fn test_extract_link_with_title_and_target() {
595 let html = r#"<html><body><a href="https://example.com" title="示例" target="_blank" rel="nofollow">链接</a></body></html>"#;
596 let document = Html::parse_document(html);
597 let selector = Selector::parse("a").unwrap();
598 let element = document.select(&selector).next().unwrap();
599 let link = extract_link(&element, None).unwrap();
600
601 assert_eq!(link.title.as_deref(), Some("示例"));
602 assert_eq!(link.target.as_deref(), Some("_blank"));
603 assert!(link.is_nofollow);
604 assert!(link.opens_new_tab());
605 }
606
607 #[test]
608 fn test_should_skip_link_with_empty_href() {
609 let html = r#"<html><body><a href="">空链接</a></body></html>"#;
610 let links = extract_from_html(html, None);
611 assert!(links.is_empty());
612 }
613
614 #[test]
615 fn test_only_whitespace_href_skipped() {
616 let html = r#"<html><body><a href=" ">空白链接</a></body></html>"#;
617 let links = extract_from_html(html, None);
618 assert!(links.is_empty());
619 }
620
621 #[test]
622 fn test_same_domain_different_scheme_is_external() {
623 let base = Url::parse("https://example.com").ok();
625 let result = determine_link_type("http://example.com/page", base.as_ref());
626 assert_eq!(result, LinkType::Internal);
627 }
628
629 #[test]
630 fn test_extract_links_multiple_selectors() {
631 let html = r#"<html><body>
632 <a href="https://a.com">A</a>
633 <a href="https://b.com">B</a>
634 <a href="https://c.com">C</a>
635 </body></html>"#;
636 let links = extract_from_html(html, None);
637 assert_eq!(links.len(), 3);
638 }
639
640 #[test]
641 fn test_normalize_url_noop() {
642 let result = normalize_url("https://example.com/path/to/page");
643 assert_eq!(result.as_deref(), Some("https://example.com/path/to/page"));
644 }
645
646 #[test]
647 fn test_calculate_link_stats_empty() {
648 let stats = calculate_link_stats(&[]);
649 assert_eq!(stats.total, 0);
650 assert_eq!(stats.internal, 0);
651 assert_eq!(stats.external, 0);
652 assert_eq!(stats.followable, 0);
653 }
654}