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.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
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 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
116fn 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
156pub 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
194pub 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
214fn 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
232pub 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
250pub 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
266pub 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
280pub 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
294pub 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
310pub 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
324pub fn count_words(text: &str) -> usize {
326 text.split_whitespace()
327 .filter(|w| w.chars().any(|c| c.is_alphabetic()))
328 .count()
329}
330
331pub 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
339fn count_syllables(text: &str) -> usize {
341 text.split_whitespace()
342 .map(count_word_syllables)
343 .sum()
344}
345
346fn 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
381pub 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
404const 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}