Skip to main content

parse_book_source/eval/
mod.rs

1//! 规则解释器(Interpreter + Composite)。递归遍历 [`Rule`] 求值;无字符串 DSL 解析。
2//!
3//! 两个入口:
4//! - [`eval_value`]:值规则 → 一个字符串。
5//! - [`eval_list`]:列表规则 → 多个「子上下文」内容串(每个供后续 item 规则求值)。
6
7// 求值子模块:抽取后端(css/json/regex/raw 分派)、XPath 后端、确定性 transform 算子、JS 逃生舱。
8pub(crate) mod backend;
9#[cfg(feature = "js")]
10pub(crate) mod js;
11pub(crate) mod transform;
12mod xpath;
13
14use crate::error::EvalError;
15use crate::source::{CleanStep, Rule};
16use fancy_regex::Regex;
17use std::collections::HashMap;
18use std::sync::LazyLock;
19
20/// 模板插值变量表(`{{key}}` / `{{page}}` / `{{base}}` / 命名捕获)。
21pub type Vars = HashMap<String, String>;
22
23/// 对当前上下文求一个值。
24pub fn eval_value(rule: &Rule, ctx: &str, vars: &Vars) -> Result<String, EvalError> {
25    match rule {
26        Rule::Literal { literal } => Ok(literal.clone()),
27        Rule::Template { template } => Ok(interpolate(template, vars)),
28        Rule::FirstOf { first_of } => {
29            for r in first_of {
30                let v = eval_value(r, ctx, vars)?;
31                if !v.trim().is_empty() {
32                    return Ok(v);
33                }
34            }
35            Ok(String::new())
36        }
37        Rule::Concat { concat, join } => {
38            let mut parts = Vec::new();
39            for r in concat {
40                let v = eval_value(r, ctx, vars)?;
41                if !v.trim().is_empty() {
42                    parts.push(v);
43                }
44            }
45            Ok(parts.join(join))
46        }
47        Rule::Js { js } => run_js(js, ctx, vars),
48        Rule::Leaf(l) => {
49            let raw = backend::extract(l.via, ctx, l.select.as_deref(), l.index, &l.extract)?;
50            apply_clean(raw, &l.clean, vars)
51        }
52    }
53}
54
55/// 执行一段 JS(逃生舱):以 `result` 为当前上下文、注入变量 + `crypto` 助手。
56/// 未启用 `js` feature 时返回 `Unsupported("js")`(但书源仍可解析)。
57fn run_js(script: &str, result: &str, vars: &Vars) -> Result<String, EvalError> {
58    #[cfg(feature = "js")]
59    {
60        js::eval_js(script, result, vars)
61    }
62    #[cfg(not(feature = "js"))]
63    {
64        let _ = (script, result, vars);
65        Err(EvalError::Unsupported("js"))
66    }
67}
68
69/// 选中所有匹配,返回各自的子上下文内容串。
70pub fn eval_list(rule: &Rule, ctx: &str) -> Result<Vec<String>, EvalError> {
71    match rule {
72        Rule::Leaf(l) => match l.select.as_deref() {
73            Some(sel) => backend::select_all(l.via, ctx, sel),
74            // 无选择器:把当前上下文作为单一项(而非把空串当非法选择器)。
75            None => Ok(vec![ctx.to_string()]),
76        },
77        Rule::FirstOf { first_of } => {
78            for r in first_of {
79                let v = eval_list(r, ctx)?;
80                if !v.is_empty() {
81                    return Ok(v);
82                }
83            }
84            Ok(Vec::new())
85        }
86        // literal/template/concat 作为列表无意义:退化为单值(若非空)。
87        other => {
88            let v = eval_value(other, ctx, &Vars::new())?;
89            Ok(if v.is_empty() { Vec::new() } else { vec![v] })
90        }
91    }
92}
93
94/// 应用清洗流水线。步内固定顺序:
95/// `regex→replace → trim → prepend → append → decode → encode → hash → cipher → fontMap → cn`。
96/// 编解码/加解密会失败(非法输入、错密钥),故返回 `Result`(显式报错,不静默空)。
97fn apply_clean(mut s: String, steps: &[CleanStep], vars: &Vars) -> Result<String, EvalError> {
98    for step in steps {
99        if let Some(pat) = &step.regex {
100            // 非法正则是配置错误,显式报错(与抽取层 regex_extract 及下方 crypto 步一致),不静默跳过。
101            let re = Regex::new(pat).map_err(|e| EvalError::Regex(e.to_string()))?;
102            let rep = step.replace.as_deref().unwrap_or("");
103            s = re.replace_all(&s, rep).into_owned();
104        }
105        if step.trim.unwrap_or(false) {
106            s = s.trim().to_string();
107        }
108        if let Some(p) = &step.prepend {
109            s = format!("{p}{s}");
110        }
111        if let Some(a) = &step.append {
112            s = format!("{s}{a}");
113        }
114        if let Some(c) = step.decode {
115            s = transform::decode(&s, c)?;
116        }
117        if let Some(c) = step.encode {
118            s = transform::encode(&s, c)?;
119        }
120        if let Some(h) = &step.hash {
121            s = transform::hash(&s, h)?;
122        }
123        if let Some(c) = &step.cipher {
124            s = transform::cipher(&s, c)?;
125        }
126        if let Some(table) = &step.font_map {
127            s = transform::font_map(&s, table)?;
128        }
129        if let Some(cn) = step.cn {
130            s = transform::cn_convert(&s, cn);
131        }
132        if let Some(js) = &step.js {
133            s = run_js(js, &s, vars)?;
134        }
135    }
136    Ok(s)
137}
138
139/// 把 `{{key}}` 替换为变量值,未知键替换为空串。
140pub(crate) fn interpolate(template: &str, vars: &Vars) -> String {
141    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{\s*([\w.\-]+)\s*\}\}").unwrap());
142    RE.replace_all(template, |c: &fancy_regex::Captures| {
143        c.get(1)
144            .and_then(|m| vars.get(m.as_str()))
145            .cloned()
146            .unwrap_or_default()
147    })
148    .into_owned()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::source::Rule;
155
156    fn rule(j: &str) -> Rule {
157        serde_json::from_str(j).expect("rule json")
158    }
159
160    // 合成的 bilixs 式目录:.box 内 直接子 h2(卷)+ a.module-row-text(章);
161    // span 包裹的"阅读进度"应被 `.box > h2` 排除。
162    const CATALOG: &str = r#"<html><body>
163      <div class="box">
164        <span id="shuqian"><h2 class="module-title type">阅读进度</h2></span>
165        <h2 class="module-title type">第一卷 魔性不改</h2>
166        <div class="module-row-info"><a class="module-row-text" href="/n/1.html"><i></i><div class="module-row-title"><span>第一章 甲</span></div></a></div>
167        <div class="module-row-info"><a class="module-row-text" href="/n/2.html"><i></i><div class="module-row-title"><span>第二章 乙</span></div></a></div>
168        <h2 class="module-title type">第二卷 魔子出山</h2>
169        <div class="module-row-info"><a class="module-row-text" href="/n/3.html"><i></i><div class="module-row-title"><span>第三章 丙</span></div></a></div>
170      </div>
171    </body></html>"#;
172
173    fn toc_list() -> Rule {
174        rule(r#"{"via":"css","select":".box > h2.module-title.type, .box a.module-row-text"}"#)
175    }
176
177    #[test]
178    fn list_selects_volumes_and_chapters_in_document_order() {
179        let items = eval_list(&toc_list(), CATALOG).unwrap();
180        assert_eq!(items.len(), 5, "2 卷 + 3 章 = 5(排除 span 内的阅读进度)");
181    }
182
183    #[test]
184    fn toc_rules_split_into_volumes_and_chapters() {
185        let name = rule(
186            r#"{"firstOf":[{"via":"css","select":".module-row-title","extract":"text"},{"via":"css","select":"h2","extract":"text"}]}"#,
187        );
188        let url = rule(r#"{"via":"css","select":"a","extract":{"attr":"href"}}"#);
189        let is_volume = rule(r#"{"via":"css","select":"h2","extract":"text"}"#);
190        let vars = Vars::new();
191
192        let mut chapters = Vec::new();
193        let mut volumes = Vec::new();
194        for it in eval_list(&toc_list(), CATALOG).unwrap() {
195            let nm = eval_value(&name, &it, &vars).unwrap();
196            if eval_value(&is_volume, &it, &vars)
197                .unwrap()
198                .trim()
199                .is_empty()
200            {
201                let u = eval_value(&url, &it, &vars).unwrap();
202                chapters.push((nm, u));
203            } else {
204                volumes.push(nm);
205            }
206        }
207        assert_eq!(volumes, vec!["第一卷 魔性不改", "第二卷 魔子出山"]);
208        assert_eq!(chapters.len(), 3);
209        assert_eq!(
210            chapters[0],
211            ("第一章 甲".to_string(), "/n/1.html".to_string())
212        );
213        assert_eq!(
214            chapters[2],
215            ("第三章 丙".to_string(), "/n/3.html".to_string())
216        );
217    }
218
219    #[test]
220    fn book_info_extracts_og_meta_attr() {
221        let html = r#"<head><meta property="og:novel:book_name" content="蛊真人"><meta property="og:image" content="https://x/c.jpg"></head>"#;
222        let name = rule(
223            r#"{"via":"css","select":"[property=\"og:novel:book_name\"]","extract":{"attr":"content"}}"#,
224        );
225        assert_eq!(eval_value(&name, html, &Vars::new()).unwrap(), "蛊真人");
226    }
227
228    #[test]
229    fn content_html_extract_cleans_paragraphs() {
230        let html = r#"<div class="article-content"><p>第一段。</p><p>第二段。</p></div>"#;
231        let r = rule(
232            r#"{"via":"css","select":".article-content","extract":"html","clean":[{"trim":true}]}"#,
233        );
234        let out = eval_value(&r, html, &Vars::new()).unwrap();
235        assert!(out.contains("第一段。"));
236        assert!(out.contains("第二段。"));
237        assert!(out.contains('\n'), "段落间应有换行");
238    }
239
240    #[test]
241    fn clean_font_map_restores_via_inline_table() {
242        // camelCase "fontMap" 反序列化 + clean 流水线接线;fontMap 直接是「码点→字」表。
243        let r = rule(r#"{"via":"raw","clean":[{"fontMap":{"E001":"甲","E002":"乙"}}]}"#);
244        assert_eq!(
245            eval_value(&r, "\u{E001}\u{E002}!", &Vars::new()).unwrap(),
246            "甲乙!"
247        );
248    }
249
250    #[test]
251    fn template_interpolates_vars() {
252        let r = rule(r#"{"template":"{{base}}/search?q={{key}}&pg={{page}}"}"#);
253        let mut vars = Vars::new();
254        vars.insert("base".into(), "https://x.com".into());
255        vars.insert("key".into(), "蛊真人".into());
256        vars.insert("page".into(), "2".into());
257        assert_eq!(
258            eval_value(&r, "", &vars).unwrap(),
259            "https://x.com/search?q=蛊真人&pg=2"
260        );
261    }
262
263    #[test]
264    fn firstof_falls_back_to_second_when_first_empty() {
265        let r = rule(
266            r#"{"firstOf":[{"via":"css","select":".nope","extract":"text"},{"via":"css","select":"h2","extract":"text"}]}"#,
267        );
268        let html = r#"<h2>标题</h2>"#;
269        assert_eq!(eval_value(&r, html, &Vars::new()).unwrap(), "标题");
270    }
271
272    #[test]
273    fn clean_regex_replace_strips_boilerplate() {
274        let r = rule(
275            r#"{"via":"raw","clean":[{"regex":"请收藏本站[^\\n]*","replace":""},{"trim":true}]}"#,
276        );
277        let out = eval_value(&r, "正文内容 请收藏本站xxx.com", &Vars::new()).unwrap();
278        assert_eq!(out, "正文内容");
279    }
280
281    #[test]
282    fn clean_pipeline_decrypts_content() {
283        // 端到端:clean 链 cipher 步对「base64 密文」AES-CBC 解密出明文。
284        // 先用 transform 造出该书源会返回的 base64 密文(模拟服务端加密)。
285        use crate::source::{ByteEnc, CipherAlgo, CipherMode, CipherOp, CipherStep, Padding};
286        let plain = "蛊真人 第一章 正文……";
287        let ct = transform::cipher(
288            plain,
289            &CipherStep {
290                algo: CipherAlgo::Aes,
291                mode: CipherMode::Cbc,
292                padding: Padding::Pkcs7,
293                op: CipherOp::Encrypt,
294                key: "0123456789abcdef".into(),
295                key_enc: ByteEnc::Utf8,
296                iv: Some("abcdef9876543210".into()),
297                iv_enc: ByteEnc::Utf8,
298                input_enc: Some(ByteEnc::Utf8),
299                output_enc: Some(ByteEnc::Base64),
300            },
301        )
302        .unwrap();
303
304        // 书源侧:via:raw 取到密文,clean 用 cipher 步解密(默认 decrypt + inputEnc=base64)。
305        let r = rule(
306            r#"{"via":"raw","clean":[{"cipher":{"algo":"aes","mode":"cbc","key":"0123456789abcdef","iv":"abcdef9876543210"}}]}"#,
307        );
308        let out = eval_value(&r, &ct, &Vars::new()).unwrap();
309        assert_eq!(out, plain);
310    }
311
312    #[test]
313    fn clean_cipher_error_propagates() {
314        // 非法 base64 密文 → cipher 步报错(显式失败,不静默空)。
315        let r = rule(
316            r#"{"via":"raw","clean":[{"cipher":{"algo":"aes","mode":"cbc","key":"0123456789abcdef","iv":"abcdef9876543210"}}]}"#,
317        );
318        let err = eval_value(&r, "!!!not-base64!!!", &Vars::new());
319        assert!(matches!(
320            err,
321            Err(EvalError::Codec(_) | EvalError::Crypto(_))
322        ));
323    }
324
325    #[test]
326    fn js_rule_parses_regardless_of_feature() {
327        // Rule::Js 与 clean.js 变体恒可解析(配置可移植,不受 feature 影响)。
328        assert!(matches!(rule(r#"{"js":"result + '!'"}"#), Rule::Js { .. }));
329        let r = rule(r#"{"via":"raw","clean":[{"js":"result"}]}"#);
330        assert!(matches!(r, Rule::Leaf(_)));
331    }
332
333    #[cfg(not(feature = "js"))]
334    #[test]
335    fn js_rule_unsupported_without_feature() {
336        let r = rule(r#"{"js":"result + '!'"}"#);
337        assert!(matches!(
338            eval_value(&r, "x", &Vars::new()),
339            Err(EvalError::Unsupported("js"))
340        ));
341    }
342
343    #[cfg(feature = "js")]
344    #[test]
345    fn js_rule_evaluates_with_feature() {
346        let r = rule(r#"{"js":"result + '!'"}"#);
347        assert_eq!(eval_value(&r, "x", &Vars::new()).unwrap(), "x!");
348        // clean.js 也生效:取值后 JS 后处理。
349        let r2 = rule(r#"{"via":"raw","clean":[{"js":"result.toUpperCase()"}]}"#);
350        assert_eq!(eval_value(&r2, "abc", &Vars::new()).unwrap(), "ABC");
351    }
352}