//! 章节内容解析与文本处理。
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 = OnceLock::new();
R.get_or_init(|| {
Regex::new(r"(?is)
|
|||").unwrap()
})
}
fn re_open_p() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?is)]*>").unwrap())
}
fn re_para() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?is)]*>(.*?)
").unwrap())
}
fn re_strip_tags() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"<[^>]+>").unwrap())
}
fn re_strip_header() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"").unwrap())
}
fn re_strip_script() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"").unwrap())
}
fn re_strip_style() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"").unwrap())
}
fn re_strip_comments() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?s)").unwrap())
}
fn re_extract_body() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?is)]*>(.*?)").unwrap())
}
fn re_br_normalize() -> &'static Regex {
static R: OnceLock = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?i)
").unwrap())
}
pub struct ContentParser;
impl ContentParser {
/// 解析 API 返回的章节内容映射: chapter_id -> (内容, 标题)
pub fn extract_api_content(value: &Value, cfg: &Config) -> HashMap {
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());
// 缓存统一保存为 XHTML 格式,txt 的清洗在 finalize 阶段完成。
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
}
/// EPUB 专用:保留正文 XHTML,移除 header/script/style 并抽取 body 内容。
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 {
// Many chapters come as XHTML fragments (,
, etc.).
// If we strip tags directly, paragraphs collapse into a single line.
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");
// Keep paragraph breaks: output blank lines between paragraphs.
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();
}
// 某些章节正文首段会重复输出章节标题(常见于 h1/h2 被扁平化后),
// 这里做保守去重:仅当首个非空行与章节标题一致时移除首行。
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::>()
.join("\n")
}
}
/// 简化的 XHTML 清洗:去掉 与脚本,保留主体文本。
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!("{}
", 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!("{}
", Self::escape_html(trimmed)));
}
}
}
paragraphs.join("\n")
}
fn strip_tags(raw: &str) -> String {
// 粗暴去标签,避免引入额外 HTML 解析库
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 {
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 {
// Decode common HTML entities that may appear in the API response
// Note: & must be replaced last to avoid double-decoding issues
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 {
// Decode common HTML entities that may appear in the API response.
// `&` must be replaced last so `"` can be decoded by the
// next bounded pass instead of being over/under-decoded in one pass.
if !(s.contains('&')) {
return s.to_string();
}
use std::sync::OnceLock;
static RE_DECIMAL: OnceLock = OnceLock::new();
static RE_HEX: OnceLock = 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();
// Decode decimal numeric entities (NNN;)
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::()
{
// Validate code point is in valid Unicode range (0 to 0x10FFFF)
if code_point <= 0x10FFFF
&& let Some(ch) = char::from_u32(code_point)
{
return ch.to_string();
}
}
caps[0].to_string() // Return original if parsing fails
})
.to_string();
// Decode hexadecimal numeric entities (HH; or HH;)
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)
{
// Validate code point is in valid Unicode range (0 to 0x10FFFF)
if code_point <= 0x10FFFF
&& let Some(ch) = char::from_u32(code_point)
{
return ch.to_string();
}
}
caps[0].to_string() // Return original if parsing fails
})
.to_string();
// Then decode named entities
result
.replace(" ", " ")
// Straight quotes and apostrophes
.replace(""", "\"")
.replace("'", "'")
// Curly quotes (common in Chinese novels)
.replace("“", "\u{201C}")
.replace("”", "\u{201D}")
.replace("‘", "\u{2018}")
.replace("’", "\u{2019}")
.replace("‚", "\u{201A}")
.replace("„", "\u{201E}")
// Dashes (common in Chinese novels)
.replace("–", "\u{2013}")
.replace("—", "\u{2014}")
// Ellipsis
.replace("…", "\u{2026}")
// Other punctuation
.replace("•", "\u{2022}")
.replace("", "\u{00AD}")
// Angle brackets
.replace("<", "<")
.replace(">", ">")
.replace("‹", "\u{2039}")
.replace("›", "\u{203A}")
// Must be last to avoid double-decoding
.replace("&", "&")
}
}
fn normalize_title_for_compare(s: &str) -> String {
s.chars()
.filter(|ch| {
!ch.is_whitespace()
&& !matches!(
ch,
' ' | ':' | ':' | ',' | ',' | '。' | '!' | '!' | '?' | '?' | '、'
)
})
.collect::()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::ContentParser;
#[test]
fn clean_plain_removes_duplicated_leading_title() {
let raw = "第1章 开局
第1章 开局
正文第一段
正文第二段
";
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 = "引子
正文第一段
";
let out = ContentParser::clean_plain(raw, "第1章 开局");
assert!(out.contains("引子"));
assert!(out.contains("正文第一段"));
}
#[test]
fn clean_xhtml_decodes_entities_before_reescaping() {
let raw = "他说"A&B",还写了'C'
";
let out = ContentParser::clean_xhtml(raw, "第1章 开局");
assert_eq!(out, "他说"A&B",还写了'C'
");
assert!(!out.contains("""));
assert!(!out.contains("&"));
}
#[test]
fn clean_plain_decodes_double_escaped_cached_entities() {
let raw = "他说"A&B",还写了'C'
";
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 = "(^_^)/ ~~ \"hi\" it's ok :-)
";
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 = "他说"A&B"
";
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("&"));
}
}