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 && 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 if let Ok(body_sel) = Selector::parse("body")
219 && let Some(body) = document.select(&body_sel).next() {
220 return collect_text_recursive(body);
221 }
222
223 collect_text_recursive(document.root_element())
225}
226
227pub fn extract_text_only(document: &Html) -> String {
229 collect_text_recursive(document.root_element())
230}
231
232pub fn extract_structure(document: &Html) -> u64 {
234 let mut structure = String::new();
235 let root = document.root_element();
236 extract_structure_recursive(&root, &mut structure, 0);
237 hash_string(&structure)
238}
239
240fn extract_structure_recursive(element: &ElementRef, output: &mut String, depth: usize) {
242 let tag_name = element.value().name.local.as_ref();
243 output.push_str(&format!("<{tag_name}"));
244
245 if let Some(id) = element.value().attr("id") {
247 output.push_str(&format!("#{id}"));
248 }
249
250 let tag = tag_name.to_lowercase();
251 if tag == "a" {
252 if let Some(href) = element.value().attr("href") {
253 if href.starts_with("http") {
254 output.push_str("[ext]");
255 } else {
256 output.push_str("[rel]");
257 }
258 } else {
259 output.push_str("[no]");
260 }
261 } else if tag == "img" {
262 output.push_str("[img]");
263 }
264
265 output.push_str(&format!(":{depth}"));
266
267 for child in element.children() {
268 if let Some(child_el) = ElementRef::wrap(child) {
269 extract_structure_recursive(&child_el, output, depth + 1);
270 }
271 }
272}
273
274pub fn count_elements(document: &Html) -> usize {
276 count_elements_recursive(&document.root_element())
277}
278
279fn count_elements_recursive(element: &ElementRef) -> usize {
280 let mut count = 1;
281 for child in element.children() {
282 if let Some(child_el) = ElementRef::wrap(child) {
283 count += count_elements_recursive(&child_el);
284 }
285 }
286 count
287}
288
289pub fn count_text_nodes(document: &Html) -> usize {
291 count_text_nodes_recursive(&document.root_element())
292}
293
294fn count_text_nodes_recursive(element: &ElementRef) -> usize {
295 let mut count = 0;
296 for child in element.children() {
297 match child.value() {
298 Node::Text(t) => {
299 let trimmed = t.text.trim();
300 if !trimmed.is_empty() {
301 count += 1;
302 }
303 }
304 Node::Element(_) => {
305 if let Some(child_el) = ElementRef::wrap(child) {
306 count += count_text_nodes_recursive(&child_el);
307 }
308 }
309 _ => {}
310 }
311 }
312 count
313}
314
315pub fn extract_amp_info(document: &Html) -> AmpInfo {
321 let is_amp = detect_is_amp_page(document);
322 let amp_url = extract_amp_link(document);
323 let canonical_url = extract_canonical_link(document);
324 let has_amp_runtime = detect_amp_runtime(document);
325 let amp_components = extract_amp_components(document);
326 let amp_version = detect_amp_version(document);
327
328 AmpInfo {
329 is_amp,
330 amp_url,
331 canonical_url,
332 has_amp_runtime,
333 amp_components,
334 amp_version,
335 }
336}
337
338pub fn detect_is_amp_page(document: &Html) -> bool {
342 if let Ok(html_sel) = Selector::parse("html")
343 && let Some(html_el) = document.select(&html_sel).next() {
344 let el = html_el.value();
345 if el.attr("amp").is_some() || el.has_class("amp", scraper::CaseSensitivity::CaseSensitive)
346 {
347 return true;
348 }
349 if el.attr("\u{26A1}").is_some() {
351 return true;
352 }
353 }
354 false
355}
356
357pub fn extract_amp_link(document: &Html) -> Option<String> {
359 let sel_str = "link[rel=amphtml]";
360 if let Ok(sel) = Selector::parse(sel_str)
361 && let Some(el) = document.select(&sel).next() {
362 return el.value().attr("href").map(std::string::ToString::to_string);
363 }
364 None
365}
366
367pub fn extract_canonical_link(document: &Html) -> Option<String> {
369 if let Some(el) = document.select(&SELECTORS.link).find(|e| {
370 e.value().attr("rel").is_some_and(|r| {
371 r.to_lowercase() == "canonical"
372 })
373 }) {
374 return el.value().attr("href").map(std::string::ToString::to_string);
375 }
376 None
377}
378
379pub fn detect_amp_runtime(document: &Html) -> bool {
381 let sel_str = "script[src*=ampproject]";
382 if let Ok(sel) = Selector::parse(sel_str)
383 && document.select(&sel).any(|el| {
384 el.value().attr("src").is_some_and(|src| {
385 src.contains("cdn.ampproject.org")
386 })
387 }) {
388 return true;
389 }
390 false
391}
392
393pub fn extract_amp_components(document: &Html) -> Vec<String> {
395 let mut components = Vec::new();
396
397 if let Ok(sel) = Selector::parse("script[custom-element], script[custom-template]") {
399 for el in document.select(&sel) {
400 let name = el.value().attr("custom-element")
401 .or_else(|| el.value().attr("custom-template"))
402 .map(std::string::ToString::to_string);
403 if let Some(n) = name
404 && !components.contains(&n) {
405 components.push(n);
406 }
407 }
408 }
409
410 components
411}
412
413pub fn detect_amp_version(document: &Html) -> Option<String> {
417 let sel_str = "script[src*=cdn.ampproject]";
418 if let Ok(sel) = Selector::parse(sel_str) {
419 for el in document.select(&sel) {
420 if let Some(src) = el.value().attr("src") {
421 if let Some(ver_start) = src.rfind('/') {
423 let candidate = &src[ver_start + 1..];
424 if candidate == "v0.js" || candidate.starts_with("v0/") {
425 let parts: Vec<&str> = src.split('/').collect();
427 for part in &parts {
428 if part.starts_with("v0") && part.len() > 2 {
429 let ver = part[2..].trim_start_matches('-');
430 if !ver.is_empty() && ver != ".js" {
431 return Some(ver.to_string());
432 }
433 }
434 }
435 return None;
436 }
437 }
438 }
439 }
440 }
441 None
442}
443
444pub fn resolve_url(base_url: &str, relative: &str) -> Option<String> {
448 let base = url::Url::parse(base_url).ok()?;
449 base.join(relative).ok().map(|u| u.to_string())
450}
451
452pub fn extract_cache_hints(document: &Html) -> CacheHints {
460 let mut hints = CacheHints::default();
461 let mut no_cache_set = false;
462
463 if let Ok(sel) = Selector::parse("meta[http-equiv]") {
465 for el in document.select(&sel) {
466 let equiv = el.value().attr("http-equiv")
467 .map(str::to_lowercase);
468 let content = el.value().attr("content");
469
470 match (equiv.as_deref(), content) {
471 (Some("cache-control"), Some(val)) => {
472 if is_no_cache_value(val) {
473 hints.should_cache = false;
474 no_cache_set = true;
475 } else {
476 parse_cache_control_with_default(val, &mut hints);
477 }
478 }
479 (Some("expires"), Some(val)) => {
480 if hints.max_age.is_none() {
481 if let Some(expires) = parse_http_date(val) {
483 let now = std::time::SystemTime::now()
484 .duration_since(std::time::UNIX_EPOCH)
485 .unwrap_or_default()
486 .as_secs();
487 if expires > now {
488 hints.max_age = Some(expires - now);
489 }
490 }
491 }
492 }
493 (Some("pragma"), Some(val)) => {
494 if val.to_lowercase().contains("no-cache") {
495 hints.should_cache = false;
496 no_cache_set = true;
497 }
498 }
499 (Some("last-modified"), Some(val)) => {
500 hints.last_modified = Some(val.to_string());
501 }
502 (Some("etag"), Some(val)) => {
503 hints.etag = Some(val.to_string());
504 }
505 _ => {}
506 }
507 }
508 }
509
510 if !no_cache_set {
512 hints.should_cache = true;
513 if hints.max_age.is_none() {
514 hints.max_age = Some(300);
515 }
516 }
517
518 if let Some(title_el) = document.select(&SELECTORS.title).next() {
520 let title_text = title_el.text().collect::<String>();
521 let clean_title = title_text.trim();
522 if !clean_title.is_empty() {
523 hints.cache_key = Some(format!("page-{}", hash_string(clean_title)));
524 }
525 }
526
527 hints
528}
529
530fn is_no_cache_value(val: &str) -> bool {
531 val.split(',')
532 .map(|d| d.trim().to_lowercase())
533 .any(|d| d == "no-cache" || d == "no-store" || d == "private")
534}
535
536fn parse_cache_control_with_default(val: &str, hints: &mut CacheHints) {
537 for directive in val.split(',') {
538 let d = directive.trim().to_lowercase();
539 if let Some(max_age) = d.strip_prefix("max-age=") {
540 hints.max_age = max_age.trim().parse().ok();
541 } else if let Some(stale) = d.strip_prefix("stale-while-revalidate=") {
542 hints.stale_while_revalidate = stale.trim().parse().ok();
543 }
544 }
545}
546
547fn parse_http_date(date_str: &str) -> Option<u64> {
548 let _ = date_str;
552 None
553}
554
555pub fn has_content_changed(old_doc: &Html, new_doc: &Html) -> bool {
563 let old_fp = generate_fingerprint(old_doc);
564 let new_fp = generate_fingerprint(new_doc);
565 old_fp.has_changed(&new_fp)
566}
567
568pub fn content_similarity(doc_a: &Html, doc_b: &Html) -> f64 {
572 let fp_a = generate_fingerprint(doc_a);
573 let fp_b = generate_fingerprint(doc_b);
574 fp_a.similarity(&fp_b)
575}
576
577pub fn is_amp_page(html_content: &str) -> bool {
579 let doc = Html::parse_document(html_content);
580 let info = extract_amp_info(&doc);
581 info.is_amp
582}
583
584pub fn get_amp_url(html_content: &str) -> Option<String> {
589 let doc = Html::parse_document(html_content);
590 let info = extract_amp_info(&doc);
591 if info.is_amp {
592 info.canonical_url
593 } else {
594 info.amp_url
595 }
596}
597
598pub fn quick_html_hash(html_content: &str) -> u64 {
602 let doc = Html::parse_document(html_content);
603 let text = extract_text_only(&doc);
604 hash_string(&text)
605}
606
607pub fn quick_hash(text: &str) -> u64 {
611 hash_string(text)
612}
613
614fn collect_text_recursive(element: ElementRef) -> String {
619 let mut result = String::new();
620 for child in element.children() {
621 match child.value() {
622 Node::Text(text) => {
623 let trimmed = text.text.trim();
624 if !trimmed.is_empty() {
625 if !result.is_empty() && !result.ends_with(' ') {
626 result.push(' ');
627 }
628 result.push_str(trimmed);
629 }
630 }
631 Node::Element(el) => {
632 let tag = el.name.local.as_ref();
633 if matches!(tag, "script" | "style" | "noscript") {
635 continue;
636 }
637 if let Some(child_el) = ElementRef::wrap(child) {
638 let child_text = collect_text_recursive(child_el);
639 if !child_text.is_empty() {
640 if is_block_element(tag) && !result.is_empty() && !result.ends_with('\n') {
641 result.push('\n');
642 }
643 result.push_str(&child_text);
644 if is_block_element(tag) {
645 result.push('\n');
646 }
647 }
648 }
649 }
650 _ => {}
651 }
652 }
653 result
654}
655
656fn is_block_element(tag: &str) -> bool {
657 matches!(
658 tag,
659 "p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
660 | "ul" | "ol" | "li" | "blockquote" | "pre" | "table"
661 | "section" | "article" | "header" | "footer" | "nav"
662 | "aside" | "br" | "hr" | "figure" | "figcaption"
663 | "dl" | "dt" | "dd" | "tr" | "form"
664 )
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn create_doc(html: &str) -> Html {
674 Html::parse_document(html)
675 }
676
677 #[test]
680 fn test_fingerprint_identical_html() {
681 let html = "<html><body><p>Hello World</p></body></html>";
682 let doc1 = create_doc(html);
683 let doc2 = create_doc(html);
684 let fp1 = generate_fingerprint(&doc1);
685 let fp2 = generate_fingerprint(&doc2);
686 assert_eq!(fp1.text_hash, fp2.text_hash);
687 assert_eq!(fp1.structure_hash, fp2.structure_hash);
688 assert!(!fp1.has_changed(&fp2));
689 assert!((fp1.similarity(&fp2) - 1.0).abs() < 1e-6);
690 }
691
692 #[test]
693 fn test_fingerprint_different_content() {
694 let doc1 = create_doc("<html><body><p>Original content</p></body></html>");
695 let doc2 = create_doc("<html><body><p>Modified content here</p></body></html>");
696 let fp1 = generate_fingerprint(&doc1);
697 let fp2 = generate_fingerprint(&doc2);
698 assert!(fp1.has_changed(&fp2));
699 assert!(fp1.similarity(&fp2) < 1.0);
700 }
701
702 #[test]
703 fn test_structural_change_detection() {
704 let doc1 = create_doc("<html><body><p>Text</p></body></html>");
705 let doc2 = create_doc("<html><body><div><p>Text</p></div></body></html>");
706 let fp1 = generate_fingerprint(&doc1);
707 let fp2 = generate_fingerprint(&doc2);
708 assert!(fp1.has_structural_changes(&fp2));
709 assert!(!fp1.has_minor_changes(&fp2));
710 }
711
712 #[test]
713 fn test_minor_change_detection() {
714 let doc1 = create_doc("<html><body><p>Hello World</p></body></html>");
715 let doc2 = create_doc("<html><body><p>Hello World!</p></body></html>");
716 let fp1 = generate_fingerprint(&doc1);
717 let fp2 = generate_fingerprint(&doc2);
718 assert!(fp1.has_minor_changes(&fp2));
719 assert!(!fp1.has_structural_changes(&fp2));
720 }
721
722 #[test]
723 fn test_fingerprint_empty_document() {
724 let doc = create_doc("");
725 let fp = generate_fingerprint(&doc);
726 assert_eq!(fp.text_length, 0);
727 assert!(fp.element_count > 0);
728 assert_eq!(fp.text_node_count, 0);
729 }
730
731 #[test]
732 fn test_hash_string_consistency() {
733 let h1 = hash_string("hello");
734 let h2 = hash_string("hello");
735 let h3 = hash_string("world");
736 assert_eq!(h1, h2);
737 assert_ne!(h1, h3);
738 }
739
740 #[test]
741 fn test_fingerprint_document_alias() {
742 let doc = create_doc("<html><body><p>Test</p></body></html>");
743 let fp1 = fingerprint_document(&doc);
744 let fp2 = generate_fingerprint(&doc);
745 assert_eq!(fp1.text_hash, fp2.text_hash);
746 assert_eq!(fp1.structure_hash, fp2.structure_hash);
747 }
748
749 #[test]
752 fn test_extract_main_content_finds_article() {
753 let html = r"
754 <html><body>
755 <nav>Nav content</nav>
756 <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>
757 <footer>Footer</footer>
758 </body></html>
759 ";
760 let doc = create_doc(html);
761 let content = extract_main_content(&doc);
762 assert!(content.contains("main article content"));
763 assert!(!content.contains("Nav"));
764 }
765
766 #[test]
767 fn test_extract_text_only_returns_all_text() {
768 let html = "<html><body><h1>Title</h1><p>Paragraph</p></body></html>";
769 let doc = create_doc(html);
770 let text = extract_text_only(&doc);
771 assert!(text.contains("Title"));
772 assert!(text.contains("Paragraph"));
773 }
774
775 #[test]
776 fn test_count_elements_basic() {
777 let html = "<html><body><div><p>1</p><p>2</p></div></body></html>";
778 let doc = create_doc(html);
779 let count = count_elements(&doc);
780 assert_eq!(count, 6);
781 }
782
783 #[test]
784 fn test_count_text_nodes_basic() {
785 let html = "<html><body><p>Hello</p><p>World</p></body></html>";
786 let doc = create_doc(html);
787 let count = count_text_nodes(&doc);
788 assert_eq!(count, 2);
789 }
790
791 #[test]
792 fn test_extract_main_content_fallback_to_body() {
793 let html = "<html><body><p>Just a simple body text</p></body></html>";
794 let doc = create_doc(html);
795 let content = extract_main_content(&doc);
796 assert!(content.contains("simple body text"));
797 }
798
799 #[test]
802 fn test_detect_amp_page_with_amp_attribute() {
803 let html = r"<html amp><head><title>AMP Page</title></head><body>Hello</body></html>";
804 let doc = create_doc(html);
805 assert!(detect_is_amp_page(&doc));
806 }
807
808 #[test]
809 fn test_detect_amp_page_with_emoji_attribute() {
810 let html = "<html \u{26A1}><head><title>AMP</title></head><body>Hi</body></html>";
811 let doc = create_doc(html);
812 assert!(detect_is_amp_page(&doc));
813 }
814
815 #[test]
816 fn test_detect_non_amp_page() {
817 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
818 let doc = create_doc(html);
819 assert!(!detect_is_amp_page(&doc));
820 }
821
822 #[test]
823 fn test_extract_amp_link() {
824 let html = r#"
825 <head>
826 <link rel="amphtml" href="https://example.com/amp/page">
827 </head>
828 "#;
829 let doc = create_doc(html);
830 assert_eq!(
831 extract_amp_link(&doc),
832 Some("https://example.com/amp/page".to_string())
833 );
834 }
835
836 #[test]
837 fn test_extract_canonical_link() {
838 let html = r#"
839 <head>
840 <link rel="canonical" href="https://example.com/page">
841 </head>
842 "#;
843 let doc = create_doc(html);
844 assert_eq!(
845 extract_canonical_link(&doc),
846 Some("https://example.com/page".to_string())
847 );
848 }
849
850 #[test]
851 fn test_detect_amp_runtime_present() {
852 let html = r#"
853 <head>
854 <script async src="https://cdn.ampproject.org/v0.js"></script>
855 </head>
856 "#;
857 let doc = create_doc(html);
858 assert!(detect_amp_runtime(&doc));
859 }
860
861 #[test]
862 fn test_detect_amp_runtime_absent() {
863 let html = "<html><head></head><body>No AMP</body></html>";
864 let doc = create_doc(html);
865 assert!(!detect_amp_runtime(&doc));
866 }
867
868 #[test]
869 fn test_extract_amp_components() {
870 let html = r#"
871 <head>
872 <script custom-element="amp-carousel" src="https://cdn.ampproject.org/v0/amp-carousel-0.1.js"></script>
873 <script custom-element="amp-lightbox" src="https://cdn.ampproject.org/v0/amp-lightbox-0.1.js"></script>
874 <script custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"></script>
875 </head>
876 "#;
877 let doc = create_doc(html);
878 let components = extract_amp_components(&doc);
879 assert!(components.contains(&"amp-carousel".to_string()));
880 assert!(components.contains(&"amp-lightbox".to_string()));
881 assert!(components.contains(&"amp-mustache".to_string()));
882 assert_eq!(components.len(), 3);
883 }
884
885 #[test]
886 fn test_extract_amp_info_complete() {
887 let html = r#"
888 <html amp>
889 <head>
890 <title>AMP Test</title>
891 <link rel="canonical" href="https://example.com/page">
892 <script async src="https://cdn.ampproject.org/v0.js"></script>
893 <script custom-element="amp-carousel" src="https://cdn.ampproject.org/v0/amp-carousel-0.1.js"></script>
894 </head>
895 <body><p>AMP content</p></body>
896 </html>
897 "#;
898 let doc = create_doc(html);
899 let info = extract_amp_info(&doc);
900 assert!(info.is_amp);
901 assert!(info.has_amp_runtime);
902 assert_eq!(info.canonical_url, Some("https://example.com/page".to_string()));
903 }
905
906 #[test]
907 fn test_amp_info_no_amp_version() {
908 let html = r"<html><head><title>Normal</title></head><body>No AMP</body></html>";
909 let doc = create_doc(html);
910 let info = extract_amp_info(&doc);
911 assert!(!info.is_amp);
912 assert!(!info.has_amp_version());
913 }
914
915 #[test]
916 fn test_is_amp_page_convenience() {
917 let amp_html = r"<html amp><head><title>A</title></head><body>B</body></html>";
918 assert!(is_amp_page(amp_html));
919 let normal_html = "<html><head><title>A</title></head><body>B</body></html>";
920 assert!(!is_amp_page(normal_html));
921 }
922
923 #[test]
924 fn test_get_amp_url_from_non_amp() {
925 let html = r#"
926 <head>
927 <link rel="amphtml" href="https://example.com/amp">
928 <link rel="canonical" href="https://example.com/page">
929 </head>
930 "#;
931 assert_eq!(get_amp_url(html), Some("https://example.com/amp".to_string()));
932 }
933
934 #[test]
935 fn test_get_amp_url_from_amp() {
936 let html = r#"
937 <html amp>
938 <head>
939 <link rel="canonical" href="https://example.com/page">
940 </head>
941 <body>AMP</body>
942 </html>
943 "#;
944 assert_eq!(get_amp_url(html), Some("https://example.com/page".to_string()));
945 }
946
947 #[test]
950 fn test_resolve_absolute_url() {
951 assert_eq!(
952 resolve_url("https://example.com", "/path/to/page"),
953 Some("https://example.com/path/to/page".to_string())
954 );
955 }
956
957 #[test]
958 fn test_resolve_relative_url() {
959 assert_eq!(
960 resolve_url("https://example.com/base/", "relative"),
961 Some("https://example.com/base/relative".to_string())
962 );
963 }
964
965 #[test]
966 fn test_resolve_invalid_base_url() {
967 assert_eq!(resolve_url("not-a-url", "/path"), None);
968 }
969
970 #[test]
973 fn test_cache_hints_from_meta_cache_control() {
974 let html = r#"
975 <head>
976 <meta http-equiv="Cache-Control" content="max-age=3600">
977 </head>
978 "#;
979 let doc = create_doc(html);
980 let hints = extract_cache_hints(&doc);
981 assert!(hints.should_cache);
982 assert_eq!(hints.max_age, Some(3600));
983 }
984
985 #[test]
986 fn test_cache_hints_no_cache_directive() {
987 let html = r#"
988 <head>
989 <meta http-equiv="Cache-Control" content="no-cache, no-store">
990 </head>
991 "#;
992 let doc = create_doc(html);
993 let hints = extract_cache_hints(&doc);
994 assert!(!hints.should_cache);
995 }
996
997 #[test]
998 fn test_cache_hints_default_values() {
999 let html = "<html><head><title>Test</title></head><body>Hello</body></html>";
1000 let doc = create_doc(html);
1001 let hints = extract_cache_hints(&doc);
1002 assert!(hints.should_cache);
1003 assert_eq!(hints.max_age, Some(300));
1004 }
1005
1006 #[test]
1007 fn test_cache_hints_with_etag() {
1008 let html = r#"
1009 <head>
1010 <meta http-equiv="ETag" content="abc123">
1011 <meta http-equiv="Cache-Control" content="max-age=7200">
1012 </head>
1013 "#;
1014 let doc = create_doc(html);
1015 let hints = extract_cache_hints(&doc);
1016 assert_eq!(hints.etag, Some("abc123".to_string()));
1017 assert_eq!(hints.max_age, Some(7200));
1018 }
1019
1020 #[test]
1021 fn test_cache_hints_cache_key_generated() {
1022 let html = "<html><head><title>My Article Title</title></head><body>Content</body></html>";
1023 let doc = create_doc(html);
1024 let hints = extract_cache_hints(&doc);
1025 assert!(hints.cache_key.is_some());
1026 let key = hints.cache_key.unwrap();
1027 assert!(key.starts_with("page-"));
1028 }
1029
1030 #[test]
1031 fn test_cache_hints_with_stale_revalidate() {
1032 let html = r#"
1033 <head>
1034 <meta http-equiv="Cache-Control" content="max-age=3600, stale-while-revalidate=86400">
1035 </head>
1036 "#;
1037 let doc = create_doc(html);
1038 let hints = extract_cache_hints(&doc);
1039 assert_eq!(hints.stale_while_revalidate, Some(86400));
1040 }
1041
1042 #[test]
1045 fn test_has_content_changed_true() {
1046 let old = create_doc("<html><body><p>Old content</p></body></html>");
1047 let new = create_doc("<html><body><p>New content</p></body></html>");
1048 assert!(has_content_changed(&old, &new));
1049 }
1050
1051 #[test]
1052 fn test_has_content_changed_false() {
1053 let old = create_doc("<html><body><p>Same content</p></body></html>");
1054 let new = create_doc("<html><body><p>Same content</p></body></html>");
1055 assert!(!has_content_changed(&old, &new));
1056 }
1057
1058 #[test]
1059 fn test_content_similarity_identical() {
1060 let doc = create_doc("<html><body><p>Hello World</p></body></html>");
1061 let sim = content_similarity(&doc, &doc);
1062 assert!((sim - 1.0).abs() < 1e-6);
1063 }
1064
1065 #[test]
1066 fn test_content_similarity_completely_different() {
1067 let doc1 = create_doc("<html><body><p>AAAA</p></body></html>");
1068 let doc2 = create_doc("<html><body><div><span>BBBB</span></div></body></html>");
1069 let sim = content_similarity(&doc1, &doc2);
1070 assert!(sim < 1.0);
1071 assert!(sim >= 0.0);
1072 }
1073
1074 #[test]
1075 fn test_quick_hash_consistency() {
1076 let h1 = quick_hash("test data");
1077 let h2 = quick_hash("test data");
1078 let h3 = quick_hash("different data");
1079 assert_eq!(h1, h2);
1080 assert_ne!(h1, h3);
1081 }
1082
1083 #[test]
1084 fn test_quick_hash_empty() {
1085 let h = quick_hash("");
1086 assert_ne!(h, 0);
1087 }
1088
1089 #[test]
1092 fn test_document_with_only_whitespace() {
1093 let doc = create_doc("<html><body> \n </body></html>");
1094 let fp = generate_fingerprint(&doc);
1095 assert_eq!(fp.text_length, 0);
1096 assert_eq!(fp.text_node_count, 0);
1097 }
1098
1099 #[test]
1100 fn test_document_with_script_style_excluded() {
1101 let html = r"
1102 <html><body>
1103 <p>Visible text</p>
1104 <script>var x = 1;</script>
1105 <style>.hidden {}</style>
1106 </body></html>
1107 ";
1108 let doc = create_doc(html);
1109 let text = extract_text_only(&doc);
1110 assert!(text.contains("Visible text"));
1111 assert!(!text.contains("var x"));
1112 assert!(!text.contains(".hidden"));
1113 }
1114
1115 #[test]
1116 fn test_fingerprint_similarity_same_structure_different_text() {
1117 let doc1 = create_doc("<html><body><p>Short</p></body></html>");
1118 let doc2 = create_doc("<html><body><p>Longer text here</p></body></html>");
1119 let fp1 = generate_fingerprint(&doc1);
1120 let fp2 = generate_fingerprint(&doc2);
1121 assert!(fp1.similarity(&fp2) > 0.39);
1123 }
1124
1125 #[test]
1126 fn test_nested_element_count() {
1127 let html = r"
1128 <html><body>
1129 <div>
1130 <ul>
1131 <li>1</li>
1132 <li>2</li>
1133 <li>3</li>
1134 </ul>
1135 </div>
1136 </body></html>
1137 ";
1138 let doc = create_doc(html);
1139 let count = count_elements(&doc);
1140 assert_eq!(count, 8);
1141 }
1142
1143 #[test]
1144 fn test_extract_amp_link_no_amp() {
1145 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
1146 let doc = create_doc(html);
1147 assert_eq!(extract_amp_link(&doc), None);
1148 }
1149
1150 #[test]
1151 fn test_extract_canonical_link_missing() {
1152 let html = "<html><head><title>No canonical</title></head><body>Hi</body></html>";
1153 let doc = create_doc(html);
1154 assert_eq!(extract_canonical_link(&doc), None);
1155 }
1156
1157 #[test]
1158 fn test_amp_components_empty_when_no_amp() {
1159 let html = "<html><head><title>Normal</title></head><body>Hi</body></html>";
1160 let doc = create_doc(html);
1161 let components = extract_amp_components(&doc);
1162 assert!(components.is_empty());
1163 }
1164
1165 #[test]
1166 fn test_cache_hints_last_modified() {
1167 let html = r#"
1168 <head>
1169 <meta http-equiv="Last-Modified" content="Mon, 01 Jan 2024 00:00:00 GMT">
1170 </head>
1171 "#;
1172 let doc = create_doc(html);
1173 let hints = extract_cache_hints(&doc);
1174 assert!(hints.last_modified.is_some());
1175 }
1176
1177 #[test]
1178 fn test_cache_hints_pragma_no_cache() {
1179 let html = r#"
1180 <head>
1181 <meta http-equiv="Pragma" content="no-cache">
1182 </head>
1183 "#;
1184 let doc = create_doc(html);
1185 let hints = extract_cache_hints(&doc);
1186 assert!(!hints.should_cache);
1187 }
1188}