1use scraper::{Html, ElementRef, Node, Selector};
7use serde::{Deserialize, Serialize};
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10
11use crate::selector::SELECTORS;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ContentFingerprint {
22 pub text_hash: u64,
24 pub structure_hash: u64,
26 pub main_content_hash: u64,
28 pub element_count: usize,
30 pub text_node_count: usize,
32 pub text_length: usize,
34 pub main_content_length: usize,
36}
37
38impl ContentFingerprint {
39 pub fn has_changed(&self, other: &ContentFingerprint) -> bool {
43 self.text_hash != other.text_hash || self.structure_hash != other.structure_hash
44 }
45
46 pub fn has_minor_changes(&self, other: &ContentFingerprint) -> bool {
48 self.text_hash != other.text_hash && self.structure_hash == other.structure_hash
49 }
50
51 pub fn has_structural_changes(&self, other: &ContentFingerprint) -> bool {
53 self.structure_hash != other.structure_hash
54 }
55
56 pub fn similarity(&self, other: &ContentFingerprint) -> f64 {
60 let mut matching = 0.0;
61 let mut total = 0.0;
62
63 if self.text_hash == other.text_hash {
65 matching += 40.0;
66 } else {
67 let min_len = self.text_length.min(other.text_length) as f64;
69 let max_len = self.text_length.max(other.text_length) as f64;
70 if max_len > 0.0 {
71 matching += 40.0 * (min_len / max_len);
72 }
73 }
74 total += 40.0;
75
76 if self.structure_hash == other.structure_hash {
78 matching += 40.0;
79 } else if self.element_count > 0 || other.element_count > 0 {
80 let min_el = self.element_count.min(other.element_count) as f64;
82 let max_el = self.element_count.max(other.element_count) as f64;
83 if max_el > 0.0 {
84 matching += 40.0 * (min_el / max_el);
85 }
86 }
87 total += 40.0;
88
89 if self.main_content_hash == other.main_content_hash {
91 matching += 20.0;
92 } else {
93 let min_mc = self.main_content_length.min(other.main_content_length) as f64;
94 let max_mc = self.main_content_length.max(other.main_content_length) as f64;
95 if max_mc > 0.0 {
96 matching += 20.0 * (min_mc / max_mc);
97 }
98 }
99 total += 20.0;
100
101 if total == 0.0 {
102 return 1.0;
103 }
104 matching / total
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AmpInfo {
117 pub is_amp: bool,
119 pub amp_url: Option<String>,
121 pub canonical_url: Option<String>,
123 pub has_amp_runtime: bool,
125 pub amp_components: Vec<String>,
127 pub amp_version: Option<String>,
129}
130
131impl AmpInfo {
132 pub fn has_amp_version(&self) -> bool {
134 self.amp_version.is_some()
135 }
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct CacheHints {
147 pub max_age: Option<u64>,
149 pub stale_while_revalidate: Option<u64>,
151 pub etag: Option<String>,
153 pub last_modified: Option<String>,
155 pub should_cache: bool,
157 pub cache_key: Option<String>,
159}
160
161pub fn generate_fingerprint(document: &Html) -> ContentFingerprint {
167 let text = extract_text_only(document);
168 let structure_hash = extract_structure(document);
169 let main_content = extract_main_content(document);
170 let text_hash = hash_string(&text);
171 let main_content_hash = hash_string(&main_content);
172 let element_count = count_elements(document);
173 let text_node_count = count_text_nodes(document);
174
175 ContentFingerprint {
176 text_hash,
177 structure_hash,
178 main_content_hash,
179 element_count,
180 text_node_count,
181 text_length: text.len(),
182 main_content_length: main_content.len(),
183 }
184}
185
186pub fn fingerprint_document(document: &Html) -> ContentFingerprint {
190 generate_fingerprint(document)
191}
192
193pub fn hash_string(s: &str) -> u64 {
195 let mut hasher = DefaultHasher::new();
196 s.hash(&mut hasher);
197 hasher.finish()
198}
199
200pub fn extract_main_content(document: &Html) -> String {
205 let content_selectors = ["article", "main", "[role=main]", ".content", ".post-content"];
206
207 for sel_str in &content_selectors {
208 if let Ok(sel) = Selector::parse(sel_str) {
209 if let Some(el) = document.select(&sel).next() {
210 let text = collect_text_recursive(el);
211 if text.len() > 50 {
212 return text;
213 }
214 }
215 }
216 }
217
218 if let Ok(body_sel) = Selector::parse("body") {
220 if let Some(body) = document.select(&body_sel).next() {
221 return collect_text_recursive(body);
222 }
223 }
224
225 collect_text_recursive(document.root_element())
227}
228
229pub fn extract_text_only(document: &Html) -> String {
231 collect_text_recursive(document.root_element())
232}
233
234pub fn extract_structure(document: &Html) -> u64 {
236 let mut structure = String::new();
237 let root = document.root_element();
238 extract_structure_recursive(&root, &mut structure, 0);
239 hash_string(&structure)
240}
241
242fn extract_structure_recursive(element: &ElementRef, output: &mut String, depth: usize) {
244 let tag_name = element.value().name.local.as_ref();
245 output.push_str(&format!("<{}", tag_name));
246
247 if let Some(id) = element.value().attr("id") {
249 output.push_str(&format!("#{}", id));
250 }
251
252 let tag = tag_name.to_lowercase();
253 if tag == "a" {
254 if let Some(href) = element.value().attr("href") {
255 if href.starts_with("http") {
256 output.push_str("[ext]");
257 } else {
258 output.push_str("[rel]");
259 }
260 } else {
261 output.push_str("[no]");
262 }
263 } else if tag == "img" {
264 output.push_str("[img]");
265 }
266
267 output.push_str(&format!(":{}", depth));
268
269 for child in element.children() {
270 if let Some(child_el) = ElementRef::wrap(child) {
271 extract_structure_recursive(&child_el, output, depth + 1);
272 }
273 }
274}
275
276pub fn count_elements(document: &Html) -> usize {
278 count_elements_recursive(&document.root_element())
279}
280
281fn count_elements_recursive(element: &ElementRef) -> usize {
282 let mut count = 1;
283 for child in element.children() {
284 if let Some(child_el) = ElementRef::wrap(child) {
285 count += count_elements_recursive(&child_el);
286 }
287 }
288 count
289}
290
291pub fn count_text_nodes(document: &Html) -> usize {
293 count_text_nodes_recursive(&document.root_element())
294}
295
296fn count_text_nodes_recursive(element: &ElementRef) -> usize {
297 let mut count = 0;
298 for child in element.children() {
299 match child.value() {
300 Node::Text(t) => {
301 let trimmed = t.text.trim();
302 if !trimmed.is_empty() {
303 count += 1;
304 }
305 }
306 Node::Element(_) => {
307 if let Some(child_el) = ElementRef::wrap(child) {
308 count += count_text_nodes_recursive(&child_el);
309 }
310 }
311 _ => {}
312 }
313 }
314 count
315}
316
317pub fn extract_amp_info(document: &Html) -> AmpInfo {
323 let is_amp = detect_is_amp_page(document);
324 let amp_url = extract_amp_link(document);
325 let canonical_url = extract_canonical_link(document);
326 let has_amp_runtime = detect_amp_runtime(document);
327 let amp_components = extract_amp_components(document);
328 let amp_version = detect_amp_version(document);
329
330 AmpInfo {
331 is_amp,
332 amp_url,
333 canonical_url,
334 has_amp_runtime,
335 amp_components,
336 amp_version,
337 }
338}
339
340pub fn detect_is_amp_page(document: &Html) -> bool {
344 if let Ok(html_sel) = Selector::parse("html") {
345 if let Some(html_el) = document.select(&html_sel).next() {
346 let el = html_el.value();
347 if el.attr("amp").is_some() || el.has_class("amp", scraper::CaseSensitivity::CaseSensitive)
348 {
349 return true;
350 }
351 if el.attr("\u{26A1}").is_some() {
353 return true;
354 }
355 }
356 }
357 false
358}
359
360pub fn extract_amp_link(document: &Html) -> Option<String> {
362 let sel_str = "link[rel=amphtml]";
363 if let Ok(sel) = Selector::parse(sel_str) {
364 if let Some(el) = document.select(&sel).next() {
365 return el.value().attr("href").map(|s| s.to_string());
366 }
367 }
368 None
369}
370
371pub fn extract_canonical_link(document: &Html) -> Option<String> {
373 if let Some(el) = document.select(&SELECTORS.link).find(|e| {
374 e.value().attr("rel").map_or(false, |r| {
375 r.to_lowercase() == "canonical"
376 })
377 }) {
378 return el.value().attr("href").map(|s| s.to_string());
379 }
380 None
381}
382
383pub fn detect_amp_runtime(document: &Html) -> bool {
385 let sel_str = "script[src*=ampproject]";
386 if let Ok(sel) = Selector::parse(sel_str) {
387 if document.select(&sel).any(|el| {
388 el.value().attr("src").map_or(false, |src| {
389 src.contains("cdn.ampproject.org")
390 })
391 }) {
392 return true;
393 }
394 }
395 false
396}
397
398pub fn extract_amp_components(document: &Html) -> Vec<String> {
400 let mut components = Vec::new();
401
402 if let Ok(sel) = Selector::parse("script[custom-element], script[custom-template]") {
404 for el in document.select(&sel) {
405 let name = el.value().attr("custom-element")
406 .or_else(|| el.value().attr("custom-template"))
407 .map(|s| s.to_string());
408 if let Some(n) = name {
409 if !components.contains(&n) {
410 components.push(n);
411 }
412 }
413 }
414 }
415
416 components
417}
418
419pub fn detect_amp_version(document: &Html) -> Option<String> {
423 let sel_str = "script[src*=cdn.ampproject]";
424 if let Ok(sel) = Selector::parse(sel_str) {
425 for el in document.select(&sel) {
426 if let Some(src) = el.value().attr("src") {
427 if let Some(ver_start) = src.rfind('/') {
429 let candidate = &src[ver_start + 1..];
430 if candidate == "v0.js" || candidate.starts_with("v0/") {
431 let parts: Vec<&str> = src.split('/').collect();
433 for part in &parts {
434 if part.starts_with("v0") && part.len() > 2 {
435 let ver = part[2..].trim_start_matches('-');
436 if !ver.is_empty() && ver != ".js" {
437 return Some(ver.to_string());
438 }
439 }
440 }
441 return None;
442 }
443 }
444 }
445 }
446 }
447 None
448}
449
450pub fn resolve_url(base_url: &str, relative: &str) -> Option<String> {
454 let base = url::Url::parse(base_url).ok()?;
455 base.join(relative).ok().map(|u| u.to_string())
456}
457
458pub fn extract_cache_hints(document: &Html) -> CacheHints {
466 let mut hints = CacheHints::default();
467 let mut no_cache_set = false;
468
469 if let Ok(sel) = Selector::parse("meta[http-equiv]") {
471 for el in document.select(&sel) {
472 let equiv = el.value().attr("http-equiv")
473 .map(|s| s.to_lowercase());
474 let content = el.value().attr("content");
475
476 match (equiv.as_deref(), content) {
477 (Some("cache-control"), Some(val)) => {
478 if is_no_cache_value(val) {
479 hints.should_cache = false;
480 no_cache_set = true;
481 } else {
482 parse_cache_control_with_default(val, &mut hints);
483 }
484 }
485 (Some("expires"), Some(val)) => {
486 if hints.max_age.is_none() {
487 if let Some(expires) = parse_http_date(val) {
489 let now = std::time::SystemTime::now()
490 .duration_since(std::time::UNIX_EPOCH)
491 .unwrap_or_default()
492 .as_secs();
493 if expires > now {
494 hints.max_age = Some(expires - now);
495 }
496 }
497 }
498 }
499 (Some("pragma"), Some(val)) => {
500 if val.to_lowercase().contains("no-cache") {
501 hints.should_cache = false;
502 no_cache_set = true;
503 }
504 }
505 (Some("last-modified"), Some(val)) => {
506 hints.last_modified = Some(val.to_string());
507 }
508 (Some("etag"), Some(val)) => {
509 hints.etag = Some(val.to_string());
510 }
511 _ => {}
512 }
513 }
514 }
515
516 if !no_cache_set {
518 hints.should_cache = true;
519 if hints.max_age.is_none() {
520 hints.max_age = Some(300);
521 }
522 }
523
524 if let Some(title_el) = document.select(&SELECTORS.title).next() {
526 let title_text = title_el.text().collect::<String>();
527 let clean_title = title_text.trim();
528 if !clean_title.is_empty() {
529 hints.cache_key = Some(format!("page-{}", hash_string(clean_title)));
530 }
531 }
532
533 hints
534}
535
536fn is_no_cache_value(val: &str) -> bool {
537 val.split(',')
538 .map(|d| d.trim().to_lowercase())
539 .any(|d| d == "no-cache" || d == "no-store" || d == "private")
540}
541
542fn parse_cache_control_with_default(val: &str, hints: &mut CacheHints) {
543 for directive in val.split(',') {
544 let d = directive.trim().to_lowercase();
545 if let Some(max_age) = d.strip_prefix("max-age=") {
546 hints.max_age = max_age.trim().parse().ok();
547 } else if let Some(stale) = d.strip_prefix("stale-while-revalidate=") {
548 hints.stale_while_revalidate = stale.trim().parse().ok();
549 }
550 }
551}
552
553fn parse_http_date(date_str: &str) -> Option<u64> {
554 let _ = date_str;
558 None
559}
560
561pub fn has_content_changed(old_doc: &Html, new_doc: &Html) -> bool {
569 let old_fp = generate_fingerprint(old_doc);
570 let new_fp = generate_fingerprint(new_doc);
571 old_fp.has_changed(&new_fp)
572}
573
574pub fn content_similarity(doc_a: &Html, doc_b: &Html) -> f64 {
578 let fp_a = generate_fingerprint(doc_a);
579 let fp_b = generate_fingerprint(doc_b);
580 fp_a.similarity(&fp_b)
581}
582
583pub fn is_amp_page(html_content: &str) -> bool {
585 let doc = Html::parse_document(html_content);
586 let info = extract_amp_info(&doc);
587 info.is_amp
588}
589
590pub fn get_amp_url(html_content: &str) -> Option<String> {
595 let doc = Html::parse_document(html_content);
596 let info = extract_amp_info(&doc);
597 if info.is_amp {
598 info.canonical_url
599 } else {
600 info.amp_url
601 }
602}
603
604pub fn quick_html_hash(html_content: &str) -> u64 {
608 let doc = Html::parse_document(html_content);
609 let text = extract_text_only(&doc);
610 hash_string(&text)
611}
612
613pub fn quick_hash(text: &str) -> u64 {
617 hash_string(text)
618}
619
620fn collect_text_recursive(element: ElementRef) -> String {
625 let mut result = String::new();
626 for child in element.children() {
627 match child.value() {
628 Node::Text(text) => {
629 let trimmed = text.text.trim();
630 if !trimmed.is_empty() {
631 if !result.is_empty() && !result.ends_with(' ') {
632 result.push(' ');
633 }
634 result.push_str(trimmed);
635 }
636 }
637 Node::Element(el) => {
638 let tag = el.name.local.as_ref();
639 if matches!(tag, "script" | "style" | "noscript") {
641 continue;
642 }
643 if let Some(child_el) = ElementRef::wrap(child) {
644 let child_text = collect_text_recursive(child_el);
645 if !child_text.is_empty() {
646 if is_block_element(tag) && !result.is_empty() && !result.ends_with('\n') {
647 result.push('\n');
648 }
649 result.push_str(&child_text);
650 if is_block_element(tag) {
651 result.push('\n');
652 }
653 }
654 }
655 }
656 _ => {}
657 }
658 }
659 result
660}
661
662fn is_block_element(tag: &str) -> bool {
663 matches!(
664 tag,
665 "p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
666 | "ul" | "ol" | "li" | "blockquote" | "pre" | "table"
667 | "section" | "article" | "header" | "footer" | "nav"
668 | "aside" | "br" | "hr" | "figure" | "figcaption"
669 | "dl" | "dt" | "dd" | "tr" | "form"
670 )
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 fn create_doc(html: &str) -> Html {
680 Html::parse_document(html)
681 }
682
683 #[test]
686 fn test_fingerprint_identical_html() {
687 let html = "<html><body><p>Hello World</p></body></html>";
688 let doc1 = create_doc(html);
689 let doc2 = create_doc(html);
690 let fp1 = generate_fingerprint(&doc1);
691 let fp2 = generate_fingerprint(&doc2);
692 assert_eq!(fp1.text_hash, fp2.text_hash);
693 assert_eq!(fp1.structure_hash, fp2.structure_hash);
694 assert!(!fp1.has_changed(&fp2));
695 assert!((fp1.similarity(&fp2) - 1.0).abs() < 1e-6);
696 }
697
698 #[test]
699 fn test_fingerprint_different_content() {
700 let doc1 = create_doc("<html><body><p>Original content</p></body></html>");
701 let doc2 = create_doc("<html><body><p>Modified content here</p></body></html>");
702 let fp1 = generate_fingerprint(&doc1);
703 let fp2 = generate_fingerprint(&doc2);
704 assert!(fp1.has_changed(&fp2));
705 assert!(fp1.similarity(&fp2) < 1.0);
706 }
707
708 #[test]
709 fn test_structural_change_detection() {
710 let doc1 = create_doc("<html><body><p>Text</p></body></html>");
711 let doc2 = create_doc("<html><body><div><p>Text</p></div></body></html>");
712 let fp1 = generate_fingerprint(&doc1);
713 let fp2 = generate_fingerprint(&doc2);
714 assert!(fp1.has_structural_changes(&fp2));
715 assert!(!fp1.has_minor_changes(&fp2));
716 }
717
718 #[test]
719 fn test_minor_change_detection() {
720 let doc1 = create_doc("<html><body><p>Hello World</p></body></html>");
721 let doc2 = create_doc("<html><body><p>Hello World!</p></body></html>");
722 let fp1 = generate_fingerprint(&doc1);
723 let fp2 = generate_fingerprint(&doc2);
724 assert!(fp1.has_minor_changes(&fp2));
725 assert!(!fp1.has_structural_changes(&fp2));
726 }
727
728 #[test]
729 fn test_fingerprint_empty_document() {
730 let doc = create_doc("");
731 let fp = generate_fingerprint(&doc);
732 assert_eq!(fp.text_length, 0);
733 assert!(fp.element_count > 0);
734 assert_eq!(fp.text_node_count, 0);
735 }
736
737 #[test]
738 fn test_hash_string_consistency() {
739 let h1 = hash_string("hello");
740 let h2 = hash_string("hello");
741 let h3 = hash_string("world");
742 assert_eq!(h1, h2);
743 assert_ne!(h1, h3);
744 }
745
746 #[test]
747 fn test_fingerprint_document_alias() {
748 let doc = create_doc("<html><body><p>Test</p></body></html>");
749 let fp1 = fingerprint_document(&doc);
750 let fp2 = generate_fingerprint(&doc);
751 assert_eq!(fp1.text_hash, fp2.text_hash);
752 assert_eq!(fp1.structure_hash, fp2.structure_hash);
753 }
754
755 #[test]
758 fn test_extract_main_content_finds_article() {
759 let html = r#"
760 <html><body>
761 <nav>Nav content</nav>
762 <article><p>This is the main article content with enough text to exceed the minimum threshold of fifty characters so that it gets selected</p></article>
763 <footer>Footer</footer>
764 </body></html>
765 "#;
766 let doc = create_doc(html);
767 let content = extract_main_content(&doc);
768 assert!(content.contains("main article content"));
769 assert!(!content.contains("Nav"));
770 }
771
772 #[test]
773 fn test_extract_text_only_returns_all_text() {
774 let html = "<html><body><h1>Title</h1><p>Paragraph</p></body></html>";
775 let doc = create_doc(html);
776 let text = extract_text_only(&doc);
777 assert!(text.contains("Title"));
778 assert!(text.contains("Paragraph"));
779 }
780
781 #[test]
782 fn test_count_elements_basic() {
783 let html = "<html><body><div><p>1</p><p>2</p></div></body></html>";
784 let doc = create_doc(html);
785 let count = count_elements(&doc);
786 assert_eq!(count, 6);
787 }
788
789 #[test]
790 fn test_count_text_nodes_basic() {
791 let html = "<html><body><p>Hello</p><p>World</p></body></html>";
792 let doc = create_doc(html);
793 let count = count_text_nodes(&doc);
794 assert_eq!(count, 2);
795 }
796
797 #[test]
798 fn test_extract_main_content_fallback_to_body() {
799 let html = "<html><body><p>Just a simple body text</p></body></html>";
800 let doc = create_doc(html);
801 let content = extract_main_content(&doc);
802 assert!(content.contains("simple body text"));
803 }
804
805 #[test]
808 fn test_detect_amp_page_with_amp_attribute() {
809 let html = r#"<html amp><head><title>AMP Page</title></head><body>Hello</body></html>"#;
810 let doc = create_doc(html);
811 assert!(detect_is_amp_page(&doc));
812 }
813
814 #[test]
815 fn test_detect_amp_page_with_emoji_attribute() {
816 let html = "<html \u{26A1}><head><title>AMP</title></head><body>Hi</body></html>";
817 let doc = create_doc(html);
818 assert!(detect_is_amp_page(&doc));
819 }
820
821 #[test]
822 fn test_detect_non_amp_page() {
823 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
824 let doc = create_doc(html);
825 assert!(!detect_is_amp_page(&doc));
826 }
827
828 #[test]
829 fn test_extract_amp_link() {
830 let html = r#"
831 <head>
832 <link rel="amphtml" href="https://example.com/amp/page">
833 </head>
834 "#;
835 let doc = create_doc(html);
836 assert_eq!(
837 extract_amp_link(&doc),
838 Some("https://example.com/amp/page".to_string())
839 );
840 }
841
842 #[test]
843 fn test_extract_canonical_link() {
844 let html = r#"
845 <head>
846 <link rel="canonical" href="https://example.com/page">
847 </head>
848 "#;
849 let doc = create_doc(html);
850 assert_eq!(
851 extract_canonical_link(&doc),
852 Some("https://example.com/page".to_string())
853 );
854 }
855
856 #[test]
857 fn test_detect_amp_runtime_present() {
858 let html = r#"
859 <head>
860 <script async src="https://cdn.ampproject.org/v0.js"></script>
861 </head>
862 "#;
863 let doc = create_doc(html);
864 assert!(detect_amp_runtime(&doc));
865 }
866
867 #[test]
868 fn test_detect_amp_runtime_absent() {
869 let html = "<html><head></head><body>No AMP</body></html>";
870 let doc = create_doc(html);
871 assert!(!detect_amp_runtime(&doc));
872 }
873
874 #[test]
875 fn test_extract_amp_components() {
876 let html = r#"
877 <head>
878 <script custom-element="amp-carousel" src="https://cdn.ampproject.org/v0/amp-carousel-0.1.js"></script>
879 <script custom-element="amp-lightbox" src="https://cdn.ampproject.org/v0/amp-lightbox-0.1.js"></script>
880 <script custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"></script>
881 </head>
882 "#;
883 let doc = create_doc(html);
884 let components = extract_amp_components(&doc);
885 assert!(components.contains(&"amp-carousel".to_string()));
886 assert!(components.contains(&"amp-lightbox".to_string()));
887 assert!(components.contains(&"amp-mustache".to_string()));
888 assert_eq!(components.len(), 3);
889 }
890
891 #[test]
892 fn test_extract_amp_info_complete() {
893 let html = r#"
894 <html amp>
895 <head>
896 <title>AMP Test</title>
897 <link rel="canonical" href="https://example.com/page">
898 <script async src="https://cdn.ampproject.org/v0.js"></script>
899 <script custom-element="amp-carousel" src="https://cdn.ampproject.org/v0/amp-carousel-0.1.js"></script>
900 </head>
901 <body><p>AMP content</p></body>
902 </html>
903 "#;
904 let doc = create_doc(html);
905 let info = extract_amp_info(&doc);
906 assert!(info.is_amp);
907 assert!(info.has_amp_runtime);
908 assert_eq!(info.canonical_url, Some("https://example.com/page".to_string()));
909 }
911
912 #[test]
913 fn test_amp_info_no_amp_version() {
914 let html = r#"<html><head><title>Normal</title></head><body>No AMP</body></html>"#;
915 let doc = create_doc(html);
916 let info = extract_amp_info(&doc);
917 assert!(!info.is_amp);
918 assert!(!info.has_amp_version());
919 }
920
921 #[test]
922 fn test_is_amp_page_convenience() {
923 let amp_html = r#"<html amp><head><title>A</title></head><body>B</body></html>"#;
924 assert!(is_amp_page(amp_html));
925 let normal_html = "<html><head><title>A</title></head><body>B</body></html>";
926 assert!(!is_amp_page(normal_html));
927 }
928
929 #[test]
930 fn test_get_amp_url_from_non_amp() {
931 let html = r#"
932 <head>
933 <link rel="amphtml" href="https://example.com/amp">
934 <link rel="canonical" href="https://example.com/page">
935 </head>
936 "#;
937 assert_eq!(get_amp_url(html), Some("https://example.com/amp".to_string()));
938 }
939
940 #[test]
941 fn test_get_amp_url_from_amp() {
942 let html = r#"
943 <html amp>
944 <head>
945 <link rel="canonical" href="https://example.com/page">
946 </head>
947 <body>AMP</body>
948 </html>
949 "#;
950 assert_eq!(get_amp_url(html), Some("https://example.com/page".to_string()));
951 }
952
953 #[test]
956 fn test_resolve_absolute_url() {
957 assert_eq!(
958 resolve_url("https://example.com", "/path/to/page"),
959 Some("https://example.com/path/to/page".to_string())
960 );
961 }
962
963 #[test]
964 fn test_resolve_relative_url() {
965 assert_eq!(
966 resolve_url("https://example.com/base/", "relative"),
967 Some("https://example.com/base/relative".to_string())
968 );
969 }
970
971 #[test]
972 fn test_resolve_invalid_base_url() {
973 assert_eq!(resolve_url("not-a-url", "/path"), None);
974 }
975
976 #[test]
979 fn test_cache_hints_from_meta_cache_control() {
980 let html = r#"
981 <head>
982 <meta http-equiv="Cache-Control" content="max-age=3600">
983 </head>
984 "#;
985 let doc = create_doc(html);
986 let hints = extract_cache_hints(&doc);
987 assert!(hints.should_cache);
988 assert_eq!(hints.max_age, Some(3600));
989 }
990
991 #[test]
992 fn test_cache_hints_no_cache_directive() {
993 let html = r#"
994 <head>
995 <meta http-equiv="Cache-Control" content="no-cache, no-store">
996 </head>
997 "#;
998 let doc = create_doc(html);
999 let hints = extract_cache_hints(&doc);
1000 assert!(!hints.should_cache);
1001 }
1002
1003 #[test]
1004 fn test_cache_hints_default_values() {
1005 let html = "<html><head><title>Test</title></head><body>Hello</body></html>";
1006 let doc = create_doc(html);
1007 let hints = extract_cache_hints(&doc);
1008 assert!(hints.should_cache);
1009 assert_eq!(hints.max_age, Some(300));
1010 }
1011
1012 #[test]
1013 fn test_cache_hints_with_etag() {
1014 let html = r#"
1015 <head>
1016 <meta http-equiv="ETag" content="abc123">
1017 <meta http-equiv="Cache-Control" content="max-age=7200">
1018 </head>
1019 "#;
1020 let doc = create_doc(html);
1021 let hints = extract_cache_hints(&doc);
1022 assert_eq!(hints.etag, Some("abc123".to_string()));
1023 assert_eq!(hints.max_age, Some(7200));
1024 }
1025
1026 #[test]
1027 fn test_cache_hints_cache_key_generated() {
1028 let html = "<html><head><title>My Article Title</title></head><body>Content</body></html>";
1029 let doc = create_doc(html);
1030 let hints = extract_cache_hints(&doc);
1031 assert!(hints.cache_key.is_some());
1032 let key = hints.cache_key.unwrap();
1033 assert!(key.starts_with("page-"));
1034 }
1035
1036 #[test]
1037 fn test_cache_hints_with_stale_revalidate() {
1038 let html = r#"
1039 <head>
1040 <meta http-equiv="Cache-Control" content="max-age=3600, stale-while-revalidate=86400">
1041 </head>
1042 "#;
1043 let doc = create_doc(html);
1044 let hints = extract_cache_hints(&doc);
1045 assert_eq!(hints.stale_while_revalidate, Some(86400));
1046 }
1047
1048 #[test]
1051 fn test_has_content_changed_true() {
1052 let old = create_doc("<html><body><p>Old content</p></body></html>");
1053 let new = create_doc("<html><body><p>New content</p></body></html>");
1054 assert!(has_content_changed(&old, &new));
1055 }
1056
1057 #[test]
1058 fn test_has_content_changed_false() {
1059 let old = create_doc("<html><body><p>Same content</p></body></html>");
1060 let new = create_doc("<html><body><p>Same content</p></body></html>");
1061 assert!(!has_content_changed(&old, &new));
1062 }
1063
1064 #[test]
1065 fn test_content_similarity_identical() {
1066 let doc = create_doc("<html><body><p>Hello World</p></body></html>");
1067 let sim = content_similarity(&doc, &doc);
1068 assert!((sim - 1.0).abs() < 1e-6);
1069 }
1070
1071 #[test]
1072 fn test_content_similarity_completely_different() {
1073 let doc1 = create_doc("<html><body><p>AAAA</p></body></html>");
1074 let doc2 = create_doc("<html><body><div><span>BBBB</span></div></body></html>");
1075 let sim = content_similarity(&doc1, &doc2);
1076 assert!(sim < 1.0);
1077 assert!(sim >= 0.0);
1078 }
1079
1080 #[test]
1081 fn test_quick_hash_consistency() {
1082 let h1 = quick_hash("test data");
1083 let h2 = quick_hash("test data");
1084 let h3 = quick_hash("different data");
1085 assert_eq!(h1, h2);
1086 assert_ne!(h1, h3);
1087 }
1088
1089 #[test]
1090 fn test_quick_hash_empty() {
1091 let h = quick_hash("");
1092 assert_ne!(h, 0);
1093 }
1094
1095 #[test]
1098 fn test_document_with_only_whitespace() {
1099 let doc = create_doc("<html><body> \n </body></html>");
1100 let fp = generate_fingerprint(&doc);
1101 assert_eq!(fp.text_length, 0);
1102 assert_eq!(fp.text_node_count, 0);
1103 }
1104
1105 #[test]
1106 fn test_document_with_script_style_excluded() {
1107 let html = r#"
1108 <html><body>
1109 <p>Visible text</p>
1110 <script>var x = 1;</script>
1111 <style>.hidden {}</style>
1112 </body></html>
1113 "#;
1114 let doc = create_doc(html);
1115 let text = extract_text_only(&doc);
1116 assert!(text.contains("Visible text"));
1117 assert!(!text.contains("var x"));
1118 assert!(!text.contains(".hidden"));
1119 }
1120
1121 #[test]
1122 fn test_fingerprint_similarity_same_structure_different_text() {
1123 let doc1 = create_doc("<html><body><p>Short</p></body></html>");
1124 let doc2 = create_doc("<html><body><p>Longer text here</p></body></html>");
1125 let fp1 = generate_fingerprint(&doc1);
1126 let fp2 = generate_fingerprint(&doc2);
1127 assert!(fp1.similarity(&fp2) > 0.39);
1129 }
1130
1131 #[test]
1132 fn test_nested_element_count() {
1133 let html = r#"
1134 <html><body>
1135 <div>
1136 <ul>
1137 <li>1</li>
1138 <li>2</li>
1139 <li>3</li>
1140 </ul>
1141 </div>
1142 </body></html>
1143 "#;
1144 let doc = create_doc(html);
1145 let count = count_elements(&doc);
1146 assert_eq!(count, 8);
1147 }
1148
1149 #[test]
1150 fn test_extract_amp_link_no_amp() {
1151 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
1152 let doc = create_doc(html);
1153 assert_eq!(extract_amp_link(&doc), None);
1154 }
1155
1156 #[test]
1157 fn test_extract_canonical_link_missing() {
1158 let html = "<html><head><title>No canonical</title></head><body>Hi</body></html>";
1159 let doc = create_doc(html);
1160 assert_eq!(extract_canonical_link(&doc), None);
1161 }
1162
1163 #[test]
1164 fn test_amp_components_empty_when_no_amp() {
1165 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
1166 let doc = create_doc(html);
1167 let components = extract_amp_components(&doc);
1168 assert!(components.is_empty());
1169 }
1170
1171 #[test]
1172 fn test_cache_hints_last_modified() {
1173 let html = r#"
1174 <head>
1175 <meta http-equiv="Last-Modified" content="Mon, 01 Jan 2024 00:00:00 GMT">
1176 </head>
1177 "#;
1178 let doc = create_doc(html);
1179 let hints = extract_cache_hints(&doc);
1180 assert!(hints.last_modified.is_some());
1181 }
1182
1183 #[test]
1184 fn test_cache_hints_pragma_no_cache() {
1185 let html = r#"
1186 <head>
1187 <meta http-equiv="Pragma" content="no-cache">
1188 </head>
1189 "#;
1190 let doc = create_doc(html);
1191 let hints = extract_cache_hints(&doc);
1192 assert!(!hints.should_cache);
1193 }
1194}