| |
|
|
| use regex::Regex; |
| use serde_json::Value; |
| use std::collections::HashMap; |
| use std::sync::OnceLock; |
|
|
| use crate::base_system::context::Config; |
|
|
| |
| fn re_breaks() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| { |
| Regex::new(r"(?is)<br\s*/?>|</p\s*>|</div\s*>|</section\s*>|</h[1-6]\s*>").unwrap() |
| }) |
| } |
|
|
| fn re_open_p() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?is)<p\b[^>]*>").unwrap()) |
| } |
|
|
| fn re_para() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?is)<p[^>]*>(.*?)</p>").unwrap()) |
| } |
|
|
| fn re_strip_tags() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"<[^>]+>").unwrap()) |
| } |
|
|
| fn re_strip_header() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"<header[^>]*>.*?</header>").unwrap()) |
| } |
|
|
| fn re_strip_script() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"<script[^>]*>.*?</script>").unwrap()) |
| } |
|
|
| fn re_strip_style() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"<style[^>]*>.*?</style>").unwrap()) |
| } |
|
|
| fn re_strip_comments() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?s)<!--.*?-->").unwrap()) |
| } |
|
|
| fn re_extract_body() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?is)<body[^>]*>(.*?)</body>").unwrap()) |
| } |
|
|
| fn re_br_normalize() -> &'static Regex { |
| static R: OnceLock<Regex> = OnceLock::new(); |
| R.get_or_init(|| Regex::new(r"(?i)<br\s*/?>").unwrap()) |
| } |
|
|
| pub struct ContentParser; |
|
|
| impl ContentParser { |
| |
| pub fn extract_api_content(value: &Value, cfg: &Config) -> HashMap<String, (String, String)> { |
| let mut out = HashMap::new(); |
| let data = value |
| .get("data") |
| .and_then(|v| v.as_object()) |
| .or_else(|| value.as_object()); |
|
|
| let Some(map) = data else { |
| return out; |
| }; |
|
|
| for (cid, info) in map { |
| let obj = info.as_object(); |
| let raw_content = obj |
| .and_then(|o| o.get("content")) |
| .and_then(Value::as_str) |
| .unwrap_or_default(); |
| let title = obj |
| .and_then(|o| o.get("title")) |
| .and_then(Value::as_str) |
| .or_else(|| { |
| obj.and_then(|o| o.get("origin_chapter_title")) |
| .and_then(Value::as_str) |
| }) |
| .unwrap_or(cid.as_str()); |
|
|
| |
| let processed = if cfg.novel_format.eq_ignore_ascii_case("epub") { |
| Self::prepare_epub_xhtml(raw_content) |
| } else { |
| Self::clean_xhtml(raw_content, title) |
| }; |
|
|
| out.insert(cid.clone(), (processed, title.to_string())); |
| } |
|
|
| out |
| } |
|
|
| |
| fn prepare_epub_xhtml(raw: &str) -> String { |
| let stripped = Self::strip_header(raw); |
| let body = Self::extract_body(&stripped).unwrap_or(stripped); |
| Self::strip_comments(&body) |
| } |
|
|
| |
| pub fn clean_plain(raw: &str, title: &str) -> String { |
| |
| |
| let normalized = re_breaks().replace_all(raw, "\n"); |
| let normalized = re_open_p().replace_all(&normalized, "\n"); |
| let normalized = normalized.replace("\r\n", "\n").replace('\r', "\n"); |
|
|
| let without_tags = Self::strip_tags(&normalized); |
| let without_tags = Self::unescape_html_entities(&without_tags); |
| let without_tags = without_tags.replace("\r\n", "\n").replace('\r', "\n"); |
|
|
| |
| let mut out = Vec::new(); |
| let mut last_blank = true; |
| for line in without_tags.lines() { |
| let trimmed = line.trim(); |
| if trimmed.is_empty() { |
| if !last_blank { |
| out.push(String::new()); |
| last_blank = true; |
| } |
| continue; |
| } |
| last_blank = false; |
| out.push(trimmed.to_string()); |
| } |
|
|
| while out.last().is_some_and(|l| l.trim().is_empty()) { |
| out.pop(); |
| } |
|
|
| |
| |
| let normalized_title = normalize_title_for_compare(title); |
| if !normalized_title.is_empty() { |
| loop { |
| let Some(first_non_empty_idx) = out.iter().position(|l| !l.trim().is_empty()) |
| else { |
| break; |
| }; |
| let first_line_norm = |
| normalize_title_for_compare(out[first_non_empty_idx].as_str()); |
| if first_line_norm != normalized_title { |
| break; |
| } |
| out.remove(first_non_empty_idx); |
| if first_non_empty_idx < out.len() && out[first_non_empty_idx].trim().is_empty() { |
| out.remove(first_non_empty_idx); |
| } |
| } |
| } |
|
|
| if out.is_empty() { |
| without_tags.trim().to_string() |
| } else { |
| out.into_iter() |
| .map(|line| { |
| if line.trim().is_empty() { |
| String::new() |
| } else { |
| format!(" {}", line.trim()) |
| } |
| }) |
| .collect::<Vec<_>>() |
| .join("\n") |
| } |
| } |
|
|
| |
| pub fn clean_xhtml(raw: &str, _title: &str) -> String { |
| let stripped = Self::strip_header(raw); |
| let body = Self::extract_body(&stripped).unwrap_or(stripped); |
| let body = Self::strip_comments(&body); |
| let mut paragraphs = Vec::new(); |
|
|
| |
| let re_para = re_para(); |
| for cap in re_para.captures_iter(&body) { |
| let inner = cap.get(1).map(|m| m.as_str()).unwrap_or(""); |
| let cleaned = Self::sanitize_paragraph(inner); |
| if !cleaned.is_empty() { |
| paragraphs.push(format!("<p>{}</p>", cleaned)); |
| } |
| } |
|
|
| if paragraphs.is_empty() { |
| let plain = Self::strip_tags(&body); |
| for line in plain.split('\n') { |
| let decoded = Self::unescape_html_entities(line); |
| let trimmed = decoded.trim(); |
| if !trimmed.is_empty() { |
| paragraphs.push(format!("<p>{}</p>", Self::escape_html(trimmed))); |
| } |
| } |
| } |
|
|
| paragraphs.join("\n") |
| } |
|
|
| fn strip_tags(raw: &str) -> String { |
| |
| let s = re_strip_tags().replace_all(raw, ""); |
| s.replace("\r\n", "\n").replace('\r', "\n") |
| } |
|
|
| fn strip_header(raw: &str) -> String { |
| |
| let tmp = re_strip_header().replace_all(raw, ""); |
| let tmp = re_strip_script().replace_all(&tmp, ""); |
| re_strip_style().replace_all(&tmp, "").to_string() |
| } |
|
|
| fn strip_comments(raw: &str) -> String { |
| re_strip_comments().replace_all(raw, "").to_string() |
| } |
|
|
| fn extract_body(raw: &str) -> Option<String> { |
| re_extract_body() |
| .captures(raw) |
| .and_then(|cap| cap.get(1)) |
| .map(|m| m.as_str().to_string()) |
| } |
|
|
| fn sanitize_paragraph(inner: &str) -> String { |
| |
| let with_newlines = re_br_normalize().replace_all(inner, "\n"); |
| let text = Self::strip_tags(&with_newlines); |
| let decoded = Self::unescape_html_entities(text.trim()); |
| let trimmed = decoded.trim(); |
| if trimmed.is_empty() { |
| return String::new(); |
| } |
| Self::escape_html(trimmed) |
| } |
|
|
| fn escape_html(s: &str) -> String { |
| s.replace('&', "&") |
| .replace('<', "<") |
| .replace('>', ">") |
| .replace('"', """) |
| .replace('\'', "'") |
| } |
|
|
| fn unescape_html_entities(s: &str) -> String { |
| |
| |
| if !(s.contains('&')) { |
| return s.to_string(); |
| } |
|
|
| let mut result = s.to_string(); |
| for _ in 0..4 { |
| let decoded = Self::unescape_html_entities_once(&result); |
| if decoded == result { |
| break; |
| } |
| result = decoded; |
| if !result.contains('&') { |
| break; |
| } |
| } |
| result |
| } |
|
|
| fn unescape_html_entities_once(s: &str) -> String { |
| |
| |
| |
| if !(s.contains('&')) { |
| return s.to_string(); |
| } |
|
|
| use std::sync::OnceLock; |
| static RE_DECIMAL: OnceLock<Regex> = OnceLock::new(); |
| static RE_HEX: OnceLock<Regex> = OnceLock::new(); |
|
|
| let re_decimal = RE_DECIMAL.get_or_init(|| Regex::new(r"&#(\d+);").unwrap()); |
| let re_hex = RE_HEX.get_or_init(|| Regex::new(r"&#[xX]([0-9a-fA-F]+);").unwrap()); |
|
|
| let mut result = s.to_string(); |
|
|
| |
| result = re_decimal |
| .replace_all(&result, |caps: ®ex::Captures| { |
| if let Some(num_str) = caps.get(1) |
| && let Ok(code_point) = num_str.as_str().parse::<u32>() |
| { |
| |
| if code_point <= 0x10FFFF |
| && let Some(ch) = char::from_u32(code_point) |
| { |
| return ch.to_string(); |
| } |
| } |
| caps[0].to_string() |
| }) |
| .to_string(); |
|
|
| |
| result = re_hex |
| .replace_all(&result, |caps: ®ex::Captures| { |
| if let Some(hex_str) = caps.get(1) |
| && let Ok(code_point) = u32::from_str_radix(hex_str.as_str(), 16) |
| { |
| |
| if code_point <= 0x10FFFF |
| && let Some(ch) = char::from_u32(code_point) |
| { |
| return ch.to_string(); |
| } |
| } |
| caps[0].to_string() |
| }) |
| .to_string(); |
|
|
| |
| result |
| .replace(" ", " ") |
| |
| .replace(""", "\"") |
| .replace("'", "'") |
| |
| .replace("“", "\u{201C}") |
| .replace("”", "\u{201D}") |
| .replace("‘", "\u{2018}") |
| .replace("’", "\u{2019}") |
| .replace("‚", "\u{201A}") |
| .replace("„", "\u{201E}") |
| |
| .replace("–", "\u{2013}") |
| .replace("—", "\u{2014}") |
| |
| .replace("…", "\u{2026}") |
| |
| .replace("•", "\u{2022}") |
| .replace("­", "\u{00AD}") |
| |
| .replace("<", "<") |
| .replace(">", ">") |
| .replace("‹", "\u{2039}") |
| .replace("›", "\u{203A}") |
| |
| .replace("&", "&") |
| } |
| } |
|
|
| fn normalize_title_for_compare(s: &str) -> String { |
| s.chars() |
| .filter(|ch| { |
| !ch.is_whitespace() |
| && !matches!( |
| ch, |
| ' ' | ':' | ':' | ',' | ',' | '。' | '!' | '!' | '?' | '?' | '、' |
| ) |
| }) |
| .collect::<String>() |
| .to_lowercase() |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::ContentParser; |
|
|
| #[test] |
| fn clean_plain_removes_duplicated_leading_title() { |
| let raw = "<h1>第1章 开局</h1><p>第1章 开局</p><p>正文第一段</p><p>正文第二段</p>"; |
| let out = ContentParser::clean_plain(raw, "第1章 开局"); |
| assert!( |
| !out.lines() |
| .any(|l| l.contains("第1章 开局") && l.trim() == "第1章 开局") |
| ); |
| assert!(out.contains("正文第一段")); |
| assert!(out.contains("正文第二段")); |
| } |
|
|
| #[test] |
| fn clean_plain_keeps_non_title_first_line() { |
| let raw = "<p>引子</p><p>正文第一段</p>"; |
| let out = ContentParser::clean_plain(raw, "第1章 开局"); |
| assert!(out.contains("引子")); |
| assert!(out.contains("正文第一段")); |
| } |
|
|
| #[test] |
| fn clean_xhtml_decodes_entities_before_reescaping() { |
| let raw = "<p>他说"A&B",还写了'C'</p>"; |
| let out = ContentParser::clean_xhtml(raw, "第1章 开局"); |
|
|
| assert_eq!(out, "<p>他说"A&B",还写了'C'</p>"); |
| assert!(!out.contains("&#34;")); |
| assert!(!out.contains("&amp;")); |
| } |
|
|
| #[test] |
| fn clean_plain_decodes_double_escaped_cached_entities() { |
| let raw = "<p>他说&#34;A&amp;B&#34;,还写了&#x27;C&#x27;</p>"; |
| let out = ContentParser::clean_plain(raw, "第1章 开局"); |
|
|
| assert!(out.contains("他说\"A&B\",还写了'C'")); |
| assert!(!out.contains(""")); |
| assert!(!out.contains("&")); |
| } |
|
|
| #[test] |
| fn clean_plain_preserves_ascii_emoticons_and_symbols() { |
| let raw = "<p>(^_^)/ ~~ \"hi\" it's ok :-)</p>"; |
| let out = ContentParser::clean_plain(raw, "第1章 开局"); |
|
|
| assert!(out.contains("(^_^)/ ~~ \"hi\" it's ok :-)")); |
| assert!(!out.contains("(^_^)/")); |
| assert!(!out.contains(""hi"")); |
| assert!(!out.contains(":-)")); |
| } |
|
|
| #[test] |
| fn clean_xhtml_then_clean_plain_restores_text_entities() { |
| let raw = "<p>他说"A&B"</p>"; |
| let cached = ContentParser::clean_xhtml(raw, "第1章 开局"); |
| let out = ContentParser::clean_plain(&cached, "第1章 开局"); |
|
|
| assert!(out.contains("他说\"A&B\"")); |
| assert!(!out.contains(""")); |
| assert!(!out.contains("&")); |
| } |
| } |
|
|