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            && 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    // 回退到 body
218    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    // 最后回退到根元素
224    collect_text_recursive(document.root_element())
225}
226
227/// 从文档中提取所有文本(不含 HTML 标签)
228pub fn extract_text_only(document: &Html) -> String {
229    collect_text_recursive(document.root_element())
230}
231
232/// 提取文档的结构指纹(基于 DOM 标签层次结构)
233pub 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
240/// 递归提取元素的 DOM 结构
241fn 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    // 只保留 id 和少量关键属性以保持结构泛化能力
246    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
274/// 统计文档中的 HTML 元素总数
275pub 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
289/// 统计文档中的文本节点数量
290pub 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
315// ============================================================================
316// AMP 检测
317// ============================================================================
318
319/// 从 HTML 文档中提取 AMP 信息
320pub 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
338/// 检测当前页面是否为 AMP 页面
339///
340/// 通过检查 `<html>` 标签是否包含 `amp` 或 `⚡` 属性来判断。
341pub 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            // 检查 ⚡ 属性
350            if el.attr("\u{26A1}").is_some() {
351                return true;
352            }
353        }
354    false
355}
356
357/// 提取 AMP 版本的链接(`<link rel="amphtml">`)
358pub 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
367/// 提取 canonical 链接(`<link rel="canonical">`)
368pub 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
379/// 检测页面是否加载了 AMP 运行时
380pub 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
393/// 提取页面使用的 AMP 扩展组件列表
394pub fn extract_amp_components(document: &Html) -> Vec<String> {
395    let mut components = Vec::new();
396
397    // 查找 script[custom-element] 和 script[custom-template]
398    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
413/// 检测 AMP 版本号
414///
415/// 从 AMP 运行时脚本的 `src` 属性中提取版本号。
416pub 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                // 版本号通常以 /v0.js 或 /v0/ 形式出现
422                if let Some(ver_start) = src.rfind('/') {
423                    let candidate = &src[ver_start + 1..];
424                    if candidate == "v0.js" || candidate.starts_with("v0/") {
425                        // 从 URL 中提取完整版本号
426                        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
444/// 解析相对 URL 为绝对 URL
445///
446/// 使用 `base_url` 作为基础,将 `relative` 解析为完整 URL。
447pub 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
452// ============================================================================
453// 缓存提示提取
454// ============================================================================
455
456/// 从 HTML 文档中提取缓存提示信息
457///
458/// 检查 `<meta http-equiv>` 和缓存的启发式规则。
459pub fn extract_cache_hints(document: &Html) -> CacheHints {
460    let mut hints = CacheHints::default();
461    let mut no_cache_set = false;
462
463    // 检查 meta[http-equiv] 标签
464    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                        // 尝试解析过期时间
482                        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    // 默认情况下,如果没设置禁止缓存,就认为是可缓存的
511    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    // 生成缓存键:使用页面标题和正文长度的组合
519    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    // 简单尝试解析 HTTP 日期格式(RFC 2822/1123)
549    // 此处不做完整实现,返回 None 表示无法解析
550    // 完整解析可依赖 httpdate 或 chrono crate
551    let _ = date_str;
552    None
553}
554
555// ============================================================================
556// 便利函数
557// ============================================================================
558
559/// 检查两个 HTML 文档的内容是否发生了变化
560///
561/// 返回 `true` 表示内容已变化(需要重新处理)。
562pub 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
568/// 计算两个 HTML 文档的内容相似度
569///
570/// 返回 0.0 ~ 1.0 之间的相似度分数。
571pub 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
577/// 检测 HTML 文档是否为 AMP 页面
578pub 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
584/// 获取 AMP 版本的 URL(如果存在)
585///
586/// 对于非 AMP 页面,返回指向其 AMP 版本的链接;
587/// 对于 AMP 页面,返回其 canonical 链接。
588pub 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
598/// 快速计算 HTML 文档的文本哈希值
599///
600/// 比 `generate_fingerprint` 轻量,仅对全文文本进行哈希。
601pub 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
607/// 快速计算文本内容的哈希值
608///
609/// 直接对纯文本进行哈希,不解析 HTML。
610pub fn quick_hash(text: &str) -> u64 {
611    hash_string(text)
612}
613
614// ============================================================================
615// 辅助函数
616// ============================================================================
617
618fn 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                // 跳过 script、style、noscript 内容
634                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    // ─── 辅助函数 ─────────────────────────────────────────────
672
673    fn create_doc(html: &str) -> Html {
674        Html::parse_document(html)
675    }
676
677    // ─── 内容指纹测试 ──────────────────────────────────────────
678
679    #[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    // ─── 内容提取测试 ──────────────────────────────────────────
750
751    #[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    // ─── AMP 检测测试 ──────────────────────────────────────────
800
801    #[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        // v0.js 中无显式版本号,has_amp_version 可能为 false
904    }
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    // ─── URL 解析测试 ──────────────────────────────────────────
948
949    #[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    // ─── 缓存提示测试 ──────────────────────────────────────────
971
972    #[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    // ─── 便利函数测试 ──────────────────────────────────────────
1043
1044    #[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    // ─── 边界情况测试 ──────────────────────────────────────────
1090
1091    #[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        // 结构相同,文本不同,相似度应 > 0.4(结构分)
1122        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}