Skip to main content

crawlkit_parser/
fingerprint.rs

1//! 内容指纹与 AMP 检测模块
2//!
3//! 提供内容指纹生成、变化检测、AMP 页面识别和缓存提示提取功能。
4//! 改编自 halldyll-parser 的指纹与 AMP 检测实现。
5
6use 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// ============================================================================
14// 内容指纹
15// ============================================================================
16
17/// 内容指纹,用于检测 HTML 内容的变化
18///
19/// 存储文本哈希、结构哈希和元素统计信息,通过比较两个指纹来判断内容变化程度。
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ContentFingerprint {
22    /// 全文文本哈希
23    pub text_hash: u64,
24    /// DOM 结构哈希
25    pub structure_hash: u64,
26    /// 主要正文文本哈希
27    pub main_content_hash: u64,
28    /// 元素总数
29    pub element_count: usize,
30    /// 文本节点数
31    pub text_node_count: usize,
32    /// 文本总长度(字符)
33    pub text_length: usize,
34    /// 主要正文文本长度
35    pub main_content_length: usize,
36}
37
38impl ContentFingerprint {
39    /// 判断与另一个指纹相比内容是否发生了变化
40    ///
41    /// 只要任一哈希不同,即认为内容已变化。
42    pub fn has_changed(&self, other: &ContentFingerprint) -> bool {
43        self.text_hash != other.text_hash || self.structure_hash != other.structure_hash
44    }
45
46    /// 判断是否仅发生了微小变化(文本改变但结构未变)
47    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    /// 判断是否发生了结构性变化(DOM 树结构改变)
52    pub fn has_structural_changes(&self, other: &ContentFingerprint) -> bool {
53        self.structure_hash != other.structure_hash
54    }
55
56    /// 计算与另一个指纹的相似度(0.0 ~ 1.0)
57    ///
58    /// 综合考虑文本哈希、结构哈希和元素数量的差异。
59    pub fn similarity(&self, other: &ContentFingerprint) -> f64 {
60        let mut matching = 0.0;
61        let mut total = 0.0;
62
63        // 文本哈希相似度贡献 40%
64        if self.text_hash == other.text_hash {
65            matching += 40.0;
66        } else {
67            // 根据文本长度的接近程度给部分分数
68            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        // 结构哈希相似度贡献 40%
77        if self.structure_hash == other.structure_hash {
78            matching += 40.0;
79        } else if self.element_count > 0 || other.element_count > 0 {
80            // 根据元素数量的接近程度给部分分数
81            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        // 正文哈希相似度贡献 20%
90        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// ============================================================================
109// AMP 信息
110// ============================================================================
111
112/// AMP 页面信息
113///
114/// 存储从 HTML 文档中提取的 AMP 相关元数据。
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AmpInfo {
117    /// 当前页面是否为 AMP 页面
118    pub is_amp: bool,
119    /// AMP 版本的 URL(如果当前页面不是 AMP,这里有指向 AMP 版本的链接)
120    pub amp_url: Option<String>,
121    /// 标准版(canonical)URL
122    pub canonical_url: Option<String>,
123    /// 是否加载了 AMP 运行时
124    pub has_amp_runtime: bool,
125    /// 使用中的 AMP 扩展组件列表
126    pub amp_components: Vec<String>,
127    /// AMP 版本号
128    pub amp_version: Option<String>,
129}
130
131impl AmpInfo {
132    /// 是否包含 AMP 版本号信息
133    pub fn has_amp_version(&self) -> bool {
134        self.amp_version.is_some()
135    }
136}
137
138// ============================================================================
139// 缓存提示
140// ============================================================================
141
142/// 缓存提示信息
143///
144/// 从 HTTP 响应头或 HTML 中提取的缓存相关元数据。
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct CacheHints {
147    /// 建议的缓存时间(秒)
148    pub max_age: Option<u64>,
149    /// 缓存过期后允许使用过期内容的宽限时间(秒)
150    pub stale_while_revalidate: Option<u64>,
151    /// ETag 标识
152    pub etag: Option<String>,
153    /// 最后修改时间
154    pub last_modified: Option<String>,
155    /// 是否应缓存此内容
156    pub should_cache: bool,
157    /// 缓存键建议
158    pub cache_key: Option<String>,
159}
160
161// ============================================================================
162// 指纹生成
163// ============================================================================
164
165/// 为 HTML 文档生成完整的内容指纹
166pub 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
186/// 为 HTML 文档生成指纹
187///
188/// `generate_fingerprint` 的别名,提供更直观的命名。
189pub fn fingerprint_document(document: &Html) -> ContentFingerprint {
190    generate_fingerprint(document)
191}
192
193/// 计算字符串的哈希值
194pub fn hash_string(s: &str) -> u64 {
195    let mut hasher = DefaultHasher::new();
196    s.hash(&mut hasher);
197    hasher.finish()
198}
199
200/// 从文档中提取主要正文内容
201///
202/// 先尝试使用 `article`、`main`、`[role=main]` 等选择器定位正文区域,
203/// 如果都不匹配则回退到 `<body>`。
204pub 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    // 回退到 body
219    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    // 最后回退到根元素
226    collect_text_recursive(document.root_element())
227}
228
229/// 从文档中提取所有文本(不含 HTML 标签)
230pub fn extract_text_only(document: &Html) -> String {
231    collect_text_recursive(document.root_element())
232}
233
234/// 提取文档的结构指纹(基于 DOM 标签层次结构)
235pub 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
242/// 递归提取元素的 DOM 结构
243fn 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    // 只保留 id 和少量关键属性以保持结构泛化能力
248    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
276/// 统计文档中的 HTML 元素总数
277pub 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
291/// 统计文档中的文本节点数量
292pub 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
317// ============================================================================
318// AMP 检测
319// ============================================================================
320
321/// 从 HTML 文档中提取 AMP 信息
322pub 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
340/// 检测当前页面是否为 AMP 页面
341///
342/// 通过检查 `<html>` 标签是否包含 `amp` 或 `⚡` 属性来判断。
343pub 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            // 检查 ⚡ 属性
352            if el.attr("\u{26A1}").is_some() {
353                return true;
354            }
355        }
356    }
357    false
358}
359
360/// 提取 AMP 版本的链接(`<link rel="amphtml">`)
361pub 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
371/// 提取 canonical 链接(`<link rel="canonical">`)
372pub 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
383/// 检测页面是否加载了 AMP 运行时
384pub 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
398/// 提取页面使用的 AMP 扩展组件列表
399pub fn extract_amp_components(document: &Html) -> Vec<String> {
400    let mut components = Vec::new();
401
402    // 查找 script[custom-element] 和 script[custom-template]
403    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
419/// 检测 AMP 版本号
420///
421/// 从 AMP 运行时脚本的 `src` 属性中提取版本号。
422pub 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                // 版本号通常以 /v0.js 或 /v0/ 形式出现
428                if let Some(ver_start) = src.rfind('/') {
429                    let candidate = &src[ver_start + 1..];
430                    if candidate == "v0.js" || candidate.starts_with("v0/") {
431                        // 从 URL 中提取完整版本号
432                        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
450/// 解析相对 URL 为绝对 URL
451///
452/// 使用 `base_url` 作为基础,将 `relative` 解析为完整 URL。
453pub 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
458// ============================================================================
459// 缓存提示提取
460// ============================================================================
461
462/// 从 HTML 文档中提取缓存提示信息
463///
464/// 检查 `<meta http-equiv>` 和缓存的启发式规则。
465pub fn extract_cache_hints(document: &Html) -> CacheHints {
466    let mut hints = CacheHints::default();
467    let mut no_cache_set = false;
468
469    // 检查 meta[http-equiv] 标签
470    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                        // 尝试解析过期时间
488                        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    // 默认情况下,如果没设置禁止缓存,就认为是可缓存的
517    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    // 生成缓存键:使用页面标题和正文长度的组合
525    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    // 简单尝试解析 HTTP 日期格式(RFC 2822/1123)
555    // 此处不做完整实现,返回 None 表示无法解析
556    // 完整解析可依赖 httpdate 或 chrono crate
557    let _ = date_str;
558    None
559}
560
561// ============================================================================
562// 便利函数
563// ============================================================================
564
565/// 检查两个 HTML 文档的内容是否发生了变化
566///
567/// 返回 `true` 表示内容已变化(需要重新处理)。
568pub 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
574/// 计算两个 HTML 文档的内容相似度
575///
576/// 返回 0.0 ~ 1.0 之间的相似度分数。
577pub 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
583/// 检测 HTML 文档是否为 AMP 页面
584pub 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
590/// 获取 AMP 版本的 URL(如果存在)
591///
592/// 对于非 AMP 页面,返回指向其 AMP 版本的链接;
593/// 对于 AMP 页面,返回其 canonical 链接。
594pub 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
604/// 快速计算 HTML 文档的文本哈希值
605///
606/// 比 `generate_fingerprint` 轻量,仅对全文文本进行哈希。
607pub 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
613/// 快速计算文本内容的哈希值
614///
615/// 直接对纯文本进行哈希,不解析 HTML。
616pub fn quick_hash(text: &str) -> u64 {
617    hash_string(text)
618}
619
620// ============================================================================
621// 辅助函数
622// ============================================================================
623
624fn 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                // 跳过 script、style、noscript 内容
640                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    // ─── 辅助函数 ─────────────────────────────────────────────
678
679    fn create_doc(html: &str) -> Html {
680        Html::parse_document(html)
681    }
682
683    // ─── 内容指纹测试 ──────────────────────────────────────────
684
685    #[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    // ─── 内容提取测试 ──────────────────────────────────────────
756
757    #[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    // ─── AMP 检测测试 ──────────────────────────────────────────
806
807    #[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        // v0.js 中无显式版本号,has_amp_version 可能为 false
910    }
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    // ─── URL 解析测试 ──────────────────────────────────────────
954
955    #[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    // ─── 缓存提示测试 ──────────────────────────────────────────
977
978    #[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    // ─── 便利函数测试 ──────────────────────────────────────────
1049
1050    #[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    // ─── 边界情况测试 ──────────────────────────────────────────
1096
1097    #[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        // 结构相同,文本不同,相似度应 > 0.4(结构分)
1128        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}