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        if let scraper::Node::Text(_) = child.value() { count += 1 }
402        if let Some(child_ref) = scraper::ElementRef::wrap(child) {
403            count += count_text_nodes_recursive(&child_ref);
404        }
405    }
406    count
407}
408
409/// 统计文档中的注释节点数。
410fn count_comments(document: &Html) -> usize {
411    count_comments_recursive(&document.root_element())
412}
413
414fn count_comments_recursive(element: &scraper::ElementRef) -> usize {
415    let mut count = 0;
416    for child in element.children() {
417        if matches!(child.value(), scraper::Node::Comment(_)) {
418            count += 1;
419        }
420        if let Some(child_ref) = scraper::ElementRef::wrap(child) {
421            count += count_comments_recursive(&child_ref);
422        }
423    }
424    count
425}
426
427// ============================================================================
428// 测试
429// ============================================================================
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    /// 包含各类 HTML 结构的示例页面,用于集成测试。
436    const SAMPLE_HTML: &str = r#"
437<!DOCTYPE html>
438<html lang="zh-CN">
439<head>
440    <meta charset="utf-8">
441    <title>示例页面 — 测试用</title>
442    <meta name="description" content="这是一个用于解析测试的示例页面">
443    <meta name="keywords" content="测试, html, 解析">
444    <meta name="author" content="测试作者">
445    <meta property="og:title" content="OG 示例标题">
446    <meta property="og:type" content="article">
447    <meta name="twitter:card" content="summary">
448    <link rel="canonical" href="https://example.com/page">
449    <link rel="icon" href="/favicon.ico">
450    <base href="https://example.com/blog/">
451</head>
452<body>
453    <h1 id="main-title">主标题</h1>
454    <h2 class="section">第一章</h2>
455    <p>这是第一段文字。包含足够长的内容来满足最小段落长度要求。</p>
456    <p>这是第二段文字,同样有足够的内容来通过过滤器。</p>
457
458    <ul>
459        <li>无序列表项一</li>
460        <li>无序列表项二</li>
461        <li>无序列表项三</li>
462    </ul>
463
464    <ol>
465        <li>有序第一</li>
466        <li>有序第二</li>
467    </ol>
468
469    <table>
470        <thead>
471            <tr><th>姓名</th><th>年龄</th></tr>
472        </thead>
473        <tbody>
474            <tr><td>张三</td><td>28</td></tr>
475            <tr><td>李四</td><td>35</td></tr>
476        </tbody>
477    </table>
478
479    <pre><code class="language-rust">fn hello() { println!("你好"); }</code></pre>
480
481    <blockquote cite="https://example.com/quote">
482        这是一段引用文字。
483        <cite>作者名</cite>
484    </blockquote>
485
486    <img src="/images/photo.jpg" alt="示例图片" width="800" height="600">
487
488    <a href="https://example.com/other">外部链接</a>
489    <a href="/relative-page">相对链接</a>
490    <a href="mailto:test@example.com">邮箱链接</a>
491
492    <script type="application/ld+json">
493        {"@type": "Article", "name": "示例文章", "description": "测试"}
494    </script>
495</body>
496</html>
497"#;
498
499    // ── 构造方法 ──
500
501    #[test]
502    fn 默认构造() {
503        let parser = HtmlParser::new();
504        assert!(parser.config().base_url.is_none());
505    }
506
507    #[test]
508    fn 带配置构造() {
509        let config = ParserConfig::minimal();
510        let parser = HtmlParser::with_config(config);
511        assert!(!parser.config().extract_images);
512    }
513
514    #[test]
515    fn 带基础URL构造() {
516        let url = Url::parse("https://example.com").unwrap();
517        let parser = HtmlParser::with_base_url(url);
518        assert!(parser.has_base_url());
519    }
520
521    #[test]
522    fn 设置基础URL() {
523        let mut parser = HtmlParser::new();
524        assert!(!parser.has_base_url());
525        parser.set_base_url(Url::parse("https://example.com").unwrap());
526        assert!(parser.has_base_url());
527    }
528
529    #[test]
530    fn 获取配置引用() {
531        let parser = HtmlParser::new();
532        let config = parser.config();
533        assert!(config.extract_images);
534    }
535
536    #[test]
537    fn 获取配置可变引用() {
538        let mut parser = HtmlParser::new();
539        parser.config_mut().extract_images = false;
540        assert!(!parser.config().extract_images);
541    }
542
543    #[test]
544    fn 默认trait实现() {
545        let parser: HtmlParser = Default::default();
546        assert!(parser.config().extract_images);
547    }
548
549    // ── 完整解析 ──
550
551    #[test]
552    fn 解析完整文档() {
553        let parser = HtmlParser::new();
554        let result = parser.parse(SAMPLE_HTML).unwrap();
555        assert!(result.metadata.title.is_some());
556        assert!(result.text.word_count > 0);
557        assert!(!result.headings.is_empty());
558        assert!(!result.paragraphs.is_empty());
559        assert!(!result.links.is_empty());
560        assert!(!result.images.is_empty());
561        assert!(!result.lists.is_empty());
562        assert!(!result.tables.is_empty());
563        assert!(!result.code_blocks.is_empty());
564        assert!(!result.quotes.is_empty());
565        assert!(!result.structured_data.is_empty());
566    }
567
568    #[test]
569    fn 解析文档统计信息() {
570        let parser = HtmlParser::new();
571        let result = parser.parse(SAMPLE_HTML).unwrap();
572        assert!(result.stats.html_size > 0);
573        assert!(result.stats.parse_time_us > 0);
574        assert!(result.stats.node_count > 0);
575    }
576
577    #[test]
578    fn 解析空文档() {
579        let parser = HtmlParser::new();
580        let result = parser.parse("").unwrap();
581        assert_eq!(result.text.word_count, 0);
582        assert!(result.headings.is_empty());
583    }
584
585    #[test]
586    fn 解析最小化文档() {
587        let parser = HtmlParser::with_config(ParserConfig::minimal());
588        let result = parser.parse(SAMPLE_HTML).unwrap();
589        assert!(result.images.is_empty());
590        assert!(result.tables.is_empty());
591        assert!(result.code_blocks.is_empty());
592        assert!(result.structured_data.is_empty());
593        assert!(result.metadata.title.is_some());
594    }
595
596    // ── 片段解析 ──
597
598    #[test]
599    fn 解析HTML片段() {
600        let parser = HtmlParser::new();
601        let fragment = "<p>这是片段内容。</p><a href=\"https://example.com\">链接</a>";
602        let result = parser.parse_fragment(fragment).unwrap();
603        assert!(result.text.cleaned_text.contains("片段内容"));
604        assert_eq!(result.links.len(), 1);
605    }
606
607    #[test]
608    fn 片段解析无元数据() {
609        let parser = HtmlParser::new();
610        let result = parser.parse_fragment("<p>纯文本</p>").unwrap();
611        assert!(result.metadata.title.is_none());
612    }
613
614    // ── 单项提取 ──
615
616    #[test]
617    fn 单项提取元数据() {
618        let parser = HtmlParser::new();
619        let meta = parser.extract_metadata(SAMPLE_HTML).unwrap();
620        assert_eq!(meta.title.as_deref(), Some("OG 示例标题"));
621        assert!(meta.description.is_some());
622        assert!(!meta.keywords.is_empty());
623    }
624
625    #[test]
626    fn 单项提取文本() {
627        let parser = HtmlParser::new();
628        let text = parser.extract_text(SAMPLE_HTML).unwrap();
629        assert!(text.cleaned_text.contains("主标题"));
630        assert!(text.word_count > 5);
631    }
632
633    #[test]
634    fn 单项提取标题() {
635        let parser = HtmlParser::new();
636        let headings = parser.extract_headings(SAMPLE_HTML);
637        assert_eq!(headings.len(), 2);
638        assert_eq!(headings[0].text, "主标题");
639        assert_eq!(headings[1].text, "第一章");
640    }
641
642    #[test]
643    fn 单项提取链接() {
644        let parser = HtmlParser::new();
645        let links = parser.extract_links(SAMPLE_HTML).unwrap();
646        assert!(!links.is_empty());
647        let has_external = links.iter().any(|l| l.href.contains("example.com/other"));
648        assert!(has_external);
649    }
650
651    #[test]
652    fn 单项提取图片() {
653        let parser = HtmlParser::new();
654        let images = parser.extract_images(SAMPLE_HTML);
655        assert_eq!(images.len(), 1);
656        assert_eq!(images[0].alt, "示例图片");
657    }
658
659    #[test]
660    fn 单项提取列表() {
661        let parser = HtmlParser::new();
662        let lists = parser.extract_lists(SAMPLE_HTML);
663        assert_eq!(lists.len(), 2);
664        let ul = lists.iter().find(|l| l.items.len() == 3);
665        assert!(ul.is_some());
666    }
667
668    #[test]
669    fn 单项提取表格() {
670        let parser = HtmlParser::new();
671        let tables = parser.extract_tables(SAMPLE_HTML);
672        assert_eq!(tables.len(), 1);
673        assert_eq!(tables[0].rows.len(), 2);
674    }
675
676    #[test]
677    fn 单项提取代码块() {
678        let parser = HtmlParser::new();
679        let blocks = parser.extract_code_blocks(SAMPLE_HTML);
680        assert_eq!(blocks.len(), 1);
681        assert_eq!(blocks[0].language.as_deref(), Some("rust"));
682    }
683
684    #[test]
685    fn 单项提取引用() {
686        let parser = HtmlParser::new();
687        let quotes = parser.extract_quotes(SAMPLE_HTML);
688        assert_eq!(quotes.len(), 1);
689    }
690
691    #[test]
692    fn 单项提取结构化数据() {
693        let parser = HtmlParser::new();
694        let data = parser.extract_structured_data(SAMPLE_HTML);
695        assert_eq!(data.len(), 1);
696        assert_eq!(data[0].schema_type.as_deref(), Some("Article"));
697    }
698
699    // ── URL 工具 ──
700
701    #[test]
702    fn 解析相对URL() {
703        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
704        let resolved = parser.resolve_url("/path/to/page");
705        assert_eq!(
706            resolved.as_deref(),
707            Some("https://example.com/path/to/page")
708        );
709    }
710
711    #[test]
712    fn 解析绝对URL() {
713        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
714        let resolved = parser.resolve_url("https://other.com/page");
715        assert_eq!(resolved.as_deref(), Some("https://other.com/page"));
716    }
717
718    #[test]
719    fn 无基础URL时返回None() {
720        let parser = HtmlParser::new();
721        assert!(parser.resolve_url("/page").is_none());
722    }
723
724    #[test]
725    fn 空href返回None() {
726        let parser = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
727        assert!(parser.resolve_url("").is_none());
728    }
729
730    #[test]
731    fn 检查是否有基础URL() {
732        let parser = HtmlParser::new();
733        assert!(!parser.has_base_url());
734        let parser2 = HtmlParser::with_base_url(Url::parse("https://example.com").unwrap());
735        assert!(parser2.has_base_url());
736    }
737
738    #[test]
739    fn 获取基础URL引用() {
740        let url = Url::parse("https://example.com").unwrap();
741        let parser = HtmlParser::with_base_url(url.clone());
742        assert_eq!(parser.base_url(), Some(&url));
743    }
744
745    // ── 便捷函数 ──
746
747    #[test]
748    fn 便捷解析函数() {
749        let result = parse(SAMPLE_HTML).unwrap();
750        assert!(result.metadata.title.is_some());
751    }
752
753    #[test]
754    fn 便捷解析带URL() {
755        let url = Url::parse("https://example.com").unwrap();
756        let result = parse_with_url(SAMPLE_HTML, url).unwrap();
757        assert!(result.links.iter().any(|l| l.url.is_some()));
758    }
759
760    #[test]
761    fn 便捷获取元数据() {
762        let meta = get_metadata(SAMPLE_HTML).unwrap();
763        assert_eq!(meta.author.as_deref(), Some("测试作者"));
764    }
765
766    #[test]
767    fn 便捷获取文本() {
768        let text = get_text(SAMPLE_HTML).unwrap();
769        assert!(text.word_count > 0);
770    }
771
772    #[test]
773    fn 便捷获取链接() {
774        let links = get_links(SAMPLE_HTML).unwrap();
775        assert!(!links.is_empty());
776    }
777
778    // ── resolve_relative_url 辅助函数 ──
779
780    #[test]
781    fn 内部resolve相对路径() {
782        let base = Url::parse("https://example.com/base/").unwrap();
783        assert_eq!(
784            resolve_relative_url(&base, "page.html"),
785            Some("https://example.com/base/page.html".to_string())
786        );
787    }
788
789    #[test]
790    fn 内部resolve根相对路径() {
791        let base = Url::parse("https://example.com/base/").unwrap();
792        assert_eq!(
793            resolve_relative_url(&base, "/page.html"),
794            Some("https://example.com/page.html".to_string())
795        );
796    }
797
798    #[test]
799    fn 内部resolve协议相对() {
800        let base = Url::parse("https://example.com").unwrap();
801        assert_eq!(
802            resolve_relative_url(&base, "//cdn.example.com/file.js"),
803            Some("https://cdn.example.com/file.js".to_string())
804        );
805    }
806
807    #[test]
808    fn 内部resolve跳过空白() {
809        let base = Url::parse("https://example.com").unwrap();
810        assert!(resolve_relative_url(&base, "").is_none());
811        assert!(resolve_relative_url(&base, "  ").is_none());
812    }
813
814    // ── 节点统计 ──
815
816    #[test]
817    fn 统计节点数() {
818        let doc = Html::parse_document("<html><body><p>文本</p></body></html>");
819        assert!(count_nodes(&doc) > 0);
820    }
821
822    #[test]
823    fn 统计元素数() {
824        let doc = Html::parse_document(
825            "<html><head></head><body><p>文本</p><div><span>内联</span></div></body></html>",
826        );
827        let count = count_elements(&doc);
828        // html, head, body, p, div, span = 6
829        assert_eq!(count, 6);
830    }
831
832    #[test]
833    fn 统计文本节点数() {
834        let doc = Html::parse_document("<html><body><p>Hello</p><p>World</p></body></html>");
835        let count = count_text_nodes(&doc);
836        assert_eq!(count, 2);
837    }
838
839    #[test]
840    fn 统计注释数() {
841        let doc = Html::parse_document(
842            "<html><body><!-- 注释一 --><p>文本</p><!-- 注释二 --></body></html>",
843        );
844        let count = count_comments(&doc);
845        assert_eq!(count, 2);
846    }
847
848    // ── 边界情况 ──
849
850    #[test]
851    fn 解析包含特殊字符的HTML() {
852        let html = "<html><body><p>&lt;script&gt;alert('xss')&lt;/script&gt;</p></body></html>";
853        let parser = HtmlParser::new();
854        let result = parser.parse(html).unwrap();
855        assert!(result.text.cleaned_text.contains("alert('xss')"));
856    }
857
858    #[test]
859    fn 解析多语言混合内容() {
860        let html = "<html><body><p>Hello 世界 こんにちは</p></body></html>";
861        let parser = HtmlParser::new();
862        let result = parser.parse(html).unwrap();
863        assert!(result.text.cleaned_text.contains("Hello 世界"));
864    }
865
866    #[test]
867    fn 解析极大段落不崩溃() {
868        let long_text = "A ".repeat(10_000);
869        let html = format!("<html><body><p>{long_text}</p></body></html>");
870        let parser = HtmlParser::new();
871        let result = parser.parse(&html).unwrap();
872        assert!(result.text.word_count > 0);
873    }
874
875    #[test]
876    fn 配置关闭所有提取() {
877        let config = ParserConfig {
878            extract_images: false,
879            extract_links: false,
880            extract_tables: false,
881            extract_code_blocks: false,
882            extract_structured_data: false,
883            ..ParserConfig::default()
884        };
885        let parser = HtmlParser::with_config(config);
886        let result = parser.parse(SAMPLE_HTML).unwrap();
887        assert!(result.images.is_empty());
888        assert!(result.tables.is_empty());
889        assert!(result.code_blocks.is_empty());
890        assert!(result.structured_data.is_empty());
891    }
892
893    #[test]
894    fn 基础URL解析图片地址() {
895        let url = Url::parse("https://example.com").unwrap();
896        let parser = HtmlParser::with_base_url(url);
897        let result = parser.parse(SAMPLE_HTML).unwrap();
898        let img = &result.images[0];
899        assert_eq!(
900            img.url.as_deref(),
901            Some("https://example.com/images/photo.jpg")
902        );
903    }
904}