1pub(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
20pub type Vars = HashMap<String, String>;
22
23pub 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
55fn 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
69pub 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 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 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
94fn 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 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
139pub(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 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 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 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 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 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 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 let r2 = rule(r#"{"via":"raw","clean":[{"js":"result.toUpperCase()"}]}"#);
350 assert_eq!(eval_value(&r2, "abc", &Vars::new()).unwrap(), "ABC");
351 }
352}