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