Skip to main content

crawlkit_parser/
content.rs

1//! 结构化内容提取模块
2//!
3//! 提供从 HTML 中提取标题、段落、列表、表格、代码块、引用、图片等
4//! 结构化内容的功能。适配自 halldyll-parser 的内容提取实现。
5
6use 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// ============================================================================
17// 大纲条目
18// ============================================================================
19
20/// 大纲条目,用于构建页面标题的层级树结构
21#[derive(Debug, Clone)]
22pub struct OutlineItem {
23    /// 标题内容
24    pub heading: Heading,
25    /// 子标题列表
26    pub children: Vec<OutlineItem>,
27    /// 当前深度(从 0 开始)
28    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
41// ============================================================================
42// 标题提取
43// ============================================================================
44
45/// 从 HTML 文档中提取所有标题(h1 - h6)
46///
47/// 返回按文档顺序排列的标题列表,包含层级、文本、ID 和 class 信息。
48pub 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
88/// 获取页面的主标题(第一个 h1,回退到第一个 h2,再回退到 <title>)
89pub fn get_main_heading(document: &Html) -> Option<Heading> {
90    // 尝试 h1
91    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    // 回退到 h2
117    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
145/// 将标题列表构建为层级大纲树
146///
147/// 根据标题的 level 字段组装为嵌套的 `OutlineItem` 树结构。
148pub 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
171// ============================================================================
172// 段落提取
173// ============================================================================
174
175/// 从 HTML 文档中提取所有段落文本
176///
177/// 匹配 `<p>` 标签,过滤掉空段落和过短的段落。
178pub 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
202// ============================================================================
203// 列表提取
204// ============================================================================
205
206/// 从 HTML 文档中提取所有列表
207///
208/// 包括有序列表 `<ol>`、无序列表 `<ul>` 和定义列表 `<dl>`。
209pub fn extract_lists(document: &Html, config: &ParserConfig) -> Vec<ListContent> {
210    let mut lists = Vec::new();
211
212    // 提取有序和无序列表
213    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            // 跳过嵌套在列表项内的列表(由 extract_list_item 处理)
220            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    // 提取定义列表
236    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
248/// 从元素中提取单个列表(ol/ul)
249pub 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
274/// 从 li 元素中提取列表项,包括可能的内嵌列表
275pub 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                // 文本节点
285                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
346/// 从 dl 元素中提取定义列表
347pub 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
394// ============================================================================
395// 表格提取
396// ============================================================================
397
398/// 从 HTML 文档中提取所有表格
399pub 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
417/// 从 table 元素中提取单个表格
418pub fn extract_table(element: &ElementRef, _config: &ParserConfig) -> TableContent {
419    let mut table = TableContent::new();
420
421    // 提取表格标题
422    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    // 提取表头(thead 中的行)
437    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    // 提取表体行(tbody 或直接子级 tr)
451    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            // 没有 tbody,直接从 table 下找 tr
464            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    // 计算列数
480    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
492/// 从 tr 元素中提取表格行
493pub 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
514/// 从 td 或 th 元素中提取表格单元格
515pub 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
545// ============================================================================
546// 代码块提取
547// ============================================================================
548
549/// 已知编程语言标识符列表
550const 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
580/// 检查语言标识符是否为已知的编程语言
581pub fn is_known_language(lang: &str) -> bool {
582    let lang = lang.trim().to_lowercase();
583    KNOWN_LANGUAGES.contains(&lang.as_str())
584}
585
586/// 从 HTML 文档中提取所有代码块
587pub fn extract_code_blocks(document: &Html, _config: &ParserConfig) -> Vec<CodeBlock> {
588    let mut blocks = Vec::new();
589
590    // 提取 <pre><code> 块
591    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    // 提取独立的 <code> 元素(内联代码)
606    if let Some(code_sel) = try_parse_selector("code") {
607        for element in document.select(&code_sel) {
608            // 如果已经在 pre 中处理过则跳过
609            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
626/// 从 code 元素中提取代码块
627pub 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    // 尝试从 class 或 data-lang 属性获取语言
636    let language = element
637        .value()
638        .attr("class")
639        .and_then(|cls| {
640            // 常见的格式:language-rust, lang-rust, rust
641            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
683// ============================================================================
684// 引用提取
685// ============================================================================
686
687/// 从 HTML 文档中提取所有引用(blockquote)
688pub 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        // 检查内部是否有 <cite> 标签
714        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
745// ============================================================================
746// 图片提取
747// ============================================================================
748
749/// 从 HTML 文档中提取所有图片
750pub 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
767/// 从 img 元素中提取单张图片
768pub 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    // 跳过 data: URI 和纯空白
777    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        // 对以 / 开头的 srcset 图片也尝试解析
840        if let Some(base) = base_url {
841            image.url = resolve_image_url(base, &image.src);
842        }
843    }
844
845    Some(image)
846}
847
848/// 将图片 URL 解析为绝对 URL
849pub 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        // 协议相对 URL
856        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// ============================================================================
864// 测试
865// ============================================================================
866
867#[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    // ── 标题提取 ──
880
881    #[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    // ── 段落提取 ──
978
979    #[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    // ── 列表提取 ──
1001
1002    #[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    // ── 表格提取 ──
1104
1105    #[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    // ── 代码块提取 ──
1170
1171    #[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    // ── 引用提取 ──
1242
1243    #[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    // ── 图片提取 ──
1289
1290    #[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}