1use scraper::{Html, ElementRef};
7use url::Url;
8
9use crate::selector::try_parse_selector;
10use crate::types::{
11 Heading, Image, ImageLoading, ListContent, ListType, ListItem,
12 TableContent, TableRow, TableCell, CodeBlock, Quote,
13 ParserConfig,
14};
15
16#[derive(Debug, Clone)]
22pub struct OutlineItem {
23 pub heading: Heading,
25 pub children: Vec<OutlineItem>,
27 pub depth: usize,
29}
30
31impl OutlineItem {
32 pub fn new(heading: Heading) -> Self {
33 Self {
34 heading,
35 children: Vec::new(),
36 depth: 0,
37 }
38 }
39}
40
41pub fn extract_headings(document: &Html) -> Vec<Heading> {
49 let mut headings = Vec::new();
50
51 for level in 1..=6u8 {
52 let selector_str = format!("h{}", level);
53 let selector = match try_parse_selector(&selector_str) {
54 Some(s) => s,
55 None => continue,
56 };
57 for element in document.select(&selector) {
58 let text: String = element
59 .text()
60 .collect::<Vec<_>>()
61 .join("")
62 .trim()
63 .to_string();
64
65 if text.is_empty() {
66 continue;
67 }
68
69 let id = element.value().attr("id").map(ToString::to_string);
70 let classes: Vec<String> = element
71 .value()
72 .classes()
73 .map(|c| c.to_string())
74 .collect();
75
76 headings.push(Heading {
77 level,
78 text,
79 id,
80 classes,
81 });
82 }
83 }
84
85 headings
86}
87
88pub fn get_main_heading(document: &Html) -> Option<Heading> {
90 if let Some(sel) = try_parse_selector("h1") {
92 if let Some(el) = document.select(&sel).next() {
93 let text: String = el
94 .text()
95 .collect::<Vec<_>>()
96 .join("")
97 .trim()
98 .to_string();
99 if !text.is_empty() {
100 let id = el.value().attr("id").map(ToString::to_string);
101 let classes: Vec<String> = el
102 .value()
103 .classes()
104 .map(|c| c.to_string())
105 .collect();
106 return Some(Heading {
107 level: 1,
108 text,
109 id,
110 classes,
111 });
112 }
113 }
114 }
115
116 if let Some(sel) = try_parse_selector("h2") {
118 if let Some(el) = document.select(&sel).next() {
119 let text: String = el
120 .text()
121 .collect::<Vec<_>>()
122 .join("")
123 .trim()
124 .to_string();
125 if !text.is_empty() {
126 let id = el.value().attr("id").map(ToString::to_string);
127 let classes: Vec<String> = el
128 .value()
129 .classes()
130 .map(|c| c.to_string())
131 .collect();
132 return Some(Heading {
133 level: 2,
134 text,
135 id,
136 classes,
137 });
138 }
139 }
140 }
141
142 None
143}
144
145pub fn build_outline(headings: &[Heading]) -> Vec<OutlineItem> {
149 fn build(items: &[Heading], min_level: u8, depth: usize) -> (Vec<OutlineItem>, usize) {
150 let mut result = Vec::new();
151 let mut i = 0;
152 while i < items.len() {
153 let h = &items[i];
154 if h.level < min_level {
155 break;
156 }
157 let mut item = OutlineItem::new(h.clone());
158 item.depth = depth;
159 let (children, consumed) = build(&items[i + 1..], h.level + 1, depth + 1);
160 item.children = children;
161 i += 1 + consumed;
162 result.push(item);
163 }
164 (result, i)
165 }
166
167 let (outline, _) = build(headings, 1, 0);
168 outline
169}
170
171pub fn extract_paragraphs(document: &Html, min_length: usize) -> Vec<String> {
179 let selector = match try_parse_selector("p") {
180 Some(s) => s,
181 None => return Vec::new(),
182 };
183
184 let mut paragraphs = Vec::new();
185
186 for element in document.select(&selector) {
187 let text: String = element
188 .text()
189 .collect::<Vec<_>>()
190 .join("")
191 .trim()
192 .to_string();
193
194 if text.len() >= min_length {
195 paragraphs.push(text);
196 }
197 }
198
199 paragraphs
200}
201
202pub fn extract_lists(document: &Html, config: &ParserConfig) -> Vec<ListContent> {
210 let mut lists = Vec::new();
211
212 for list_tag in &["ul", "ol"] {
214 let selector = match try_parse_selector(list_tag) {
215 Some(s) => s,
216 None => continue,
217 };
218 for element in document.select(&selector) {
219 let parent = element.parent();
221 if let Some(parent_id) = parent {
222 if let Some(parent_ref) = ElementRef::wrap(parent_id) {
223 if parent_ref.value().name() == "li" {
224 continue;
225 }
226 }
227 }
228 let list = extract_list(&element, config);
229 if !list.is_empty() {
230 lists.push(list);
231 }
232 }
233 }
234
235 if let Some(sel) = try_parse_selector("dl") {
237 for element in document.select(&sel) {
238 let list = extract_definition_list(&element, config);
239 if !list.is_empty() {
240 lists.push(list);
241 }
242 }
243 }
244
245 lists
246}
247
248pub fn extract_list(element: &ElementRef, _config: &ParserConfig) -> ListContent {
250 let tag_name = element.value().name();
251 let list_type = match tag_name {
252 "ol" => ListType::Ordered,
253 _ => ListType::Unordered,
254 };
255
256 let mut list = ListContent::new(list_type);
257
258 for child in element.children() {
259 let child_ref = match ElementRef::wrap(child) {
260 Some(r) => r,
261 None => continue,
262 };
263 if child_ref.value().name() != "li" {
264 continue;
265 }
266 if let Some(item) = extract_list_item(&child_ref) {
267 list.add_item(item);
268 }
269 }
270
271 list
272}
273
274pub fn extract_list_item(element: &ElementRef) -> Option<ListItem> {
276 let mut text_parts = Vec::new();
277 let mut nested_list: Option<ListContent> = None;
278 let mut has_text = false;
279
280 for child in element.children() {
281 let child_ref = match ElementRef::wrap(child) {
282 Some(r) => r,
283 None => {
284 if let scraper::Node::Text(t) = child.value() {
286 let t = t.text.trim();
287 if !t.is_empty() {
288 text_parts.push(t.to_string());
289 has_text = true;
290 }
291 }
292 continue;
293 }
294 };
295
296 let name = child_ref.value().name();
297 match name {
298 "ul" | "ol" => {
299 let lt = if name == "ol" {
300 ListType::Ordered
301 } else {
302 ListType::Unordered
303 };
304 let mut nl = ListContent::new(lt);
305 for subchild in child_ref.children() {
306 if let Some(sub_ref) = ElementRef::wrap(subchild) {
307 if sub_ref.value().name() == "li" {
308 if let Some(item) = extract_list_item(&sub_ref) {
309 nl.add_item(item);
310 }
311 }
312 }
313 }
314 if !nl.is_empty() {
315 nested_list = Some(nl);
316 }
317 }
318 _ => {
319 let t: String = child_ref
320 .text()
321 .collect::<Vec<_>>()
322 .join("")
323 .trim()
324 .to_string();
325 if !t.is_empty() {
326 text_parts.push(t);
327 has_text = true;
328 }
329 }
330 }
331 }
332
333 if !has_text {
334 return None;
335 }
336
337 let text = text_parts.join(" ");
338
339 if let Some(nl) = nested_list {
340 Some(ListItem::with_nested(text, nl))
341 } else {
342 Some(ListItem::new(text))
343 }
344}
345
346pub fn extract_definition_list(element: &ElementRef, _config: &ParserConfig) -> ListContent {
348 let mut list = ListContent::new(ListType::Definition);
349
350 let mut current_term: Option<String> = None;
351
352 for child in element.children() {
353 let child_ref = match ElementRef::wrap(child) {
354 Some(r) => r,
355 None => continue,
356 };
357
358 let name = child_ref.value().name();
359 match name {
360 "dt" => {
361 let text: String = child_ref
362 .text()
363 .collect::<Vec<_>>()
364 .join("")
365 .trim()
366 .to_string();
367 if !text.is_empty() {
368 current_term = Some(text);
369 }
370 }
371 "dd" => {
372 let text: String = child_ref
373 .text()
374 .collect::<Vec<_>>()
375 .join("")
376 .trim()
377 .to_string();
378 if !text.is_empty() {
379 let item_text = if let Some(ref term) = current_term {
380 format!("{}: {}", term, text)
381 } else {
382 text
383 };
384 list.add_item(ListItem::new(item_text));
385 }
386 }
387 _ => {}
388 }
389 }
390
391 list
392}
393
394pub fn extract_tables(document: &Html, config: &ParserConfig) -> Vec<TableContent> {
400 let selector = match try_parse_selector("table") {
401 Some(s) => s,
402 None => return Vec::new(),
403 };
404
405 let mut tables = Vec::new();
406
407 for element in document.select(&selector) {
408 let table = extract_table(&element, config);
409 if !table.is_empty() {
410 tables.push(table);
411 }
412 }
413
414 tables
415}
416
417pub fn extract_table(element: &ElementRef, _config: &ParserConfig) -> TableContent {
419 let mut table = TableContent::new();
420
421 if let Some(caption_sel) = try_parse_selector("caption") {
423 if let Some(caption_el) = element.select(&caption_sel).next() {
424 let caption: String = caption_el
425 .text()
426 .collect::<Vec<_>>()
427 .join("")
428 .trim()
429 .to_string();
430 if !caption.is_empty() {
431 table.caption = Some(caption);
432 }
433 }
434 }
435
436 if let Some(thead_sel) = try_parse_selector("thead") {
438 let mut header_rows = Vec::new();
439 if let Some(thead_el) = element.select(&thead_sel).next() {
440 for row_el in thead_el.child_elements() {
441 let row = extract_table_row(&row_el);
442 if !row.cells.is_empty() {
443 header_rows.push(row);
444 }
445 }
446 }
447 table.headers = header_rows;
448 }
449
450 let mut rows = Vec::new();
452 if let Some(tbody_sel) = try_parse_selector("tbody") {
453 if let Some(tbody_el) = element.select(&tbody_sel).next() {
454 for row_el in tbody_el.child_elements() {
455 if row_el.value().name() == "tr" {
456 let row = extract_table_row(&row_el);
457 if !row.cells.is_empty() {
458 rows.push(row);
459 }
460 }
461 }
462 } else {
463 for child in element.children() {
465 if let Some(row_ref) = ElementRef::wrap(child) {
466 if row_ref.value().name() == "tr" {
467 let row = extract_table_row(&row_ref);
468 if !row.cells.is_empty() {
469 rows.push(row);
470 }
471 }
472 }
473 }
474 }
475 }
476
477 table.rows = rows;
478
479 let max_cols = table
481 .headers
482 .iter()
483 .chain(table.rows.iter())
484 .map(|r| r.cells.len())
485 .max()
486 .unwrap_or(0);
487 table.column_count = max_cols;
488
489 table
490}
491
492pub fn extract_table_row(element: &ElementRef) -> TableRow {
494 let mut cells = Vec::new();
495
496 for child in element.children() {
497 let child_ref = match ElementRef::wrap(child) {
498 Some(r) => r,
499 None => continue,
500 };
501
502 let name = child_ref.value().name();
503 if name != "td" && name != "th" {
504 continue;
505 }
506
507 let cell = extract_table_cell(&child_ref);
508 cells.push(cell);
509 }
510
511 TableRow::new(cells)
512}
513
514pub fn extract_table_cell(element: &ElementRef) -> TableCell {
516 let is_header = element.value().name() == "th";
517
518 let content: String = element
519 .text()
520 .collect::<Vec<_>>()
521 .join("")
522 .trim()
523 .to_string();
524
525 let colspan = element
526 .value()
527 .attr("colspan")
528 .and_then(|v| v.parse::<u32>().ok())
529 .unwrap_or(1);
530
531 let rowspan = element
532 .value()
533 .attr("rowspan")
534 .and_then(|v| v.parse::<u32>().ok())
535 .unwrap_or(1);
536
537 TableCell {
538 content,
539 is_header,
540 colspan,
541 rowspan,
542 }
543}
544
545const KNOWN_LANGUAGES: &[&str] = &[
551 "abap", "actionscript", "ada", "apache", "applescript", "arduino",
552 "asm", "asp", "assembly", "awk", "bash", "basic", "batch", "bison",
553 "c", "c++", "c#", "csharp", "caml", "clojure", "cmake", "coffeescript",
554 "cpp", "crystal", "cql", "css", "cuda", "cypher", "d", "dart",
555 "delphi", "diff", "django", "docker", "dot", "elixir", "elm",
556 "emacs", "erlang", "excel", "f#", "fsharp", "fasta", "fish",
557 "flow", "fortran", "fsharp", "gams", "gcode", "gdscript", "genie",
558 "gherkin", "git", "glsl", "glyph", "gnuplot", "go", "golo",
559 "gradle", "graphql", "groovy", "haskell", "haxe", "hcl", "hlsl",
560 "html", "http", "hy", "idl", "ini", "io", "java", "javascript",
561 "jinja", "jq", "json", "julia", "kotlin", "latex", "less", "lilypond",
562 "lisp", "livescript", "llvm", "lua", "m4", "makefile", "markdown",
563 "mathematica", "matlab", "mcfunction", "meson", "minizinc", "mips",
564 "modelica", "mojo", "mql4", "mql5", "msl", "mysql", "nasm", "nginx",
565 "nim", "nix", "node", "numpy", "ocaml", "octave", "odin", "opencl",
566 "opengl", "pascal", "perl", "php", "pkl", "plsql", "postgresql",
567 "powershell", "processing", "prolog", "promql", "protobuf", "pug",
568 "puppet", "purebasic", "pyramid", "python", "q", "qml", "r",
569 "racket", "ragel", "rasql", "reason", "rebol", "red", "restructuredtext",
570 "riscv", "robot", "ruby", "rust", "sas", "sass", "scala", "scheme",
571 "scilab", "scss", "shell", "smalltalk", "smarty", "snakemake", "solidity",
572 "sparql", "sql", "stan", "stata", "stylus", "svelte", "swift",
573 "systemverilog", "tcl", "terraform", "tex", "text", "thrift", "toml",
574 "turing", "twig", "typescript", "typoscript", "v", "vala", "vbnet",
575 "verilog", "vhdl", "vim", "visualbasic", "vue", "wasm", "wdl",
576 "webassembly", "wolfram", "x86asm", "xaml", "xml", "xsl", "yaml",
577 "yang", "yara", "zephir", "zig",
578];
579
580pub fn is_known_language(lang: &str) -> bool {
582 let lang = lang.trim().to_lowercase();
583 KNOWN_LANGUAGES.contains(&lang.as_str())
584}
585
586pub fn extract_code_blocks(document: &Html, _config: &ParserConfig) -> Vec<CodeBlock> {
588 let mut blocks = Vec::new();
589
590 let pre_selector = match try_parse_selector("pre") {
592 Some(s) => s,
593 None => return blocks,
594 };
595
596 for pre_element in document.select(&pre_selector) {
597 if let Some(code_el) = pre_element.child_elements().next() {
598 if code_el.value().name() == "code" {
599 let block = extract_code_block(&code_el, false);
600 blocks.push(block);
601 }
602 }
603 }
604
605 if let Some(code_sel) = try_parse_selector("code") {
607 for element in document.select(&code_sel) {
608 let parent = element.parent();
610 if let Some(parent_id) = parent {
611 if let Some(parent_ref) = ElementRef::wrap(parent_id) {
612 if parent_ref.value().name() == "pre" {
613 continue;
614 }
615 }
616 }
617
618 let block = extract_code_block(&element, true);
619 blocks.push(block);
620 }
621 }
622
623 blocks
624}
625
626pub fn extract_code_block(element: &ElementRef, is_inline: bool) -> CodeBlock {
628 let code: String = element
629 .text()
630 .collect::<Vec<_>>()
631 .join("")
632 .trim()
633 .to_string();
634
635 let language = element
637 .value()
638 .attr("class")
639 .and_then(|cls| {
640 for part in cls.split_whitespace() {
642 if let Some(lang) = part.strip_prefix("language-") {
643 if is_known_language(lang) {
644 return Some(lang.to_string());
645 }
646 }
647 if let Some(lang) = part.strip_prefix("lang-") {
648 if is_known_language(lang) {
649 return Some(lang.to_string());
650 }
651 }
652 if is_known_language(part) {
653 return Some(part.to_string());
654 }
655 }
656 None
657 })
658 .or_else(|| {
659 element
660 .value()
661 .attr("data-lang")
662 .filter(|l| is_known_language(l))
663 .map(ToString::to_string)
664 });
665
666 let filename = element
667 .value()
668 .attr("data-filename")
669 .or_else(|| element.value().attr("filename"))
670 .map(ToString::to_string);
671
672 let line_count = code.lines().count();
673
674 CodeBlock {
675 code,
676 language,
677 line_count,
678 is_inline,
679 filename,
680 }
681}
682
683pub fn extract_quotes(document: &Html, _config: &ParserConfig) -> Vec<Quote> {
689 let selector = match try_parse_selector("blockquote") {
690 Some(s) => s,
691 None => return Vec::new(),
692 };
693
694 let mut quotes = Vec::new();
695
696 for element in document.select(&selector) {
697 let text: String = element
698 .text()
699 .collect::<Vec<_>>()
700 .join("")
701 .trim()
702 .to_string();
703
704 if text.is_empty() || text.len() < 10 {
705 continue;
706 }
707
708 let cite = element
709 .value()
710 .attr("cite")
711 .map(ToString::to_string);
712
713 let cite_url = if let Some(cite_sel) = try_parse_selector("cite") {
715 element
716 .select(&cite_sel)
717 .next()
718 .and_then(|el| {
719 let cite_text: String = el
720 .text()
721 .collect::<Vec<_>>()
722 .join("")
723 .trim()
724 .to_string();
725 if cite_text.is_empty() { None } else { Some(cite_text) }
726 })
727 } else {
728 None
729 };
730
731 let mut quote = Quote::new(text);
732 if let Some(c) = cite {
733 quote.cite = Some(c);
734 }
735 if let Some(cu) = cite_url {
736 quote.cite_url = Some(cu);
737 }
738
739 quotes.push(quote);
740 }
741
742 quotes
743}
744
745pub fn extract_images(document: &Html, base_url: Option<&Url>) -> Vec<Image> {
751 let selector = match try_parse_selector("img") {
752 Some(s) => s,
753 None => return Vec::new(),
754 };
755
756 let mut images = Vec::new();
757
758 for element in document.select(&selector) {
759 if let Some(image) = extract_image(&element, base_url) {
760 images.push(image);
761 }
762 }
763
764 images
765}
766
767pub fn extract_image(element: &ElementRef, base_url: Option<&Url>) -> Option<Image> {
769 let src = element.value().attr("src")?;
770 let src = src.trim();
771
772 if src.is_empty() {
773 return None;
774 }
775
776 if src.starts_with("data:") {
778 return None;
779 }
780
781 let resolved_src = base_url.and_then(|base| resolve_image_url(base, src));
782
783 let alt = element
784 .value()
785 .attr("alt")
786 .unwrap_or("")
787 .to_string();
788
789 let title = element
790 .value()
791 .attr("title")
792 .map(ToString::to_string);
793
794 let width = element
795 .value()
796 .attr("width")
797 .and_then(|w| w.parse::<u32>().ok());
798
799 let height = element
800 .value()
801 .attr("height")
802 .and_then(|h| h.parse::<u32>().ok());
803
804 let srcset = element
805 .value()
806 .attr("srcset")
807 .map(ToString::to_string);
808
809 let sizes = element
810 .value()
811 .attr("sizes")
812 .map(ToString::to_string);
813
814 let loading = element
815 .value()
816 .attr("loading")
817 .map(|v| match v {
818 "lazy" => ImageLoading::Lazy,
819 _ => ImageLoading::Eager,
820 })
821 .unwrap_or_default();
822
823 let is_decorative = alt.is_empty();
824
825 let mut image = Image {
826 src: src.to_string(),
827 url: resolved_src,
828 alt,
829 title,
830 width,
831 height,
832 srcset,
833 sizes,
834 loading,
835 is_decorative,
836 };
837
838 if image.srcset.is_some() && image.url.is_none() && image.src.starts_with('/') {
839 if let Some(base) = base_url {
841 image.url = resolve_image_url(base, &image.src);
842 }
843 }
844
845 Some(image)
846}
847
848pub fn resolve_image_url(base: &Url, src: &str) -> Option<String> {
850 if src.starts_with("http://") || src.starts_with("https://") {
851 return Some(src.to_string());
852 }
853
854 if src.starts_with("//") {
855 let scheme = base.scheme();
857 return Some(format!("{}:{}", scheme, src));
858 }
859
860 base.join(src).ok().map(|u| u.to_string())
861}
862
863#[cfg(test)]
868mod tests {
869 use super::*;
870
871 fn create_document(html: &str) -> Html {
872 Html::parse_document(html)
873 }
874
875 fn default_config() -> ParserConfig {
876 ParserConfig::default()
877 }
878
879 #[test]
882 fn test_extract_headings_basic() {
883 let html = r#"<html><body>
884 <h1 id="main">主标题</h1>
885 <h2 class="section">章节一</h2>
886 <h3>子章节</h3>
887 <h2>章节二</h2>
888 </body></html>"#;
889 let doc = create_document(html);
890 let headings = extract_headings(&doc);
891
892 assert_eq!(headings.len(), 4);
893 assert_eq!(headings[0].level, 1);
894 assert_eq!(headings[0].text, "主标题");
895 assert_eq!(headings[0].id.as_deref(), Some("main"));
896 assert_eq!(headings[1].level, 2);
897 assert_eq!(headings[1].text, "章节一");
898 assert!(headings[1].classes.contains(&"section".to_string()));
899 }
900
901 #[test]
902 fn test_extract_headings_empty() {
903 let html = r#"<html><body><p>没有标题</p></body></html>"#;
904 let doc = create_document(html);
905 let headings = extract_headings(&doc);
906 assert!(headings.is_empty());
907 }
908
909 #[test]
910 fn test_extract_headings_ignores_empty() {
911 let html = r#"<html><body><h1> </h1><h2>内容</h2></body></html>"#;
912 let doc = create_document(html);
913 let headings = extract_headings(&doc);
914 assert_eq!(headings.len(), 1);
915 assert_eq!(headings[0].text, "内容");
916 }
917
918 #[test]
919 fn test_get_main_heading_from_h1() {
920 let html = r#"<html><body>
921 <h1 id="title">页面标题</h1>
922 <h2>次要标题</h2>
923 </body></html>"#;
924 let doc = create_document(html);
925 let main = get_main_heading(&doc);
926 assert!(main.is_some());
927 assert_eq!(main.unwrap().text, "页面标题");
928 }
929
930 #[test]
931 fn test_get_main_heading_fallback_to_h2() {
932 let html = r#"<html><body><h2>次级标题</h2></body></html>"#;
933 let doc = create_document(html);
934 let main = get_main_heading(&doc);
935 assert!(main.is_some());
936 assert_eq!(main.unwrap().level, 2);
937 }
938
939 #[test]
940 fn test_get_main_heading_none() {
941 let html = r#"<html><body><p>无标题</p></body></html>"#;
942 let doc = create_document(html);
943 let main = get_main_heading(&doc);
944 assert!(main.is_none());
945 }
946
947 #[test]
948 fn test_build_outline_simple() {
949 let headings = vec![
950 Heading::new(1, "H1").with_id("a"),
951 Heading::new(2, "H2-1").with_id("b"),
952 Heading::new(2, "H2-2").with_id("c"),
953 Heading::new(3, "H3").with_id("d"),
954 ];
955 let outline = build_outline(&headings);
956 assert_eq!(outline.len(), 1);
957 assert_eq!(outline[0].heading.text, "H1");
958 assert_eq!(outline[0].children.len(), 2);
959 assert_eq!(outline[0].children[0].children.len(), 0);
960 assert_eq!(outline[0].children[1].children.len(), 1);
961 assert_eq!(outline[0].children[1].children[0].heading.text, "H3");
962 }
963
964 #[test]
965 fn test_build_outline_multiple_h1() {
966 let headings = vec![
967 Heading::new(1, "第一部分"),
968 Heading::new(2, "细节"),
969 Heading::new(1, "第二部分"),
970 ];
971 let outline = build_outline(&headings);
972 assert_eq!(outline.len(), 2);
973 assert_eq!(outline[0].heading.text, "第一部分");
974 assert_eq!(outline[1].heading.text, "第二部分");
975 }
976
977 #[test]
980 fn test_extract_paragraphs_basic() {
981 let html = r#"<html><body>
982 <p>第一段文字内容。</p>
983 <p>第二段文字内容,长度足够长。</p>
984 <p>短</p>
985 </body></html>"#;
986 let doc = create_document(html);
987 let paragraphs = extract_paragraphs(&doc, 5);
988 assert_eq!(paragraphs.len(), 2);
989 assert!(paragraphs[0].contains("第一段"));
990 }
991
992 #[test]
993 fn test_extract_paragraphs_empty() {
994 let html = r#"<html><body></body></html>"#;
995 let doc = create_document(html);
996 let paragraphs = extract_paragraphs(&doc, 1);
997 assert!(paragraphs.is_empty());
998 }
999
1000 #[test]
1003 fn test_extract_lists_unordered() {
1004 let html = r#"<html><body>
1005 <ul>
1006 <li>苹果</li>
1007 <li>香蕉</li>
1008 <li>樱桃</li>
1009 </ul>
1010 </body></html>"#;
1011 let doc = create_document(html);
1012 let lists = extract_lists(&doc, &default_config());
1013 assert_eq!(lists.len(), 1);
1014 assert_eq!(lists[0].list_type, ListType::Unordered);
1015 assert_eq!(lists[0].items.len(), 3);
1016 assert_eq!(lists[0].items[0].text, "苹果");
1017 }
1018
1019 #[test]
1020 fn test_extract_lists_ordered() {
1021 let html = r#"<html><body>
1022 <ol>
1023 <li>第一</li>
1024 <li>第二</li>
1025 </ol>
1026 </body></html>"#;
1027 let doc = create_document(html);
1028 let lists = extract_lists(&doc, &default_config());
1029 assert_eq!(lists.len(), 1);
1030 assert_eq!(lists[0].list_type, ListType::Ordered);
1031 }
1032
1033 #[test]
1034 fn test_extract_list_with_nested() {
1035 let html = r#"<html><body>
1036 <ul>
1037 <li>
1038 水果
1039 <ul>
1040 <li>苹果</li>
1041 <li>香蕉</li>
1042 </ul>
1043 </li>
1044 <li>蔬菜</li>
1045 </ul>
1046 </body></html>"#;
1047 let doc = create_document(html);
1048 let lists = extract_lists(&doc, &default_config());
1049 assert_eq!(lists.len(), 1);
1050 assert_eq!(lists[0].items.len(), 2);
1051 let nested = &lists[0].items[0];
1052 assert!(nested.nested.is_some());
1053 let inner = nested.nested.as_ref().unwrap();
1054 assert_eq!(inner.items.len(), 2);
1055 assert_eq!(inner.items[0].text, "苹果");
1056 }
1057
1058 #[test]
1059 fn test_extract_list_skips_empty_items() {
1060 let html = r#"<html><body>
1061 <ul>
1062 <li></li>
1063 <li>有效项</li>
1064 </ul>
1065 </body></html>"#;
1066 let doc = create_document(html);
1067 let lists = extract_lists(&doc, &default_config());
1068 assert_eq!(lists[0].items.len(), 1);
1069 }
1070
1071 #[test]
1072 fn test_extract_definition_list_basic() {
1073 let html = r#"<html><body>
1074 <dl>
1075 <dt>HTML</dt>
1076 <dd>超文本标记语言</dd>
1077 <dt>CSS</dt>
1078 <dd>层叠样式表</dd>
1079 </dl>
1080 </body></html>"#;
1081 let doc = create_document(html);
1082 let lists = extract_lists(&doc, &default_config());
1083 assert_eq!(lists.len(), 1);
1084 assert_eq!(lists[0].list_type, ListType::Definition);
1085 assert_eq!(lists[0].items.len(), 2);
1086 assert!(lists[0].items[0].text.contains("HTML"));
1087 assert!(lists[0].items[0].text.contains("超文本标记语言"));
1088 }
1089
1090 #[test]
1091 fn test_extract_definition_list_dt_only() {
1092 let html = r#"<html><body>
1093 <dl>
1094 <dt>术语一</dt>
1095 <dt>术语二</dt>
1096 </dl>
1097 </body></html>"#;
1098 let doc = create_document(html);
1099 let lists = extract_lists(&doc, &default_config());
1100 assert!(lists.is_empty() || lists[0].items.is_empty());
1101 }
1102
1103 #[test]
1106 fn test_extract_tables_basic() {
1107 let html = r#"<html><body>
1108 <table>
1109 <thead>
1110 <tr><th>姓名</th><th>年龄</th></tr>
1111 </thead>
1112 <tbody>
1113 <tr><td>张三</td><td>25</td></tr>
1114 <tr><td>李四</td><td>30</td></tr>
1115 </tbody>
1116 </table>
1117 </body></html>"#;
1118 let doc = create_document(html);
1119 let tables = extract_tables(&doc, &default_config());
1120 assert_eq!(tables.len(), 1);
1121 assert_eq!(tables[0].headers.len(), 1);
1122 assert_eq!(tables[0].rows.len(), 2);
1123 assert_eq!(tables[0].column_count, 2);
1124 assert_eq!(tables[0].rows[0].cells[0].content, "张三");
1125 }
1126
1127 #[test]
1128 fn test_extract_tables_no_thead() {
1129 let html = r#"<html><body>
1130 <table>
1131 <tr><td>A</td><td>B</td></tr>
1132 <tr><td>C</td><td>D</td></tr>
1133 </table>
1134 </body></html>"#;
1135 let doc = create_document(html);
1136 let tables = extract_tables(&doc, &default_config());
1137 assert_eq!(tables.len(), 1);
1138 assert_eq!(tables[0].rows.len(), 2);
1139 }
1140
1141 #[test]
1142 fn test_extract_table_cell_with_colspan() {
1143 let html = r#"<html><body><table>
1144 <tr><td colspan="2">合并单元格</td></tr>
1145 </table></body></html>"#;
1146 let doc = create_document(html);
1147 let tables = extract_tables(&doc, &default_config());
1148 assert_eq!(tables[0].rows[0].cells[0].colspan, 2);
1149 }
1150
1151 #[test]
1152 fn test_extract_tables_empty() {
1153 let doc = create_document("<html></html>");
1154 let tables = extract_tables(&doc, &default_config());
1155 assert!(tables.is_empty());
1156 }
1157
1158 #[test]
1159 fn test_extract_table_with_caption() {
1160 let html = r#"<html><body><table>
1161 <caption>用户信息表</caption>
1162 <tr><th>ID</th><th>名称</th></tr>
1163 </table></body></html>"#;
1164 let doc = create_document(html);
1165 let tables = extract_tables(&doc, &default_config());
1166 assert_eq!(tables[0].caption.as_deref(), Some("用户信息表"));
1167 }
1168
1169 #[test]
1172 fn test_extract_code_blocks_pre_code() {
1173 let html = r#"<html><body>
1174 <pre><code class="language-rust">fn main() { println!("hello"); }</code></pre>
1175 </body></html>"#;
1176 let doc = create_document(html);
1177 let blocks = extract_code_blocks(&doc, &default_config());
1178 assert_eq!(blocks.len(), 1);
1179 assert!(!blocks[0].is_inline);
1180 assert_eq!(blocks[0].language.as_deref(), Some("rust"));
1181 assert!(blocks[0].code.contains("fn main"));
1182 }
1183
1184 #[test]
1185 fn test_extract_code_blocks_inline() {
1186 let html = r#"<html><body>
1187 <p>使用 <code>println!</code> 宏输出</p>
1188 </body></html>"#;
1189 let doc = create_document(html);
1190 let blocks = extract_code_blocks(&doc, &default_config());
1191 let inline_blocks: Vec<_> = blocks.iter().filter(|b| b.is_inline).collect();
1192 assert!(!inline_blocks.is_empty());
1193 }
1194
1195 #[test]
1196 fn test_extract_code_blocks_skips_inline_inside_pre() {
1197 let html = r#"<html><body>
1198 <pre><code>块级代码</code></pre>
1199 <p><code>内联代码</code></p>
1200 </body></html>"#;
1201 let doc = create_document(html);
1202 let blocks = extract_code_blocks(&doc, &default_config());
1203 let inline_blocks: Vec<_> = blocks.iter().filter(|b| b.is_inline).collect();
1204 let pre_blocks: Vec<_> = blocks.iter().filter(|b| !b.is_inline).collect();
1205 assert_eq!(pre_blocks.len(), 1);
1206 assert_eq!(inline_blocks.len(), 1);
1207 }
1208
1209 #[test]
1210 fn test_extract_code_block_with_data_lang() {
1211 let html = r#"<pre><code data-lang="python">print("hello")</code></pre>"#;
1212 let doc = create_document(html);
1213 let blocks = extract_code_blocks(&doc, &default_config());
1214 assert_eq!(blocks[0].language.as_deref(), Some("python"));
1215 }
1216
1217 #[test]
1218 fn test_extract_code_block_with_filename() {
1219 let html = r#"<pre><code data-filename="main.rs">fn main() {}</code></pre>"#;
1220 let doc = create_document(html);
1221 let blocks = extract_code_blocks(&doc, &default_config());
1222 assert_eq!(blocks[0].filename.as_deref(), Some("main.rs"));
1223 }
1224
1225 #[test]
1226 fn test_is_known_language_positive() {
1227 assert!(is_known_language("rust"));
1228 assert!(is_known_language("python"));
1229 assert!(is_known_language("javascript"));
1230 assert!(is_known_language("Rust"));
1231 assert!(is_known_language(" python "));
1232 }
1233
1234 #[test]
1235 fn test_is_known_language_negative() {
1236 assert!(!is_known_language("foobarbaz"));
1237 assert!(!is_known_language(""));
1238 assert!(!is_known_language(" "));
1239 }
1240
1241 #[test]
1244 fn test_extract_quotes_basic() {
1245 let html = r#"<html><body>
1246 <blockquote cite="https://example.com">
1247 这是一段引用文字。
1248 </blockquote>
1249 </body></html>"#;
1250 let doc = create_document(html);
1251 let quotes = extract_quotes(&doc, &default_config());
1252 assert_eq!(quotes.len(), 1);
1253 assert_eq!(quotes[0].text.trim(), "这是一段引用文字。");
1254 assert_eq!(quotes[0].cite.as_deref(), Some("https://example.com"));
1255 }
1256
1257 #[test]
1258 fn test_extract_quotes_with_inner_cite() {
1259 let html = r#"<html><body>
1260 <blockquote>
1261 知识就是力量。
1262 <cite>弗朗西斯·培根</cite>
1263 </blockquote>
1264 </body></html>"#;
1265 let doc = create_document(html);
1266 let quotes = extract_quotes(&doc, &default_config());
1267 assert!(quotes[0].cite_url.is_some());
1268 }
1269
1270 #[test]
1271 fn test_extract_quotes_filters_short() {
1272 let html = r#"<html><body>
1273 <blockquote>短</blockquote>
1274 <blockquote>这是一段足够长的引用文字,长度超过十个字符。</blockquote>
1275 </body></html>"#;
1276 let doc = create_document(html);
1277 let quotes = extract_quotes(&doc, &default_config());
1278 assert_eq!(quotes.len(), 1);
1279 }
1280
1281 #[test]
1282 fn test_extract_quotes_empty() {
1283 let doc = create_document("<html></html>");
1284 let quotes = extract_quotes(&doc, &default_config());
1285 assert!(quotes.is_empty());
1286 }
1287
1288 #[test]
1291 fn test_extract_images_basic() {
1292 let html = r#"<html><body>
1293 <img src="/images/photo.jpg" alt="风景照" width="800" height="600">
1294 </body></html>"#;
1295 let base = Url::parse("https://example.com").ok();
1296 let doc = create_document(html);
1297 let images = extract_images(&doc, base.as_ref());
1298 assert_eq!(images.len(), 1);
1299 assert_eq!(images[0].src, "/images/photo.jpg");
1300 assert_eq!(images[0].alt, "风景照");
1301 assert_eq!(images[0].width, Some(800));
1302 assert_eq!(images[0].height, Some(600));
1303 }
1304
1305 #[test]
1306 fn test_extract_images_resolves_url() {
1307 let html = r#"<html><body><img src="/images/pic.jpg" alt="图片"></body></html>"#;
1308 let base = Url::parse("https://example.com/blog/").ok();
1309 let doc = create_document(html);
1310 let images = extract_images(&doc, base.as_ref());
1311 assert_eq!(
1312 images[0].url.as_deref(),
1313 Some("https://example.com/images/pic.jpg")
1314 );
1315 }
1316
1317 #[test]
1318 fn test_extract_images_absolute_url() {
1319 let html = r#"<html><body>
1320 <img src="https://cdn.example.com/logo.png" alt="Logo">
1321 </body></html>"#;
1322 let base = Url::parse("https://example.com").ok();
1323 let doc = create_document(html);
1324 let images = extract_images(&doc, base.as_ref());
1325 assert_eq!(
1326 images[0].url.as_deref(),
1327 Some("https://cdn.example.com/logo.png")
1328 );
1329 }
1330
1331 #[test]
1332 fn test_extract_images_skips_data_uri() {
1333 let html = r#"<html><body>
1334 <img src="data:image/png;base64,abc123" alt="内嵌">
1335 </body></html>"#;
1336 let doc = create_document(html);
1337 let images = extract_images(&doc, None);
1338 assert!(images.is_empty());
1339 }
1340
1341 #[test]
1342 fn test_extract_images_no_src() {
1343 let html = r#"<html><body><img alt="无src"></body></html>"#;
1344 let doc = create_document(html);
1345 let images = extract_images(&doc, None);
1346 assert!(images.is_empty());
1347 }
1348
1349 #[test]
1350 fn test_extract_image_with_srcset() {
1351 let html = r#"<html><body>
1352 <img src="/img/photo.jpg" alt="响应式图片"
1353 srcset="/img/photo-400.jpg 400w, /img/photo-800.jpg 800w"
1354 sizes="(max-width: 600px) 400px, 800px"
1355 loading="lazy">
1356 </body></html>"#;
1357 let base = Url::parse("https://example.com").ok();
1358 let doc = create_document(html);
1359 let images = extract_images(&doc, base.as_ref());
1360 assert_eq!(images.len(), 1);
1361 assert!(images[0].srcset.is_some());
1362 assert!(images[0].sizes.is_some());
1363 assert_eq!(images[0].loading, ImageLoading::Lazy);
1364 }
1365
1366 #[test]
1367 fn test_extract_image_decorative() {
1368 let html = r#"<html><body><img src="/spacer.gif" alt=""></body></html>"#;
1369 let doc = create_document(html);
1370 let images = extract_images(&doc, None);
1371 assert!(images[0].is_decorative);
1372 }
1373
1374 #[test]
1375 fn test_resolve_image_url_absolute() {
1376 let base = Url::parse("https://example.com").unwrap();
1377 assert_eq!(
1378 resolve_image_url(&base, "https://other.com/img.jpg"),
1379 Some("https://other.com/img.jpg".to_string())
1380 );
1381 }
1382
1383 #[test]
1384 fn test_resolve_image_url_relative() {
1385 let base = Url::parse("https://example.com/blog/").unwrap();
1386 assert_eq!(
1387 resolve_image_url(&base, "img.jpg"),
1388 Some("https://example.com/blog/img.jpg".to_string())
1389 );
1390 }
1391
1392 #[test]
1393 fn test_resolve_image_url_root_relative() {
1394 let base = Url::parse("https://example.com/blog/").unwrap();
1395 assert_eq!(
1396 resolve_image_url(&base, "/images/img.jpg"),
1397 Some("https://example.com/images/img.jpg".to_string())
1398 );
1399 }
1400
1401 #[test]
1402 fn test_resolve_image_url_protocol_relative() {
1403 let base = Url::parse("https://example.com").unwrap();
1404 assert_eq!(
1405 resolve_image_url(&base, "//cdn.example.com/img.jpg"),
1406 Some("https://cdn.example.com/img.jpg".to_string())
1407 );
1408 }
1409}