1use 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#[derive(Debug, Clone)]
39pub struct HtmlParser {
40 config: ParserConfig,
41}
42
43impl HtmlParser {
44 pub fn new() -> Self {
48 Self {
49 config: ParserConfig::default(),
50 }
51 }
52
53 pub fn with_config(config: ParserConfig) -> Self {
55 Self { config }
56 }
57
58 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 pub fn set_base_url(&mut self, url: Url) {
70 self.config.base_url = Some(url);
71 }
72
73 pub fn config(&self) -> &ParserConfig {
75 &self.config
76 }
77
78 pub fn config_mut(&mut self) -> &mut ParserConfig {
80 &mut self.config
81 }
82
83 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 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 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 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 pub fn extract_headings(&self, html: &str) -> Vec<Heading> {
247 let document = Html::parse_document(html);
248 extract_headings(&document)
249 }
250
251 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 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 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 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 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 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 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 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 pub fn has_base_url(&self) -> bool {
305 self.config.base_url.is_some()
306 }
307
308 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
320pub fn parse(html: &str) -> ParserResult<ParsedContent> {
326 HtmlParser::new().parse(html)
327}
328
329pub 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
335pub fn get_metadata(html: &str) -> ParserResult<PageMetadata> {
337 let parser = HtmlParser::new();
338 parser.extract_metadata(html)
339}
340
341pub fn get_text(html: &str) -> ParserResult<TextContent> {
343 let parser = HtmlParser::new();
344 parser.extract_text(html)
345}
346
347pub fn get_links(html: &str) -> ParserResult<Vec<Link>> {
349 let parser = HtmlParser::new();
350 parser.extract_links(html)
351}
352
353fn 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
373fn count_nodes(document: &Html) -> usize {
375 document.root_element().children().count()
376}
377
378fn 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
393fn 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
409fn 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#[cfg(test)]
432mod tests {
433 use super::*;
434
435 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[test]
851 fn 解析包含特殊字符的HTML() {
852 let html = "<html><body><p><script>alert('xss')</script></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}