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