Skip to main content

crawlkit_parser/
text.rs

1//! 文本提取模块
2//!
3//! 从 HTML 文档中提取纯文本内容,包含清洗、可读性分析和语言检测。
4//! 改编自 halldyll-parser 的文本提取实现。
5
6use scraper::{Html, ElementRef, Node};
7use std::collections::HashSet;
8
9use crate::selector::{try_parse_selector, BOILERPLATE_SELECTORS, CONTENT_SELECTORS};
10use crate::types::{TextContent, ParserConfig, ParserResult};
11
12/// 从 HTML 文档中提取文本内容
13pub fn extract_text(document: &Html, config: &ParserConfig) -> ParserResult<TextContent> {
14    let raw_text = extract_main_content(document, config);
15    let cleaned_text = strip_html_tags(&raw_text);
16    let word_count = count_words(&cleaned_text);
17    let char_count = cleaned_text.chars().count();
18    let language = detect_language(&cleaned_text);
19    let readability_score = if config.compute_readability && word_count > 0 {
20        Some(flesch_reading_ease(&cleaned_text))
21    } else {
22        None
23    };
24    let reading_time_minutes = if word_count > 0 {
25        Some(word_count as f64 / 225.0)
26    } else {
27        None
28    };
29
30    Ok(TextContent {
31        raw_text,
32        cleaned_text,
33        word_count,
34        char_count,
35        language,
36        readability_score,
37        reading_time_minutes,
38    })
39}
40
41/// 使用内容选择器提取正文,失败时回退到 body 文本提取
42fn extract_main_content(document: &Html, config: &ParserConfig) -> String {
43    for selector_str in CONTENT_SELECTORS.iter() {
44        if let Some(sel) = try_parse_selector(selector_str)
45            && let Some(el) = document.select(&sel).next()
46        {
47            let text = extract_element_text_filtered(el, &BOILERPLATE_SELECTORS);
48            if text.len() > config.min_paragraph_length {
49                return text;
50            }
51        }
52    }
53    for selector_str in config.content_selectors.iter() {
54        if let Some(sel) = try_parse_selector(selector_str)
55            && let Some(el) = document.select(&sel).next()
56        {
57            let text = extract_element_text_filtered(el, &BOILERPLATE_SELECTORS);
58            if text.len() > config.min_paragraph_length {
59                return text;
60            }
61        }
62    }
63    if let Some(body_sel) = try_parse_selector("body")
64        && let Some(body) = document.select(&body_sel).next()
65    {
66        extract_body_text(body)
67    } else {
68        extract_element_text_filtered(document.root_element(), &BOILERPLATE_SELECTORS)
69    }
70}
71
72/// 从 body 元素提取文本,带基础过滤
73fn extract_body_text(element: ElementRef) -> String {
74    let mut skip = BOILERPLATE_SELECTORS.clone();
75    skip.insert("header");
76    skip.insert("footer");
77    skip.insert("nav");
78    skip.insert("aside");
79    extract_element_text_filtered(element, &skip)
80}
81
82/// 提取元素的所有文本内容,自动在块级元素后换行
83pub fn extract_element_text(element: ElementRef) -> String {
84    let mut result = String::new();
85    for child in element.children() {
86        match child.value() {
87            Node::Text(text) => {
88                let trimmed = normalize_text(text);
89                if !trimmed.is_empty() {
90                    result.push_str(&trimmed);
91                    result.push(' ');
92                }
93            }
94            Node::Element(el) => {
95                let tag = el.name.local.as_ref();
96                if is_block_element(tag) {
97                    if !result.is_empty() && !result.ends_with('\n') {
98                        result.push('\n');
99                    }
100                }
101                if let Some(child_ref) = ElementRef::wrap(child) {
102                    result.push_str(&extract_element_text(child_ref));
103                }
104                if is_block_element(tag) {
105                    if !result.ends_with('\n') {
106                        result.push('\n');
107                    }
108                }
109            }
110            _ => {}
111        }
112    }
113    collapse_newlines(&result)
114}
115
116/// 提取元素文本并跳过指定的元素类型
117fn extract_element_text_filtered(
118    element: ElementRef,
119    skip_tags: &HashSet<&str>,
120) -> String {
121    let mut result = String::new();
122    for child in element.children() {
123        match child.value() {
124            Node::Text(text) => {
125                let trimmed = normalize_text(text);
126                if !trimmed.is_empty() {
127                    if !result.is_empty() && !result.ends_with(' ') && !result.ends_with('\n') {
128                        result.push(' ');
129                    }
130                    result.push_str(&trimmed);
131                }
132            }
133            Node::Element(el) => {
134                let tag = el.name.local.as_ref();
135                if skip_tags.contains(tag) {
136                    continue;
137                }
138                if is_block_element(tag) {
139                    if !result.is_empty() && !result.ends_with('\n') {
140                        result.push('\n');
141                    }
142                }
143                if let Some(child_ref) = ElementRef::wrap(child) {
144                    result.push_str(&extract_element_text_filtered(child_ref, skip_tags));
145                }
146                if is_block_element(tag) && !result.ends_with('\n') {
147                    result.push('\n');
148                }
149            }
150            _ => {}
151        }
152    }
153    collapse_newlines(&result)
154}
155
156/// 深度受限的递归文本提取,防止栈溢出
157pub fn extract_text_recursive(element: ElementRef, depth: usize, max_depth: usize) -> String {
158    if depth >= max_depth {
159        return String::new();
160    }
161    let mut result = String::new();
162    for child in element.children() {
163        match child.value() {
164            Node::Text(text) => {
165                let trimmed = normalize_text(text);
166                if !trimmed.is_empty() {
167                    if !result.is_empty() && !result.ends_with(' ') && !result.ends_with('\n') {
168                        result.push(' ');
169                    }
170                    result.push_str(&trimmed);
171                }
172            }
173            Node::Element(el) => {
174                let tag = el.name.local.as_ref();
175                if should_skip_element_by_tag(tag) {
176                    continue;
177                }
178                if is_block_element(tag) && !result.is_empty() && !result.ends_with('\n') {
179                    result.push('\n');
180                }
181                if let Some(child_ref) = ElementRef::wrap(child) {
182                    result.push_str(&extract_text_recursive(child_ref, depth + 1, max_depth));
183                }
184                if is_block_element(tag) && !result.ends_with('\n') {
185                    result.push('\n');
186                }
187            }
188            _ => {}
189        }
190    }
191    collapse_newlines(&result)
192}
193
194// ── 文本处理 ────────────────────────────────────────────────
195
196/// 规范化文本:去除首尾空白,合并内部连续空白为单个空格
197pub fn normalize_text(text: &str) -> String {
198    let mut result = String::with_capacity(text.len());
199    let mut prev_ws = false;
200    for c in text.chars() {
201        if c.is_whitespace() {
202            if !prev_ws {
203                result.push(' ');
204                prev_ws = true;
205            }
206        } else {
207            result.push(c);
208            prev_ws = false;
209        }
210    }
211    result.trim().to_string()
212}
213
214/// 将连续换行折叠为最多两个换行
215fn collapse_newlines(text: &str) -> String {
216    let mut result = String::with_capacity(text.len());
217    let mut newline_count = 0;
218    for c in text.chars() {
219        if c == '\n' {
220            newline_count += 1;
221            if newline_count <= 2 {
222                result.push(c);
223            }
224        } else {
225            newline_count = 0;
226            result.push(c);
227        }
228    }
229    result.trim().to_string()
230}
231
232/// 移除 HTML 标签,保留文本内容
233pub fn strip_html_tags(html: &str) -> String {
234    let mut result = String::with_capacity(html.len());
235    let mut in_tag = false;
236    for c in html.chars() {
237        match c {
238            '<' => in_tag = true,
239            '>' => in_tag = false,
240            _ => {
241                if !in_tag {
242                    result.push(c);
243                }
244            }
245        }
246    }
247    normalize_text(&result)
248}
249
250// ── 元素分类 ────────────────────────────────────────────────
251
252/// 判断元素是否应该被跳过
253pub fn should_skip_element(element: &ElementRef) -> bool {
254    let tag = element.value().name.local.as_ref();
255    should_skip_element_by_tag(tag)
256}
257
258pub fn should_skip_element_by_tag(tag: &str) -> bool {
259    matches!(
260        tag,
261        "script" | "style" | "noscript" | "iframe" | "canvas"
262            | "svg" | "math" | "template" | "object" | "embed"
263    )
264}
265
266/// 判断是否是块级元素
267pub fn is_block_element(tag: &str) -> bool {
268    matches!(
269        tag,
270        "p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
271            | "ul" | "ol" | "li" | "blockquote" | "pre"
272            | "table" | "tr" | "td" | "th" | "section"
273            | "article" | "figure" | "figcaption" | "details"
274            | "summary" | "dialog" | "hr" | "br" | "address"
275            | "fieldset" | "main" | "header" | "footer" | "nav"
276            | "aside" | "form" | "dl" | "dt" | "dd"
277    )
278}
279
280/// 判断是否是行内元素
281pub fn is_inline_element(tag: &str) -> bool {
282    matches!(
283        tag,
284        "a" | "span" | "strong" | "em" | "b" | "i" | "u" | "s"
285            | "sub" | "sup" | "code" | "kbd" | "q" | "cite"
286            | "abbr" | "time" | "mark" | "small" | "del" | "ins"
287            | "label" | "button" | "input" | "select" | "textarea"
288            | "img" | "br" | "wbr" | "bdi" | "bdo" | "data"
289            | "dfn" | "output" | "progress" | "meter" | "ruby"
290            | "rp" | "rt" | "samp" | "var" | "acronym"
291    )
292}
293
294// ── 可读性分析 ───────────────────────────────────────────────
295
296/// 计算 Flesch Reading Ease 分数(0-100,越高越易读)
297pub fn flesch_reading_ease(text: &str) -> f64 {
298    let word_count = count_words(text) as f64;
299    let sentence_count = count_sentences(text) as f64;
300    let syllable_count = count_syllables(text) as f64;
301    if word_count == 0.0 || sentence_count == 0.0 {
302        return 0.0;
303    }
304    let score = 206.835
305        - 1.015 * (word_count / sentence_count)
306        - 84.6 * (syllable_count / word_count);
307    score.clamp(0.0, 100.0)
308}
309
310/// 计算 Flesch-Kincaid Grade Level(美国年级水平)
311pub fn flesch_kincaid_grade(text: &str) -> f64 {
312    let word_count = count_words(text) as f64;
313    let sentence_count = count_sentences(text) as f64;
314    let syllable_count = count_syllables(text) as f64;
315    if word_count == 0.0 || sentence_count == 0.0 {
316        return 0.0;
317    }
318    let grade = 0.39 * (word_count / sentence_count)
319        + 11.8 * (syllable_count / word_count)
320        - 15.59;
321    grade.max(0.0)
322}
323
324/// 统计单词数
325pub fn count_words(text: &str) -> usize {
326    text.split_whitespace()
327        .filter(|w| w.chars().any(|c| c.is_alphabetic()))
328        .count()
329}
330
331/// 统计句子数
332pub fn count_sentences(text: &str) -> usize {
333    let count = text.chars()
334        .filter(|&c| c == '.' || c == '!' || c == '?' || c == '。' || c == '!' || c == '?')
335        .count();
336    count.max(1)
337}
338
339/// 统计总音节数
340fn count_syllables(text: &str) -> usize {
341    text.split_whitespace()
342        .map(count_word_syllables)
343        .sum()
344}
345
346/// 统计单个单词的音节数
347fn count_word_syllables(word: &str) -> usize {
348    let w = word.trim_matches(|c: char| !c.is_alphabetic()).to_lowercase();
349    if w.is_empty() {
350        return 0;
351    }
352    let len = w.len();
353    if len <= 3 {
354        return 1;
355    }
356    let chars: Vec<char> = w.chars().collect();
357    let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
358    let mut count = 0;
359    let mut prev_vowel = false;
360    for &c in &chars {
361        let is_v = vowels.contains(&c);
362        if is_v && !prev_vowel {
363            count += 1;
364        }
365        prev_vowel = is_v;
366    }
367    if w.ends_with('e') && count > 1 {
368        count -= 1;
369    }
370    if w.ends_with("le") && chars.len() > 2 && !vowels.contains(&chars[chars.len() - 3]) {
371        count += 1;
372    }
373    if w.ends_with("es") || w.ends_with("ed") {
374        if count > 1 {
375            count -= 1;
376        }
377    }
378    count.max(1)
379}
380
381// ── 语言检测 ─────────────────────────────────────────────────
382
383/// 简单语言检测:基于高频词列表匹配
384pub fn detect_language(text: &str) -> Option<String> {
385    let words: Vec<String> = text.split_whitespace()
386        .map(|w| w.trim_matches(|c: char| !c.is_alphabetic()).to_lowercase())
387        .filter(|w| w.len() > 2)
388        .collect();
389    if words.is_empty() {
390        return None;
391    }
392    let en = words.iter().filter(|w| ENGLISH_WORDS.contains(&w.as_str())).count();
393    let fr = words.iter().filter(|w| FRENCH_WORDS.contains(&w.as_str())).count();
394    let de = words.iter().filter(|w| GERMAN_WORDS.contains(&w.as_str())).count();
395    let es = words.iter().filter(|w| SPANISH_WORDS.contains(&w.as_str())).count();
396    let scores = [("en", en), ("fr", fr), ("de", de), ("es", es)];
397    let best = scores.iter().max_by_key(|(_, count)| *count).unwrap();
398    if best.1 == 0 {
399        return None;
400    }
401    Some(best.0.to_string())
402}
403
404// ── 语言词表 ─────────────────────────────────────────────────
405
406const ENGLISH_WORDS: &[&str] = &[
407    "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
408    "her", "was", "one", "our", "out", "has", "have", "been", "some", "them",
409    "than", "that", "this", "they", "what", "when", "which", "will", "your",
410    "about", "into", "over", "such", "with", "would", "could", "should",
411    "their", "there", "these", "where", "while", "after", "before", "between",
412    "through", "during", "without", "because", "people", "first", "world",
413    "still", "every", "great", "think", "thing", "under", "water", "place",
414];
415
416const FRENCH_WORDS: &[&str] = &[
417    "dans", "pour", "avec", "elle", "ils", "son", "ses", "leur", "nous",
418    "vous", "sur", "tout", "plus", "bien", "faire", "être", "avoir",
419    "cette", "comme", "mais", "fait", "faites", "entre", "aussi", "temps",
420    "monde", "autre", "deux", "grand", "petit", "alors", "tous", "chez",
421    "parce", "quand", "donc", "peut", "voir", "sans", "même", "encore",
422    "pendant", "toujours", "premier", "jamais", "chaque", "ainsi", "très",
423    "quelque", "personne", "homme", "femme", "jour", "nuit", "chose",
424];
425
426const GERMAN_WORDS: &[&str] = &[
427    "und", "die", "der", "das", "ist", "mit", "auf", "für", "sich", "auch",
428    "nicht", "ein", "eine", "einen", "dem", "den", "des", "sie", "sind",
429    "aus", "bei", "hat", "haben", "werden", "oder", "nach", "bis", "wir",
430    "mir", "wie", "zum", "zur", "durch", "gegen", "schon", "noch", "immer",
431    "sein", "seine", "ihre", "ihrer", "dass", "wenn", "aber", "alle", "dann",
432    "kann", "soll", "wird", "über", "viel", "groß", "klein", "ganz", "sagen",
433    "jetzt", "neue", "erste", "dieser", "dieses", "diese", "anderer", "worden",
434];
435
436const SPANISH_WORDS: &[&str] = &[
437    "que", "los", "las", "del", "para", "una", "por", "con", "sus", "las",
438    "era", "han", "también", "como", "más", "pero", "este", "esta", "entre",
439    "todo", "esa", "eso", "cada", "otro", "muy", "todos", "ahora", "desde",
440    "hasta", "cuando", "donde", "parte", "después", "durante", "siempre",
441    "entonces", "primero", "nunca", "mismo", "porque", "años", "tiempo",
442    "forma", "país", "lugar", "mundo", "vida", "día", "casa", "hombre",
443    "mujer", "agua", "gran", "ser", "estar", "tener", "hacer", "poder",
444];
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn create_document(html: &str) -> Html {
451        Html::parse_document(html)
452    }
453
454    fn default_config() -> ParserConfig {
455        ParserConfig::default()
456    }
457
458    #[test]
459    fn 提取简单段落文本() {
460        let html = "<html><body><p>Hello world. This is a test.</p></body></html>";
461        let doc = create_document(html);
462        let config = default_config();
463        let result = extract_text(&doc, &config).unwrap();
464        assert!(result.cleaned_text.contains("Hello world"));
465        assert!(result.word_count > 0);
466    }
467
468    #[test]
469    fn 提取多个段落() {
470        let html = "<html><body><p>First paragraph.</p><p>Second paragraph.</p></body></html>";
471        let doc = create_document(html);
472        let config = default_config();
473        let result = extract_text(&doc, &config).unwrap();
474        assert!(result.raw_text.contains("First paragraph."));
475        assert!(result.raw_text.contains("Second paragraph."));
476    }
477
478    #[test]
479    fn 跳过脚本和样式() {
480        let html = r#"<html><body>
481            <p>Visible text.</p>
482            <script>alert("hidden");</script>
483            <style>.hidden {}</style>
484            <p>More visible.</p>
485        </body></html>"#;
486        let doc = create_document(html);
487        let config = default_config();
488        let result = extract_text(&doc, &config).unwrap();
489        assert!(result.cleaned_text.contains("Visible text."));
490        assert!(result.cleaned_text.contains("More visible."));
491        assert!(!result.cleaned_text.contains("hidden"));
492    }
493
494    #[test]
495    fn 处理空文档() {
496        let html = "<html></html>";
497        let doc = create_document(html);
498        let config = default_config();
499        let result = extract_text(&doc, &config).unwrap();
500        assert_eq!(result.word_count, 0);
501        assert!(result.is_empty());
502    }
503
504    #[test]
505    fn 提取文章内容() {
506        let html = r#"<html><body>
507            <article>
508                <h1>Title</h1>
509                <p>This is the article content that should be long enough to pass the threshold.</p>
510                <p>More content here for testing purposes.</p>
511            </article>
512        </body></html>"#;
513        let doc = create_document(html);
514        let config = default_config();
515        let result = extract_text(&doc, &config).unwrap();
516        assert!(result.cleaned_text.contains("Title"));
517        assert!(result.cleaned_text.contains("article content"));
518    }
519
520    #[test]
521    fn 使用内容选择器提取() {
522        let html = r#"<html><body>
523            <div class="content">
524                <p>Main content area text.</p>
525            </div>
526            <div class="sidebar">
527                <p>Sidebar text should be excluded.</p>
528            </div>
529        </body></html>"#;
530        let doc = create_document(html);
531        let config = ParserConfig {
532            content_selectors: vec![".content".to_string()],
533            ..default_config()
534        };
535        let result = extract_text(&doc, &config).unwrap();
536        assert!(result.cleaned_text.contains("Main content area"));
537        assert!(!result.cleaned_text.contains("Sidebar text"));
538    }
539
540    #[test]
541    fn 归一化空白字符() {
542        assert_eq!(normalize_text("  hello   world  "), "hello world");
543        assert_eq!(normalize_text("foo\n\n\nbar"), "foo bar");
544        assert_eq!(normalize_text(""), "");
545    }
546
547    #[test]
548    fn 折叠换行() {
549        let input = "line1\n\n\n\n\nline2";
550        let result = collapse_newlines(input);
551        assert_eq!(result, "line1\n\nline2");
552    }
553
554    #[test]
555    fn 剥离HTML标签() {
556        let html = "<p>Hello <b>world</b></p>";
557        let result = strip_html_tags(html);
558        assert_eq!(result, "Hello world");
559    }
560
561    #[test]
562    fn 块级元素分类() {
563        assert!(is_block_element("p"));
564        assert!(is_block_element("div"));
565        assert!(is_block_element("h1"));
566        assert!(is_block_element("ul"));
567        assert!(!is_block_element("span"));
568        assert!(!is_block_element("a"));
569    }
570
571    #[test]
572    fn 行内元素分类() {
573        assert!(is_inline_element("span"));
574        assert!(is_inline_element("a"));
575        assert!(is_inline_element("strong"));
576        assert!(!is_inline_element("div"));
577        assert!(!is_inline_element("p"));
578    }
579
580    #[test]
581    fn 跳过元素检测() {
582        assert!(should_skip_element_by_tag("script"));
583        assert!(should_skip_element_by_tag("style"));
584        assert!(!should_skip_element_by_tag("p"));
585        assert!(!should_skip_element_by_tag("div"));
586    }
587
588    #[test]
589    fn 统计单词数() {
590        assert_eq!(count_words("hello world"), 2);
591        assert_eq!(count_words(""), 0);
592        assert_eq!(count_words("123 !@#"), 0);
593        assert_eq!(count_words("hello 123 world"), 2);
594    }
595
596    #[test]
597    fn 统计句子数() {
598        assert_eq!(count_sentences("Hello. World!"), 2);
599        assert_eq!(count_sentences("Just one"), 1);
600        assert_eq!(count_sentences(""), 1);
601    }
602
603    #[test]
604    fn 统计单词音节数() {
605        assert_eq!(count_word_syllables("the"), 1);
606        assert_eq!(count_word_syllables("hello"), 2);
607        assert_eq!(count_word_syllables("example"), 3);
608        assert_eq!(count_word_syllables("simple"), 2);
609    }
610
611    #[test]
612    fn 可读性分数范围() {
613        let text = "The cat sat on the mat. The dog ran in the park. The bird flew up high.";
614        let score = flesch_reading_ease(text);
615        assert!(score >= 0.0 && score <= 100.0);
616    }
617
618    #[test]
619    fn 年级水平非负() {
620        let text = "This is a test. It has multiple sentences. For grade level calculation.";
621        let grade = flesch_kincaid_grade(text);
622        assert!(grade >= 0.0);
623    }
624
625    #[test]
626    fn 语言检测英语() {
627        let text = "the world and the people are all here for you and me";
628        let lang = detect_language(text);
629        assert_eq!(lang, Some("en".to_string()));
630    }
631
632    #[test]
633    fn 语言检测法语() {
634        let text = "dans le monde avec elle et lui pour nous vous";
635        let lang = detect_language(text);
636        assert_eq!(lang, Some("fr".to_string()));
637    }
638
639    #[test]
640    fn 语言检测德语() {
641        let text = "und die der das ist mit auf für sich auch nicht";
642        let lang = detect_language(text);
643        assert_eq!(lang, Some("de".to_string()));
644    }
645
646    #[test]
647    fn 语言检测西班牙语() {
648        let text = "que los las del para una por con sus como más";
649        let lang = detect_language(text);
650        assert_eq!(lang, Some("es".to_string()));
651    }
652
653    #[test]
654    fn 未知语言返回none() {
655        let text = "xxx zzz yyy www qqq rrr";
656        let lang = detect_language(text);
657        assert_eq!(lang, None);
658    }
659
660    #[test]
661    fn 可读性分析与阅读时间() {
662        let html = "<html><body><p>This is a substantial paragraph with enough words to compute readability and reading time estimates for the test case.</p></body></html>";
663        let doc = create_document(html);
664        let config = ParserConfig {
665            compute_readability: true,
666            ..default_config()
667        };
668        let result = extract_text(&doc, &config).unwrap();
669        assert!(result.readability_score.is_some());
670        assert!(result.reading_time_minutes.is_some());
671    }
672
673    #[test]
674    fn 深度受限递归提取() {
675        let html = "<html><body><div><p><span>deep</span></p></div></body></html>";
676        let doc = create_document(html);
677        let body_sel = try_parse_selector("body").unwrap();
678        let body = doc.select(&body_sel).next().unwrap();
679        let text = extract_text_recursive(body, 0, 1);
680        assert!(text.is_empty() || text.contains("deep"));
681    }
682
683    #[test]
684    fn 提取文本过滤导航() {
685        let html = r#"<html><body>
686            <nav><p>Navigation</p></nav>
687            <main><p>Main content here.</p></main>
688        </body></html>"#;
689        let doc = create_document(html);
690        let main_sel = try_parse_selector("main").unwrap();
691        let main = doc.select(&main_sel).next().unwrap();
692        let skip_set: HashSet<&str> = ["nav"].iter().copied().collect();
693        let text = extract_element_text_filtered(main, &skip_set);
694        assert!(text.contains("Main content here."));
695    }
696
697    #[test]
698    fn normalize_保留非英文文本() {
699        let text = "  Hello   世界   ";
700        assert_eq!(normalize_text(text), "Hello 世界");
701    }
702
703    #[test]
704    fn 可读性分数与文本长度相关() {
705        let easy = "The cat sat. The dog ran. The bird flew.";
706        let hard = "The neurological manifestation of paroxysmal hypertension demonstrates substantial phenomenological complexity.";
707        assert!(flesch_reading_ease(easy) > flesch_reading_ease(hard));
708    }
709
710    #[test]
711    fn 总音节数统计() {
712        let text = "hello world example simple";
713        assert!(count_syllables(text) > 0);
714        assert_eq!(count_syllables(""), 0);
715    }
716}