1use 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
12pub 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
41fn 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
72fn 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
82pub 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
114fn 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
153pub 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
191pub 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
211fn 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
229pub 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
247pub 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
263pub 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
277pub 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
291pub 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
307pub 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
321pub fn count_words(text: &str) -> usize {
323 text.split_whitespace()
324 .filter(|w| w.chars().any(char::is_alphabetic))
325 .count()
326}
327
328pub 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
336fn count_syllables(text: &str) -> usize {
338 text.split_whitespace()
339 .map(count_word_syllables)
340 .sum()
341}
342
343fn 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
377pub 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
400const 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}