Skip to main content

parse_book_source/
backend.rs

1//! 抽取后端(Strategy)。按 `Via` 静态分派到 css(dom_query)/ json(jsonpath)/ regex /
2//! raw 实现;新增 xpath 只需加一个分支(开闭原则,见 design D8)。
3//!
4//! HTML 的 `select` 为 **self-or-descendant** 语义:把上下文当文档解析,选择器既匹配
5//! 后代、也匹配根元素自身(dom_query 解析片段后根入树),这与旧引擎一致,使
6//! `select:"a" + attr:href` 能取「列表项自身的 href」、`select:"h2"` 能判「该项是不是卷」。
7
8use super::error::EvalError;
9use super::source::{Extract, ExtractOp, Via};
10use dom_query::{Document, Matcher};
11use fancy_regex::Regex;
12use jsonpath_rust::JsonPath;
13use serde_json::Value;
14use std::sync::LazyLock;
15
16/// 从上下文抽取一个值(值规则)。
17pub fn extract(
18    via: Via,
19    content: &str,
20    select: Option<&str>,
21    index: Option<i64>,
22    ex: &Extract,
23) -> Result<String, EvalError> {
24    match via {
25        Via::Css => html_extract(content, select, index, ex),
26        Via::Json => json_extract(content, select, index, ex),
27        Via::Regex => regex_extract(content, select, index),
28        Via::Raw => Ok(content.to_string()),
29        Via::Xpath => Err(EvalError::Unsupported("xpath")),
30    }
31}
32
33/// 选中所有匹配,返回各自的「子上下文」内容串(列表规则)。
34pub fn select_all(via: Via, content: &str, select: &str) -> Result<Vec<String>, EvalError> {
35    match via {
36        Via::Css => {
37            let doc = Document::from(content.to_string());
38            let matcher =
39                Matcher::new(select).map_err(|_| EvalError::Selector(select.to_string()))?;
40            let sel = doc.select_matcher(&matcher);
41            Ok(sel.nodes().iter().map(|n| n.html().to_string()).collect())
42        }
43        Via::Json => {
44            let value: Value =
45                serde_json::from_str(content).map_err(|e| EvalError::Json(e.to_string()))?;
46            let matched = value
47                .query(select)
48                .map_err(|e| EvalError::JsonPath(e.to_string()))?;
49            // value_to_string:字符串值取内容(不带 JSON 引号),供下游 item 规则求值。
50            Ok(matched.into_iter().map(value_to_string).collect())
51        }
52        Via::Regex => {
53            let re = Regex::new(select).map_err(|e| EvalError::Regex(e.to_string()))?;
54            Ok(re
55                .find_iter(content)
56                .filter_map(|m| m.ok())
57                .map(|m| m.as_str().to_string())
58                .collect())
59        }
60        Via::Raw => Ok(vec![content.to_string()]),
61        Via::Xpath => Err(EvalError::Unsupported("xpath")),
62    }
63}
64
65// ───────────────────────── HTML(dom_query)─────────────────────────
66
67fn html_extract(
68    content: &str,
69    select: Option<&str>,
70    index: Option<i64>,
71    ex: &Extract,
72) -> Result<String, EvalError> {
73    let doc = Document::from(content.to_string());
74    // 省略 select 时作用于整篇(根);否则按选择器(self-or-descendant)。
75    // 用 Matcher 区分「选择器非法」(报错)与「合法但无匹配」(返回空)。
76    let sel = match select {
77        Some(s) => {
78            let matcher = Matcher::new(s).map_err(|_| EvalError::Selector(s.to_string()))?;
79            doc.select_matcher(&matcher)
80        }
81        None => doc.select(":root"),
82    };
83    let nodes = sel.nodes();
84    if nodes.is_empty() {
85        return Ok(String::new());
86    }
87    let node = &nodes[resolve_index(index, nodes.len())];
88    Ok(match ex {
89        // 文本/属性默认去首尾空白(标题/链接等场景几乎总是期望的;与旧引擎一致)。
90        Extract::Op(ExtractOp::Text) => node.text().trim().to_string(),
91        Extract::Op(ExtractOp::OwnText) => node.immediate_text().trim().to_string(),
92        // HTML 正文保留结构,清洗交给 `clean` 步骤。
93        Extract::Op(ExtractOp::Html) => clean_html(&node.inner_html()),
94        Extract::Op(ExtractOp::InnerHtml) => node.inner_html().to_string(),
95        Extract::Op(ExtractOp::OuterHtml) => node.html().to_string(),
96        Extract::Attr { attr } => node
97            .attr(attr)
98            .map(|s| s.trim().to_string())
99            .unwrap_or_default(),
100    })
101}
102
103/// 把正文 HTML 转为可读文本:块级/换行标签 → 换行,去注释,解码常见实体。
104/// (对应旧引擎的 `get_html_string`,用于 `extract: "html"`。)
105fn clean_html(html: &str) -> String {
106    // 以下均为编译期写死的合法正则,运行期不可能编译失败,故 unwrap 安全。
107    static TAGS: LazyLock<Regex> = LazyLock::new(|| {
108        Regex::new(r"</?(?:div|p|br|hr|h[1-6]|article|section|dd|dl|li)[^>]*>").unwrap()
109    });
110    static COMMENTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<!--[\s\S]*?-->").unwrap());
111    static OTHER_TAGS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
112
113    let s = TAGS.replace_all(html, "\n");
114    let s = COMMENTS.replace_all(&s, "");
115    let s = OTHER_TAGS.replace_all(&s, "");
116    decode_entities(&s)
117}
118
119fn decode_entities(s: &str) -> String {
120    s.replace("&amp;", "&")
121        .replace("&lt;", "<")
122        .replace("&gt;", ">")
123        .replace("&nbsp;", " ")
124        .replace("&#39;", "'")
125        .replace("&quot;", "\"")
126}
127
128// ───────────────────────── JSON(jsonpath-rust 1.x)─────────────────────────
129
130fn json_extract(
131    content: &str,
132    select: Option<&str>,
133    index: Option<i64>,
134    ex: &Extract,
135) -> Result<String, EvalError> {
136    let value: Value = serde_json::from_str(content).map_err(|e| EvalError::Json(e.to_string()))?;
137    let path = select.unwrap_or("$");
138    let matched = value
139        .query(path)
140        .map_err(|e| EvalError::JsonPath(e.to_string()))?;
141    if matched.is_empty() {
142        return Ok(String::new());
143    }
144    let v = matched[resolve_index(index, matched.len())];
145    // JSON 上下文里 attr 无意义,统一取标量字符串。
146    let _ = ex;
147    Ok(value_to_string(v))
148}
149
150fn value_to_string(v: &Value) -> String {
151    match v {
152        Value::String(s) => s.clone(),
153        Value::Null => String::new(),
154        other => other.to_string(),
155    }
156}
157
158// ───────────────────────── Regex ─────────────────────────
159
160fn regex_extract(
161    content: &str,
162    select: Option<&str>,
163    index: Option<i64>,
164) -> Result<String, EvalError> {
165    let pat = select.unwrap_or("");
166    let re = Regex::new(pat).map_err(|e| EvalError::Regex(e.to_string()))?;
167    let caps: Vec<String> = re
168        .captures_iter(content)
169        .filter_map(|c| c.ok())
170        .map(|c| {
171            // 有捕获组取第 1 组,否则取整体匹配
172            c.get(1)
173                .or_else(|| c.get(0))
174                .map(|m| m.as_str().to_string())
175                .unwrap_or_default()
176        })
177        .collect();
178    if caps.is_empty() {
179        return Ok(String::new());
180    }
181    Ok(caps[resolve_index(index, caps.len())].clone())
182}
183
184// ───────────────────────── 公共 ─────────────────────────
185
186/// 解析索引:None→0;负数从末尾;越界回退到首/末。
187fn resolve_index(index: Option<i64>, len: usize) -> usize {
188    match index {
189        None => 0,
190        Some(i) if i >= 0 => (i as usize).min(len - 1),
191        Some(i) => {
192            let from_end = (-i) as usize;
193            len.saturating_sub(from_end)
194        }
195    }
196}