Skip to main content

crawlkit_parser/
parser.rs

1//! # HtmlParser — 统一解析入口
2//!
3//! 提供 `HtmlParser` 结构体,作为所有 HTML 解析操作的中心入口。
4//! 封装文档解析、元数据提取、文本提取、结构化内容提取等功能。
5//! 适配自 halldyll-parser 的解析器编排层。
6
7use scraper::Html;
8use std::time::Instant;
9use url::Url;
10
11use crate::content::{
12    extract_code_blocks, extract_headings, extract_images, extract_lists, extract_paragraphs,
13    extract_quotes, extract_tables,
14};
15use crate::links::extract_links;
16use crate::metadata::{extract_metadata, extract_structured_data};
17use crate::text::extract_text;
18use crate::types::{
19    CodeBlock, Heading, Image, Link, ListContent, PageMetadata, ParseStats, ParsedContent,
20    ParserConfig, ParserResult, Quote, StructuredData, TableContent, TextContent,
21};
22
23// ============================================================================
24// HtmlParser
25// ============================================================================
26
27/// 统一的 HTML 解析器,封装文档解析与内容提取的全流程。
28///
29/// 通过 `ParserConfig` 控制提取的内容类型和行为。
30///
31/// # 示例
32///
33/// ```ignore
34/// let parser = HtmlParser::new();
35/// let result = parser.parse("<html><body><p>Hello</p></body></html>")?;
36/// println!("{}", result.text.cleaned_text);
37/// ```
38#[derive(Debug, Clone)]
39pub struct HtmlParser {
40    config: ParserConfig,
41}
42
43impl HtmlParser {
44    // ── 构造方法 ──────────────────────────────────────────────────────────
45
46    /// 使用默认配置创建解析器。
47    pub fn new() -> Self {
48        Self {
49            config: ParserConfig::default(),
50        }
51    }
52
53    /// 使用自定义配置创建解析器。
54    pub fn with_config(config: ParserConfig) -> Self {
55        Self { config }
56    }
57
58    /// 使用指定的基础 URL 创建解析器。
59    pub fn with_base_url(url: Url) -> Self {
60        Self {
61            config: ParserConfig {
62                base_url: Some(url),
63                ..ParserConfig::default()
64            },
65        }
66    }
67
68    /// 设置基础 URL,用于解析相对链接和图片地址。
69    pub fn set_base_url(&mut self, url: Url) {
70        self.config.base_url = Some(url);
71    }
72
73    /// 获取当前配置的引用。
74    pub fn config(&self) -> &ParserConfig {
75        &self.config
76    }
77
78    /// 获取当前配置的可变引用。
79    pub fn config_mut(&mut self) -> &mut ParserConfig {
80        &mut self.config
81    }
82
83    // ── 核心解析方法 ──────────────────────────────────────────────────────
84
85    /// 解析完整的 HTML 文档,返回结构化内容。
86    ///
87    /// 自动计算解析统计信息,并根据配置决定提取哪些内容类型。
88    pub fn parse(&self, html: &str) -> ParserResult<ParsedContent> {
89        let start = Instant::now();
90        let document = Html::parse_document(html);
91        let parse_time_us = start.elapsed().as_micros() as u64;
92
93        let html_size = html.len();
94        let node_count = count_nodes(&document);
95
96        let metadata = extract_metadata(&document, self.config.base_url.as_ref())?;
97        let text = extract_text(&document, &self.config)?;
98        let headings = extract_headings(&document);
99        let paragraphs = extract_paragraphs(&document, self.config.min_paragraph_length);
100
101        let links = if self.config.extract_links {
102            extract_links(&document, &self.config)?
103        } else {
104            Vec::new()
105        };
106
107        let images = if self.config.extract_images {
108            extract_images(&document, self.config.base_url.as_ref())
109        } else {
110            Vec::new()
111        };
112
113        let lists = extract_lists(&document, &self.config);
114
115        let tables = if self.config.extract_tables {
116            extract_tables(&document, &self.config)
117        } else {
118            Vec::new()
119        };
120
121        let code_blocks = if self.config.extract_code_blocks {
122            extract_code_blocks(&document, &self.config)
123        } else {
124            Vec::new()
125        };
126
127        let quotes = extract_quotes(&document, &self.config);
128
129        let structured_data = if self.config.extract_structured_data {
130            extract_structured_data(&document)
131        } else {
132            Vec::new()
133        };
134
135        let stats = ParseStats {
136            html_size,
137            parse_time_us,
138            node_count,
139            element_count: count_elements(&document),
140            text_node_count: count_text_nodes(&document),
141            comment_count: count_comments(&document),
142            errors: Vec::new(),
143            warnings: Vec::new(),
144        };
145
146        Ok(ParsedContent {
147            metadata,
148            text,
149            headings,
150            paragraphs,
151            links,
152            images,
153            lists,
154            tables,
155            code_blocks,
156            quotes,
157            structured_data,
158            stats,
159        })
160    }
161
162    /// 解析 HTML 片段(不包含 `<html>`/`<body>` 包装)。
163    ///
164    /// 适用于解析页面中的局部 HTML 内容。
165    pub fn parse_fragment(&self, html: &str) -> ParserResult<ParsedContent> {
166        let start = Instant::now();
167        let document = Html::parse_fragment(html);
168        let parse_time_us = start.elapsed().as_micros() as u64;
169
170        let html_size = html.len();
171        let metadata = PageMetadata::default();
172        let text = extract_text(&document, &self.config)?;
173        let headings = extract_headings(&document);
174        let paragraphs = extract_paragraphs(&document, self.config.min_paragraph_length);
175
176        let links = if self.config.extract_links {
177            extract_links(&document, &self.config)?
178        } else {
179            Vec::new()
180        };
181
182        let images = if self.config.extract_images {
183            extract_images(&document, self.config.base_url.as_ref())
184        } else {
185            Vec::new()
186        };
187
188        let lists = extract_lists(&document, &self.config);
189
190        let tables = if self.config.extract_tables {
191            extract_tables(&document, &self.config)
192        } else {
193            Vec::new()
194        };
195
196        let code_blocks = if self.config.extract_code_blocks {
197            extract_code_blocks(&document, &self.config)
198        } else {
199            Vec::new()
200        };
201
202        let quotes = extract_quotes(&document, &self.config);
203
204        let stats = ParseStats {
205            html_size,
206            parse_time_us,
207            node_count: count_nodes(&document),
208            element_count: count_elements(&document),
209            text_node_count: count_text_nodes(&document),
210            comment_count: count_comments(&document),
211            errors: Vec::new(),
212            warnings: Vec::new(),
213        };
214
215        Ok(ParsedContent {
216            metadata,
217            text,
218            headings,
219            paragraphs,
220            links,
221            images,
222            lists,
223            tables,
224            code_blocks,
225            quotes,
226            structured_data: Vec::new(),
227            stats,
228        })
229    }
230
231    // ── 单项提取方法 ──────────────────────────────────────────────────────
232
233    /// 提取页面元数据(标题、描述、OG、Twitter Card、结构化数据等)。
234    pub fn extract_metadata(&self, html: &str) -> ParserResult<PageMetadata> {
235        let document = Html::parse_document(html);
236        extract_metadata(&document, self.config.base_url.as_ref())
237    }
238
239    /// 提取页面文本内容,含清洗、可读性评分。
240    pub fn extract_text(&self, html: &str) -> ParserResult<TextContent> {
241        let document = Html::parse_document(html);
242        extract_text(&document, &self.config)
243    }
244
245    /// 提取所有标题(h1 - h6)。
246    pub fn extract_headings(&self, html: &str) -> Vec<Heading> {
247        let document = Html::parse_document(html);
248        extract_headings(&document)
249    }
250
251    /// 提取所有链接。
252    pub fn extract_links(&self, html: &str) -> ParserResult<Vec<Link>> {
253        let document = Html::parse_document(html);
254        extract_links(&document, &self.config)
255    }
256
257    /// 提取所有图片,自动解析相对地址为绝对地址。
258    pub fn extract_images(&self, html: &str) -> Vec<Image> {
259        let document = Html::parse_document(html);
260        extract_images(&document, self.config.base_url.as_ref())
261    }
262
263    /// 提取所有列表(有序、无序、定义列表)。
264    pub fn extract_lists(&self, html: &str) -> Vec<ListContent> {
265        let document = Html::parse_document(html);
266        extract_lists(&document, &self.config)
267    }
268
269    /// 提取所有表格。
270    pub fn extract_tables(&self, html: &str) -> Vec<TableContent> {
271        let document = Html::parse_document(html);
272        extract_tables(&document, &self.config)
273    }
274
275    /// 提取所有代码块。
276    pub fn extract_code_blocks(&self, html: &str) -> Vec<CodeBlock> {
277        let document = Html::parse_document(html);
278        extract_code_blocks(&document, &self.config)
279    }
280
281    /// 提取所有引用(blockquote)。
282    pub fn extract_quotes(&self, html: &str) -> Vec<Quote> {
283        let document = Html::parse_document(html);
284        extract_quotes(&document, &self.config)
285    }
286
287    /// 提取所有结构化数据(JSON-LD + Microdata)。
288    pub fn extract_structured_data(&self, html: &str) -> Vec<StructuredData> {
289        let document = Html::parse_document(html);
290        extract_structured_data(&document)
291    }
292
293    // ── URL 工具方法 ──────────────────────────────────────────────────────
294
295    /// 将相对路径或协议相对 URL 解析为绝对 URL。
296    ///
297    /// 需要解析器已配置 `base_url`,否则返回 `None`。
298    pub fn resolve_url(&self, href: &str) -> Option<String> {
299        let base = self.config.base_url.as_ref()?;
300        resolve_relative_url(base, href)
301    }
302
303    /// 检查是否已配置基础 URL。
304    pub fn has_base_url(&self) -> bool {
305        self.config.base_url.is_some()
306    }
307
308    /// 获取当前基础 URL 的引用。
309    pub fn base_url(&self) -> Option<&Url> {
310        self.config.base_url.as_ref()
311    }
312}
313
314impl Default for HtmlParser {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320// ============================================================================
321// 便捷函数
322// ============================================================================
323
324/// 使用默认配置快速解析 HTML 文档。
325pub fn parse(html: &str) -> ParserResult<ParsedContent> {
326    HtmlParser::new().parse(html)
327}
328
329/// 使用指定基础 URL 解析 HTML 文档,支持相对地址解析。
330pub fn parse_with_url(html: &str, base_url: Url) -> ParserResult<ParsedContent> {
331    let parser = HtmlParser::with_base_url(base_url);
332    parser.parse(html)
333}
334
335/// 快速提取 HTML 页面的元数据。
336pub fn get_metadata(html: &str) -> ParserResult<PageMetadata> {
337    let parser = HtmlParser::new();
338    parser.extract_metadata(html)
339}
340
341/// 快速提取 HTML 页面的纯文本内容。
342pub fn get_text(html: &str) -> ParserResult<TextContent> {
343    let parser = HtmlParser::new();
344    parser.extract_text(html)
345}
346
347/// 快速提取 HTML 页面中的所有链接。
348pub fn get_links(html: &str) -> ParserResult<Vec<Link>> {
349    let parser = HtmlParser::new();
350    parser.extract_links(html)
351}
352
353// ============================================================================
354// 内部辅助函数
355// ============================================================================
356
357/// 将相对 URL 解析为绝对 URL。
358fn resolve_relative_url(base: &Url, href: &str) -> Option<String> {
359    let href = href.trim();
360    if href.is_empty() {
361        return None;
362    }
363    if href.starts_with("http://") || href.starts_with("https://") {
364        return Some(href.to_string());
365    }
366    if href.starts_with("//") {
367        let scheme = base.scheme();
368        return Some(format!("{scheme}:{href}"));
369    }
370    base.join(href).ok().map(|u| u.to_string())
371}
372
373/// 统计文档中的节点总数。
374fn count_nodes(document: &Html) -> usize {
375    document.root_element().children().count()
376}
377
378/// 统计文档中的元素节点数。
379fn count_elements(document: &Html) -> usize {
380    count_elements_recursive(&document.root_element())
381}
382
383fn count_elements_recursive(element: &scraper::ElementRef) -> usize {
384    let mut count = 1;
385    for child in element.children() {
386        if let Some(child_ref) = scraper::ElementRef::wrap(child) {
387            count += count_elements_recursive(&child_ref);
388        }
389    }
390    count
391}
392
393/// 统计文档中的文本节点数。
394fn count_text_nodes(document: &Html) -> usize {
395    count_text_nodes_recursive(&document.root_element())
396}
397
398fn count_text_nodes_recursive(element: &scraper::ElementRef) -> usize {
399    let mut count = 0;
400    for child in element.children() {
401        match child.value() {
402            scraper::Node::Text(_) => count += 1,
403            _ => {}
404        }
405        if let Some(child_ref) = scraper::ElementRef::wrap(child) {
406            count += count_text_nodes_recursive(&child_ref);
407        }
408    }
409    count
410}
411
412/// 统计文档中的注释节点数。
413fn count_comments(document: &Html) -> usize {
414    count_comments_recursive(&document.root_element())
415}
416
417fn count_comments_recursive(element: &scraper::ElementRef) -> usize {
418    let mut count = 0;
419    for child in element.children() {
420        if matches!(child.value(), scraper::Node::Comment(_)) {
421            count += 1;
422        }
423        if let Some(child_ref) = scraper::ElementRef::wrap(child) {
424            count += count_comments_recursive(&child_ref);
425        }
426    }
427    count
428}
429
430// ============================================================================
431// 测试
432// ============================================================================
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    /// 包含各类 HTML 结构的示例页面,用于集成测试。
439    const SAMPLE_HTML: &str = r##"
440<!DOCTYPE html>
441<html lang="zh-CN">
442<head>
443    <meta charset="utf-8">
444    <title>示例页面 — 测试用</title>
445    <meta name="description" content="这是一个用于解析测试的示例页面">
446    <meta name="keywords" content="测试, html, 解析">
447    <meta name="author" content="测试作者">
448    <meta property="og:title" content="OG 示例标题">
449    <meta property="og:type" content="article">
450    <meta name="twitter:card" content="summary">
451    <link rel="canonical" href="https://example.com/page">
452    <link rel="icon" href="/favicon.ico">
453    <base href="https://example.com/blog/">
454</head>
455<body>
456    <h1 id="main-title">主标题</h1>
457    <h2 class="section">第一章</h2>
458    <p>这是第一段文字。包含足够长的内容来满足最小段落长度要求。</p>
459    <p>这是第二段文字,同样有足够的内容来通过过滤器。</p>
460
461    <ul>
462        <li>无序列表项一</li>
463        <li>无序列表项二</li>
464        <li>无序列表项三</li>
465    </ul>
466
467    <ol>
468        <li>有序第一</li>
469        <li>有序第二</li>
470    </ol>
471
472    <table>
473        <thead>
474            <tr><th>姓名</th><th>年龄</th></tr>
475        </thead>
476        <tbody>
477            <tr><td>张三</td><td>28</td></tr>
478            <tr><td>李四</td><td>35</td></tr>
479        </tbody>
480    </table>
481
482    <pre><code class="language-rust">fn hello() { println!("你好"); }</code></pre>
483
484    <blockquote cite="https://example.com/quote">
485        这是一段引用文字。
486        <cite>作者名</cite>
487    </blockquote>
488
489    <img src="/images/photo.jpg" alt="示例图片" width="800" height="600">
490
491    <a href="https://example.com/other">外部链接</a>
492    <a href="/relative-page">相对链接</a>
493    <a href="mailto:test@example.com">邮箱链接</a>
494
495    <script type="application/ld+json">
496        {"@type": "Article", "name": "示例文章", "description": "测试"}
497    </script>
498</body>
499</html>
500"##;
501
502    // ── 构造方法 ──
503
504    #[test]
505    fn 默认构造() {
506        let parser = HtmlParser::new();
507        assert!(parser.config().base_url.is_none());
508    }
509
510    #[test]
511    fn 带配置构造() {
512        let config = ParserConfig::minimal();
513        let parser = HtmlParser::with_config(config);
514        assert!(!parser.config().extract_images);
515    }
516
517    #[test]
518    fn 带基础URL构造() {
519        let url = Url::parse("https://example.com").unwrap();
520        let parser = HtmlParser::with_base_url(url);
521        assert!(parser.has_base_url());
522    }
523
524    #[test]
525    fn 设置基础URL() {
526        let mut parser = HtmlParser::new();
527        assert!(!parser.has_base_url());
528        parser.set_base_url(Url::parse("https://example.com").unwrap());
529        assert!(parser.has_base_url());
530    }
531
532    #[test]
533    fn 获取配置引用() {
534        let parser = HtmlParser::new();
535        let config = parser.config();
536        assert!(config.extract_images);
537    }
538
539    #[test]
540    fn 获取配置可变引用() {
541        let mut parser = HtmlParser::new();
542        parser.config_mut().extract_images = false;
543        assert!(!parser.config().extract_images);
544    }
545
546    #[test]
547    fn 默认trait实现() {
548        let parser: HtmlParser = Default::default();
549        assert!(parser.config().extract_images);
550    }
551
552    // ── 完整解析 ──
553
554    #[test]
555    fn 解析完整文档() {
556        let parser = HtmlParser::new();
557        let result = parser.parse(SAMPLE_HTML).unwrap();
558        assert!(result.metadata.title.is_some());
559        assert!(result.text.word_count > 0);
560        assert!(!result.headings.is_empty());
561        assert!(!result.paragraphs.is_empty());
562        assert!(!result.links.is_empty());
563        assert!(!result.images.is_empty());
564        assert!(!result.lists.is_empty());
565        assert!(!result.tables.is_empty());
566        assert!(!result.code_blocks.is_empty());
567        assert!(!result.quotes.is_empty());
568        assert!(!result.structured_data.is_empty());
569    }
570
571    #[test]
572    fn 解析文档统计信息() {
573        let parser = HtmlParser::new();
574        let result = parser.parse(SAMPLE_HTML).unwrap();
575        assert!(result.stats.html_size > 0);
576        assert!(result.stats.parse_time_us > 0);
577        assert!(result.stats.node_count > 0);
578    }
579
580    #[test]
581    fn 解析空文档() {
582        let parser = HtmlParser::new();
583        let result = parser.parse("").unwrap();
584        assert_eq!(result.text.word_count, 0);
585        assert!(result.headings.is_empty());
586    }
587
588    #[test]
589    fn 解析最小化文档() {
590        let parser = HtmlParser::with_config(ParserConfig::minimal());
591        let result = parser.parse(SAMPLE_HTML).unwrap();
592        assert!(result.images.is_empty());
593        assert!(result.tables.is_empty());
594        assert!(result.code_blocks.is_empty());
595        assert!(result.structured_data.is_empty());
596        assert!(result.metadata.title.is_some());
597    }
598
599    // ── 片段解析 ──
600
601    #[test]
602    fn 解析HTML片段() {
603        let parser = HtmlParser::new();
604        let fragment = "<p>这是片段内容。</p><a href=\"https://example.com\">链接</a>";
605        let result = parser.parse_fragment(fragment).unwrap();
606        assert!(result.text.cleaned_text.contains("片段内容"));
607        assert_eq!(result.links.len(), 1);
608    }
609
610    #[test]
611    fn 片段解析无元数据() {
612        let parser = HtmlParser::new();
613        let result = parser.parse_fragment("<p>纯文本</p>").unwrap();
614        assert!(result.metadata.title.is_none());
615    }
616
617    // ── 单项提取 ──
618
619    #[test]
620    fn 单项提取元数据() {
621        let parser = HtmlParser::new();
622        let meta = parser.extract_metadata(SAMPLE_HTML).unwrap();
623        assert_eq!(meta.title.as_deref(), Some("OG 示例标题"));
624        assert!(meta.description.is_some());
625        assert!(!meta.keywords.is_empty());
626    }
627
628    #[test]
629    fn 单项提取文本() {
630        let parser = HtmlParser::new();
631        let text = parser.extract_text(SAMPLE_HTML).unwrap();
632        assert!(text.cleaned_text.contains("主标题"));
633        assert!(text.word_count > 5);
634    }
635
636    #[test]
637    fn 单项提取标题() {
638        let parser = HtmlParser::new();
639        let headings = parser.extract_headings(SAMPLE_HTML);
640        assert_eq!(headings.len(), 2);
641        assert_eq!(headings[0].text, "主标题");
642        assert_eq!(headings[1].text, "第一章");
643    }
644
645    #[test]
646    fn 单项提取链接() {
647        let parser = HtmlParser::new();
648        let links = parser.extract_links(SAMPLE_HTML).unwrap();
649        assert!(!links.is_empty());
650        let has_external = links.iter().any(|l| l.href.contains("example.com/other"));
651        assert!(has_external);
652    }
653
654    #[test]
655    fn 单项提取图片() {
656        let parser = HtmlParser::new();
657        let images = parser.extract_images(SAMPLE_HTML);
658        assert_eq!(images.len(), 1);
659        assert_eq!(images[0].alt, "示例图片");
660    }
661
662    #[test]
663    fn 单项提取列表() {
664        let parser = HtmlParser::new();
665        let lists = parser.extract_lists(SAMPLE_HTML);
666        assert_eq!(lists.len(), 2);
667        let ul = lists.iter().find(|l| l.items.len() == 3);
668        assert!(ul.is_some());
669    }
670
671    #[test]
672    fn 单项提取表格() {
673        let parser = HtmlParser::new();
674        let tables = parser.extract_tables(SAMPLE_HTML);
675        assert_eq!(tables.len(), 1);
676        assert_eq!(tables[0].rows.len(), 2);
677    }
678
679    #[test]
680    fn 单项提取代码块() {
681        let parser = HtmlParser::new();
682        let blocks = parser.extract_code_blocks(SAMPLE_HTML);
683        assert_eq!(blocks.len(), 1);
684        assert_eq!(blocks[0].language.as_deref(), Some("rust"));
685    }
686
687    #[test]
688    fn 单项提取引用() {
689        let parser = HtmlParser::new();
690        let quotes = parser.extract_quotes(SAMPLE_HTML);
691        assert_eq!(quotes.len(), 1);
692    }
693
694    #[test]
695    fn 单项提取结构化数据() {
696        let parser = HtmlParser::new();
697        let data = parser.extract_structured_data(SAMPLE_HTML);
698        assert_eq!(data.len(), 1);
699        assert_eq!(data[0].schema_type.as_deref(), Some("Article"));
700    }
701
702    // ── URL 工具 ──
703
704    #[test]
705    fn 解析相对URL() {
706        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
707        let resolved = parser.resolve_url("/path/to/page");
708        assert_eq!(
709            resolved.as_deref(),
710            Some("https://example.com/path/to/page")
711        );
712    }
713
714    #[test]
715    fn 解析绝对URL() {
716        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
717        let resolved = parser.resolve_url("https://other.com/page");
718        assert_eq!(resolved.as_deref(), Some("https://other.com/page"));
719    }
720
721    #[test]
722    fn 无基础URL时返回None() {
723        let parser = HtmlParser::new();
724        assert!(parser.resolve_url("/page").is_none());
725    }
726
727    #[test]
728    fn 空href返回None() {
729        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
730        assert!(parser.resolve_url("").is_none());
731    }
732
733    #[test]
734    fn 检查是否有基础URL() {
735        let parser = HtmlParser::new();
736        assert!(!parser.has_base_url());
737        let parser2 = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
738        assert!(parser2.has_base_url());
739    }
740
741    #[test]
742    fn 获取基础URL引用() {
743        let url = Url::parse("https://example.com").unwrap();
744        let parser = HtmlParser::with_base_url(url.clone());
745        assert_eq!(parser.base_url(), Some(&url));
746    }
747
748    // ── 便捷函数 ──
749
750    #[test]
751    fn 便捷解析函数() {
752        let result = parse(SAMPLE_HTML).unwrap();
753        assert!(result.metadata.title.is_some());
754    }
755
756    #[test]
757    fn 便捷解析带URL() {
758        let url = Url::parse("https://example.com").unwrap();
759        let result = parse_with_url(SAMPLE_HTML, url).unwrap();
760        assert!(result.links.iter().any(|l| l.url.is_some()));
761    }
762
763    #[test]
764    fn 便捷获取元数据() {
765        let meta = get_metadata(SAMPLE_HTML).unwrap();
766        assert_eq!(meta.author.as_deref(), Some("测试作者"));
767    }
768
769    #[test]
770    fn 便捷获取文本() {
771        let text = get_text(SAMPLE_HTML).unwrap();
772        assert!(text.word_count > 0);
773    }
774
775    #[test]
776    fn 便捷获取链接() {
777        let links = get_links(SAMPLE_HTML).unwrap();
778        assert!(!links.is_empty());
779    }
780
781    // ── resolve_relative_url 辅助函数 ──
782
783    #[test]
784    fn 内部resolve相对路径() {
785        let base = Url::parse("https://example.com/base/").unwrap();
786        assert_eq!(
787            resolve_relative_url(&base, "page.html"),
788            Some("https://example.com/base/page.html".to_string())
789        );
790    }
791
792    #[test]
793    fn 内部resolve根相对路径() {
794        let base = Url::parse("https://example.com/base/").unwrap();
795        assert_eq!(
796            resolve_relative_url(&base, "/page.html"),
797            Some("https://example.com/page.html".to_string())
798        );
799    }
800
801    #[test]
802    fn 内部resolve协议相对() {
803        let base = Url::parse("https://example.com").unwrap();
804        assert_eq!(
805            resolve_relative_url(&base, "//cdn.example.com/file.js"),
806            Some("https://cdn.example.com/file.js".to_string())
807        );
808    }
809
810    #[test]
811    fn 内部resolve跳过空白() {
812        let base = Url::parse("https://example.com").unwrap();
813        assert!(resolve_relative_url(&base, "").is_none());
814        assert!(resolve_relative_url(&base, "  ").is_none());
815    }
816
817    // ── 节点统计 ──
818
819    #[test]
820    fn 统计节点数() {
821        let doc = Html::parse_document("<html><body><p>文本</p></body></html>");
822        assert!(count_nodes(&doc) > 0);
823    }
824
825    #[test]
826    fn 统计元素数() {
827        let doc = Html::parse_document(
828            "<html><head></head><body><p>文本</p><div><span>内联</span></div></body></html>",
829        );
830        let count = count_elements(&doc);
831        // html, head, body, p, div, span = 6
832        assert_eq!(count, 6);
833    }
834
835    #[test]
836    fn 统计文本节点数() {
837        let doc = Html::parse_document("<html><body><p>Hello</p><p>World</p></body></html>");
838        let count = count_text_nodes(&doc);
839        assert_eq!(count, 2);
840    }
841
842    #[test]
843    fn 统计注释数() {
844        let doc = Html::parse_document(
845            "<html><body><!-- 注释一 --><p>文本</p><!-- 注释二 --></body></html>",
846        );
847        let count = count_comments(&doc);
848        assert_eq!(count, 2);
849    }
850
851    // ── 边界情况 ──
852
853    #[test]
854    fn 解析包含特殊字符的HTML() {
855        let html = "<html><body><p>&lt;script&gt;alert('xss')&lt;/script&gt;</p></body></html>";
856        let parser = HtmlParser::new();
857        let result = parser.parse(html).unwrap();
858        assert!(result.text.cleaned_text.contains("alert('xss')"));
859    }
860
861    #[test]
862    fn 解析多语言混合内容() {
863        let html = "<html><body><p>Hello 世界 こんにちは</p></body></html>";
864        let parser = HtmlParser::new();
865        let result = parser.parse(html).unwrap();
866        assert!(result.text.cleaned_text.contains("Hello 世界"));
867    }
868
869    #[test]
870    fn 解析极大段落不崩溃() {
871        let long_text = "A ".repeat(10_000);
872        let html = format!("<html><body><p>{}</p></body></html>", long_text);
873        let parser = HtmlParser::new();
874        let result = parser.parse(&html).unwrap();
875        assert!(result.text.word_count > 0);
876    }
877
878    #[test]
879    fn 配置关闭所有提取() {
880        let config = ParserConfig {
881            extract_images: false,
882            extract_links: false,
883            extract_tables: false,
884            extract_code_blocks: false,
885            extract_structured_data: false,
886            ..ParserConfig::default()
887        };
888        let parser = HtmlParser::with_config(config);
889        let result = parser.parse(SAMPLE_HTML).unwrap();
890        assert!(result.images.is_empty());
891        assert!(result.tables.is_empty());
892        assert!(result.code_blocks.is_empty());
893        assert!(result.structured_data.is_empty());
894    }
895
896    #[test]
897    fn 基础URL解析图片地址() {
898        let url = Url::parse("https://example.com").unwrap();
899        let parser = HtmlParser::with_base_url(url);
900        let result = parser.parse(SAMPLE_HTML).unwrap();
901        let img = &result.images[0];
902        assert_eq!(
903            img.url.as_deref(),
904            Some("https://example.com/images/photo.jpg")
905        );
906    }
907}