Skip to main content

parse_book_source/source/
mod.rs

1//! v2 书源配置类型(纯 serde,镜像 `book-source.schema.json`)。
2//!
3//! 规则是显式结构化对象,无任何紧凑字符串 DSL。`Rule` 既是配置、也是供求值器
4//! 遍历的语法树(见 design D1/D6)。配置类型按职责分文件:
5//! - [`rule`] — 规则 AST(`Rule`/`LeafRule`/抽取语义)。
6//! - [`clean`] — `clean` 流水线算子(编解码/哈希/加解密/繁简)。
7//! - [`http`] — HTTP 配置与请求/多步编排(前置链 + 命名捕获)。
8//! - [`op`] — 各操作(搜索/浏览/详情/目录/正文)的字段规则与样例。
9//!
10//! 本文件聚合顶层 [`BookSource`] 并 re-export 上述子模块类型,使 `crate::source::*`
11//! 历史路径保持不变。
12
13pub mod clean;
14pub mod http;
15pub mod op;
16pub mod rule;
17
18pub use clean::*;
19pub use http::*;
20pub use op::*;
21pub use rule::*;
22
23use crate::error::ConfigError;
24use serde::{Deserialize, Serialize};
25
26/// v2 书源。
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct BookSource {
31    /// 固定为 `"trnovel-booksource/v2"`。
32    pub schema: String,
33    pub name: String,
34    #[serde(default, skip_serializing_if = "String::is_empty")]
35    pub group: String,
36    /// 站点基址,用于相对链接解析与 `{{base}}`。
37    pub url: String,
38    #[serde(default)]
39    pub http: Http,
40    /// 登录:普通 URL,或登录脚本(`@js:…` / `<js>…</js>` 包裹,内含 `login()` 函数)。
41    /// 非空即视为「需要登录」(见 [`BookSource::has_login`]);仅 `js-host` 构建可真正执行脚本登录。
42    #[serde(default, skip_serializing_if = "String::is_empty")]
43    pub login_url: String,
44    /// 声明式登录表单(TUI 渲染);收集值加密存为 loginInfo,供 `login()` 脚本读取。
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub login_ui: Vec<RowUi>,
47    /// 登录态过期校验脚本:每个网络方法响应后执行(注入 `result`=响应),判失效可提示重登。
48    #[serde(default, skip_serializing_if = "String::is_empty")]
49    pub login_check_js: String,
50    /// 开启后:响应的 `Set-Cookie` 自动回灌进 cookie 库(按注册域归并持久化)。
51    #[serde(default)]
52    pub enabled_cookie_jar: bool,
53    /// 限速:`"N/ms"`(N 次/ms)或纯毫秒间隔字符串;为空则用 `http.rateLimit`。
54    #[serde(default, skip_serializing_if = "String::is_empty")]
55    pub concurrent_rate: String,
56    /// 命名共享 fontMap 表(`名字 -> {码点:真字}`):供多个 clean 步骤按名复用,避免同一张
57    /// 大表在书源里重复内联。clean 的 `fontMap` 写字符串名(如 `{"fontMap":"search"}`)即引用本表,
58    /// 在 [`BookSource::from_json`] 解析时就地展开为内联表(运行期与直接内联等价)。
59    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
60    pub font_maps: std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub search: Option<SearchOp>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub explore: Option<ExploreOp>,
65    pub book_info: BookInfoOp,
66    pub toc: TocRules,
67    pub content: ContentRules,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub samples: Vec<Sample>,
70}
71
72/// 期望的 schema 标识。
73pub const SCHEMA_ID: &str = "trnovel-booksource/v2";
74
75/// 递归展开命名共享 fontMap:遇到对象里 `"fontMap"` 的值为**字符串**(引用名)时,
76/// 用 `registry[名]` 的内联表替换;然后递归子节点(含刚内联的表,内层无 fontMap 故安全)。
77fn expand_named_font_maps(v: &mut serde_json::Value, registry: &serde_json::Value) {
78    match v {
79        serde_json::Value::Object(map) => {
80            let replacement = match map.get("fontMap") {
81                Some(serde_json::Value::String(name)) => registry.get(name).cloned(),
82                _ => None,
83            };
84            if let Some(table) = replacement {
85                map.insert("fontMap".to_string(), table);
86            }
87            for val in map.values_mut() {
88                expand_named_font_maps(val, registry);
89            }
90        }
91        serde_json::Value::Array(arr) => {
92            for val in arr.iter_mut() {
93                expand_named_font_maps(val, registry);
94            }
95        }
96        _ => {}
97    }
98}
99
100impl BookSource {
101    /// 从 JSON 字符串解析一个书源。
102    ///
103    /// 解析前先展开**命名共享 fontMap**:把所有 clean 步骤里 `{"fontMap":"名字"}` 的字符串引用,
104    /// 就地替换为顶层 `fontMaps[名字]` 的内联表(通用 JSON 遍历,覆盖任意位置;未知名→留原字符串,
105    /// 后续按 BTreeMap 反序列化会失败并给出清晰错误)。展开后与「直接内联」逐字节等价。
106    pub fn from_json(s: &str) -> Result<Self, ConfigError> {
107        let mut v: serde_json::Value = serde_json::from_str(s)?;
108        if let Some(registry) = v.get("fontMaps").cloned()
109            && registry.is_object()
110        {
111            expand_named_font_maps(&mut v, &registry);
112        }
113        Ok(serde_json::from_value(v)?)
114    }
115
116    /// 是否需要登录(`loginUrl` 或 `loginUi` 任一非空)。据此在 TUI 暴露「书源登录」入口。
117    /// 注:仅配置 `loginUi` 而无登录脚本/`loginUrl` 属配置不完整,由登录页拦截并提示。
118    pub fn has_login(&self) -> bool {
119        !self.login_url.trim().is_empty() || !self.login_ui.is_empty()
120    }
121
122    /// 若 `loginUrl` 是登录脚本(`@js:` 或 `<js>…</js>` 包裹),剥壳返回脚本体;
123    /// 否则(普通 URL 或空)返回 `None`——此时走 headful 浏览器登录。
124    pub fn get_login_js(&self) -> Option<&str> {
125        let s = self.login_url.trim();
126        if let Some(js) = s.strip_prefix("@js:") {
127            Some(js.trim())
128        } else if let Some(rest) = s.strip_prefix("<js>") {
129            Some(rest.strip_suffix("</js>").unwrap_or(rest).trim())
130        } else {
131            None
132        }
133    }
134
135    /// 从 JSON 值解析一个或多个书源(支持单对象或数组)。
136    /// 与 [`BookSource::from_json`] 一致:逐源就地展开命名 fontMap 引用(数组里每个源各自的
137    /// `fontMaps` 注册表),否则 `{"fontMap":"名"}` 字符串引用会反序列化失败。
138    pub fn from_value_many(value: serde_json::Value) -> Result<Vec<Self>, ConfigError> {
139        fn expand_one(v: &mut serde_json::Value) {
140            if let Some(reg) = v.get("fontMaps").cloned()
141                && reg.is_object()
142            {
143                expand_named_font_maps(v, &reg);
144            }
145        }
146        match value {
147            serde_json::Value::Array(mut arr) => {
148                for item in arr.iter_mut() {
149                    expand_one(item);
150                }
151                Ok(serde_json::from_value(serde_json::Value::Array(arr))?)
152            }
153            mut v => {
154                expand_one(&mut v);
155                Ok(vec![serde_json::from_value(v)?])
156            }
157        }
158    }
159
160    /// 从本地文件导入(支持单对象或数组)。
161    pub fn from_path(path: &str) -> Result<Vec<Self>, crate::error::BookSourceError> {
162        let text = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
163        let value = serde_json::from_str(&text).map_err(ConfigError::Json)?;
164        Ok(Self::from_value_many(value)?)
165    }
166
167    /// 从网络 URL 导入(支持单对象或数组)。
168    pub async fn from_url(url: &str) -> Result<Vec<Self>, crate::error::BookSourceError> {
169        use crate::error::FetchError;
170        let text = reqwest::get(url)
171            .await
172            .map_err(FetchError::Http)?
173            // 先判 HTTP 状态:4xx/5xx 返回错误页时,避免把"非 JSON"误报成 JSON 解析失败。
174            .error_for_status()
175            .map_err(FetchError::Http)?
176            .text()
177            .await
178            .map_err(FetchError::Http)?;
179        let value = serde_json::from_str(&text).map_err(ConfigError::Json)?;
180        Ok(Self::from_value_many(value)?)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// 与 examples/bilixs.v2.json 同构的代表性书源(覆盖 leaf / firstOf / concat /
189    /// template / attr / http+cookies / samples / 分卷 isVolume)。
190    const BILIXS_V2: &str = r#"{
191      "schema": "trnovel-booksource/v2",
192      "name": "哔哩小说",
193      "group": "测试",
194      "url": "https://www.bilixs.com",
195      "http": {
196        "headers": { "User-Agent": "Mozilla/5.0" },
197        "cookies": {},
198        "warmup": ["https://www.bilixs.com/"],
199        "charset": "auto",
200        "timeout": 15000,
201        "retry": { "max": 2, "backoffMs": 500 }
202      },
203      "search": {
204        "request": { "url": { "template": "{{base}}/search.html?searchkey={{key}}" }, "method": "GET" },
205        "list": { "via": "css", "select": ".module-item" },
206        "item": {
207          "name": { "via": "css", "select": ".module-item-title", "extract": "text" },
208          "tocUrl": { "via": "css", "select": ".module-item-title", "extract": { "attr": "href" } }
209        }
210      },
211      "explore": {
212        "entries": [ { "static": [ { "title": "最近更新" } ] } ],
213        "page": {
214          "request": { "url": { "template": "{{base}}/book/lastupdate_0_1_0_0_0_0_0_{{page}}_0.html" } },
215          "list": { "via": "css", "select": ".module-item" },
216          "item": { "name": { "via": "css", "select": ".module-item-title", "extract": "text" } }
217        }
218      },
219      "bookInfo": {
220        "name": { "via": "css", "select": "[property=\"og:novel:book_name\"]", "extract": { "attr": "content" } },
221        "cover": { "via": "css", "select": "[property=\"og:image\"]", "extract": { "attr": "content" } },
222        "kind": { "concat": [
223            { "via": "css", "select": "[property=\"og:novel:tags\"]", "extract": { "attr": "content" } },
224            { "via": "css", "select": "[property=\"og:novel:status\"]", "extract": { "attr": "content" } }
225          ], "join": " · " },
226        "tocUrl": { "via": "css", "select": "[property=\"og:novel:read_url\"]", "extract": { "attr": "content" } }
227      },
228      "toc": {
229        "list": { "via": "css", "select": ".box > h2.module-title.type, .box a.module-row-text" },
230        "name": { "firstOf": [
231            { "via": "css", "select": ".module-row-title", "extract": "text" },
232            { "via": "css", "select": "h2", "extract": "text" }
233          ] },
234        "url": { "via": "css", "select": "a", "extract": { "attr": "href" } },
235        "isVolume": { "via": "css", "select": "h2", "extract": "text" },
236        "maxPages": 1
237      },
238      "content": {
239        "value": { "via": "css", "select": ".article-content", "extract": "html",
240          "clean": [ { "regex": "请收藏本站[^<\\n]*", "replace": "" }, { "trim": true } ] }
241      },
242      "samples": [
243        { "bookUrl": "/novel/guzhenren.html", "expect": { "name": "蛊真人", "volumes": 8, "minChapters": 2000 } }
244      ]
245    }"#;
246
247    #[test]
248    fn parses_v2_book_source() {
249        let bs = BookSource::from_json(BILIXS_V2).expect("应解析 v2 书源");
250        assert_eq!(bs.schema, SCHEMA_ID);
251        assert_eq!(bs.name, "哔哩小说");
252    }
253
254    #[test]
255    fn named_font_map_is_expanded_from_registry() {
256        // clean 里 `{"fontMap":"fm"}` 字符串引用 → 顶层 fontMaps["fm"] 内联表;另留一份直接内联对照。
257        let json = r#"{
258            "schema":"trnovel-booksource/v2","name":"t","url":"https://e.com",
259            "fontMaps": { "fm": { "E001": "一", "E002": "二" } },
260            "bookInfo": { "name": {"via":"css","select":"h1"} },
261            "toc": { "list": {"via":"css","select":"a"}, "name": {"via":"css","select":"a"},
262                     "url": {"via":"css","select":"a","extract":{"attr":"href"}} },
263            "content": { "value": {"via":"css","select":".c","extract":"html",
264                         "clean":[{"fontMap":"fm"}]} }
265        }"#;
266        let bs = BookSource::from_json(json).expect("应解析并展开命名 fontMap");
267        let clean = match &bs.content.value {
268            Rule::Leaf(l) => &l.clean,
269            other => panic!("content.value 应为叶子: {other:?}"),
270        };
271        let fm = clean[0]
272            .font_map
273            .as_ref()
274            .expect("命名 fontMap 应展开为内联表");
275        assert_eq!(fm.get("E001").map(String::as_str), Some("一"));
276        assert_eq!(fm.get("E002").map(String::as_str), Some("二"));
277    }
278
279    #[test]
280    fn inline_font_map_still_works() {
281        // 直接内联的 fontMap(无命名引用)保持原样,向后兼容。
282        let json = r#"{
283            "schema":"trnovel-booksource/v2","name":"t","url":"https://e.com",
284            "bookInfo": { "name": {"via":"css","select":"h1"} },
285            "toc": { "list": {"via":"css","select":"a"}, "name": {"via":"css","select":"a"},
286                     "url": {"via":"css","select":"a","extract":{"attr":"href"}} },
287            "content": { "value": {"via":"css","select":".c","extract":"html",
288                         "clean":[{"fontMap":{"E0FF":"好"}}]} }
289        }"#;
290        let bs = BookSource::from_json(json).unwrap();
291        let Rule::Leaf(l) = &bs.content.value else {
292            panic!("应为叶子")
293        };
294        assert_eq!(
295            l.clean[0]
296                .font_map
297                .as_ref()
298                .unwrap()
299                .get("E0FF")
300                .map(String::as_str),
301            Some("好")
302        );
303    }
304
305    #[test]
306    fn from_value_many_expands_named_font_map_in_array() {
307        // 回归(对应 BookSourceCache::load 修复):外部生成 / 手改的书源缓存,数组里仍是
308        // `{"fontMap":"名"}` 字符串引用,必须经 from_value_many 逐源展开才能反序列化。
309        let one = r#"{
310            "schema":"trnovel-booksource/v2","name":"t","url":"https://e.com",
311            "fontMaps": { "fm": { "E001": "一" } },
312            "bookInfo": { "name": {"via":"css","select":"h1"} },
313            "toc": { "list": {"via":"css","select":"a"}, "name": {"via":"css","select":"a"},
314                     "url": {"via":"css","select":"a","extract":{"attr":"href"}} },
315            "content": { "value": {"via":"css","select":".c","extract":"html",
316                         "clean":[{"fontMap":"fm"}]} }
317        }"#;
318        let array: serde_json::Value = serde_json::from_str(&format!("[{one}]")).unwrap();
319
320        // 旧缓存路径(裸反序列化)在命名 fontMap 字符串引用上失败 —— 正是本次修复的 bug。
321        assert!(
322            serde_json::from_value::<Vec<BookSource>>(array.clone()).is_err(),
323            "裸反序列化应在命名 fontMap 字符串引用上失败"
324        );
325
326        // 新缓存路径(from_value_many)逐源展开后成功。
327        let sources = BookSource::from_value_many(array).expect("应展开命名 fontMap 并解析");
328        assert_eq!(sources.len(), 1);
329        let Rule::Leaf(l) = &sources[0].content.value else {
330            panic!("content.value 应为叶子")
331        };
332        assert_eq!(
333            l.clean[0]
334                .font_map
335                .as_ref()
336                .expect("命名 fontMap 应展开为内联表")
337                .get("E001")
338                .map(String::as_str),
339            Some("一")
340        );
341    }
342
343    #[test]
344    fn toc_name_is_firstof_with_two_leaves() {
345        let bs = BookSource::from_json(BILIXS_V2).unwrap();
346        match &bs.toc.name {
347            Rule::FirstOf { first_of } => assert_eq!(first_of.len(), 2),
348            other => panic!("toc.name 应为 firstOf,实际 {other:?}"),
349        }
350    }
351
352    #[test]
353    fn toc_is_volume_is_leaf_css_h2() {
354        let bs = BookSource::from_json(BILIXS_V2).unwrap();
355        let iv = bs.toc.is_volume.as_ref().expect("isVolume 应存在");
356        match iv {
357            Rule::Leaf(l) => {
358                assert_eq!(l.via, Via::Css);
359                assert_eq!(l.select.as_deref(), Some("h2"));
360            }
361            other => panic!("isVolume 应为叶子,实际 {other:?}"),
362        }
363    }
364
365    #[test]
366    fn search_url_is_template_rule() {
367        let bs = BookSource::from_json(BILIXS_V2).unwrap();
368        let req = &bs.search.as_ref().unwrap().request;
369        match &req.url {
370            UrlOrRule::Rule(r) => assert!(matches!(**r, Rule::Template { .. })),
371            other => panic!("search.request.url 应为模板规则,实际 {other:?}"),
372        }
373    }
374
375    #[test]
376    fn book_info_cover_extracts_attr() {
377        let bs = BookSource::from_json(BILIXS_V2).unwrap();
378        match bs.book_info.cover.as_ref().unwrap() {
379            Rule::Leaf(l) => assert_eq!(
380                l.extract,
381                Extract::Attr {
382                    attr: "content".into()
383                }
384            ),
385            other => panic!("cover 应为属性抽取叶子,实际 {other:?}"),
386        }
387    }
388
389    #[test]
390    fn http_cookies_and_warmup_parsed() {
391        let bs = BookSource::from_json(BILIXS_V2).unwrap();
392        assert_eq!(bs.http.warmup, vec!["https://www.bilixs.com/"]);
393        assert_eq!(bs.http.charset, Charset::Auto);
394        assert_eq!(bs.http.retry.as_ref().unwrap().backoff_ms, 500);
395    }
396
397    #[test]
398    fn sample_expectations_parsed() {
399        let bs = BookSource::from_json(BILIXS_V2).unwrap();
400        let s = &bs.samples[0];
401        assert_eq!(s.expect.volumes, Some(8));
402        assert_eq!(s.expect.min_chapters, Some(2000));
403    }
404
405    #[test]
406    fn round_trips_through_json() {
407        let bs = BookSource::from_json(BILIXS_V2).unwrap();
408        let json = serde_json::to_string(&bs).unwrap();
409        let bs2 = BookSource::from_json(&json).unwrap();
410        assert_eq!(bs, bs2);
411    }
412
413    #[test]
414    fn rejects_unknown_top_level_field() {
415        let bad = BILIXS_V2.replacen("\"name\":", "\"nmae\":", 1);
416        assert!(
417            BookSource::from_json(&bad).is_err(),
418            "拼错字段应被 deny_unknown_fields 拒绝"
419        );
420    }
421
422    // ── 审查/test-coverage:has_login 判定(决定 TUI 是否显示登录入口)──
423    #[test]
424    fn has_login_when_login_url_or_login_ui_present() {
425        let mut bs = BookSource::from_json(BILIXS_V2).unwrap();
426        assert!(!bs.has_login(), "默认无 loginUrl/loginUi 不需登录");
427        bs.login_url = "https://site/login".into();
428        assert!(bs.has_login());
429        bs.login_url = "@js:function login(){}".into();
430        assert!(bs.has_login());
431        bs.login_url = "   ".into();
432        assert!(!bs.has_login(), "纯空白 loginUrl 视为不需登录");
433        // 仅配置 loginUi 也计入(TUI 须给登录入口;配置完整性由登录页校验)。
434        bs.login_ui = vec![RowUi {
435            name: "用户名".into(),
436            ..Default::default()
437        }];
438        assert!(bs.has_login(), "loginUi 非空应计入登录入口判定");
439    }
440
441    // ── 审查/test-coverage:get_login_js 剥壳各分支(@js: / <js>…</js> / 半包裹 / 普通 URL / 空)──
442    #[test]
443    fn get_login_js_strips_prefixes() {
444        let mut bs = BookSource::from_json(BILIXS_V2).unwrap();
445        bs.login_url = "@js: function login(){} ".into();
446        assert_eq!(bs.get_login_js(), Some("function login(){}"));
447        bs.login_url = "<js>BODY</js>".into();
448        assert_eq!(bs.get_login_js(), Some("BODY"));
449        bs.login_url = "<js> A </js>".into();
450        assert_eq!(bs.get_login_js(), Some("A"));
451        bs.login_url = "<js>BODY".into(); // 缺尾标签:容错保留整段
452        assert_eq!(bs.get_login_js(), Some("BODY"));
453        bs.login_url = "https://site/login".into(); // 普通 URL → None(走浏览器登录)
454        assert_eq!(bs.get_login_js(), None);
455        bs.login_url = "".into();
456        assert_eq!(bs.get_login_js(), None);
457    }
458
459    // ── 11.1:前置请求链 + 结构化捕获 解析 / round-trip / deny_unknown_fields ──
460    #[test]
461    fn parses_prelude_capture_and_round_trips() {
462        let json = r#"{
463          "schema":"trnovel-booksource/v2","name":"t","url":"https://x",
464          "search":{
465            "prelude":[{
466              "url":{"template":"{{base}}/prepare"},
467              "capture":[{"name":"token","value":{"via":"json","select":"$.token"},"scope":"source"}],
468              "skipIfPresent":["token"]
469            }],
470            "request":{"url":{"template":"{{base}}/s?token={{token}}"}},
471            "list":{"via":"css","select":".i"},
472            "item":{"name":{"via":"css","select":".t","extract":"text"}}
473          },
474          "bookInfo":{"prelude":[{"url":{"template":"{{base}}/p"},"capture":[{"name":"csrf","value":{"via":"raw"}}]}]},
475          "toc":{"list":{"via":"css","select":"a"},"name":{"via":"css","select":"a"},"url":{"via":"css","select":"a","extract":{"attr":"href"}}},
476          "content":{"value":{"via":"css","select":".c"}}
477        }"#;
478        let bs = BookSource::from_json(json).expect("应解析含 prelude 的书源");
479        let sp = &bs.search.as_ref().unwrap().prelude;
480        assert_eq!(sp.len(), 1);
481        assert_eq!(sp[0].capture[0].name, "token");
482        assert_eq!(sp[0].capture[0].scope, VarScope::Source);
483        assert_eq!(sp[0].skip_if_present, vec!["token".to_string()]);
484        // bookInfo 前置步骤默认 scope = chapter。
485        assert_eq!(bs.book_info.prelude[0].capture[0].scope, VarScope::Chapter);
486        // round-trip 相等。
487        let s = serde_json::to_string(&bs).unwrap();
488        assert_eq!(BookSource::from_json(&s).unwrap(), bs);
489    }
490
491    #[test]
492    fn prestep_rejects_unknown_field() {
493        let bad = r#"{
494          "schema":"trnovel-booksource/v2","name":"t","url":"https://x",
495          "toc":{"prelude":[{"url":{"template":"{{base}}/p"},"captuer":[]}],
496                 "list":{"via":"css","select":"a"},"name":{"via":"css","select":"a"},"url":{"via":"css","select":"a"}},
497          "bookInfo":{},
498          "content":{"value":{"via":"css","select":".c"}}
499        }"#;
500        assert!(
501            BookSource::from_json(bad).is_err(),
502            "PreStep 拼错字段(captuer)应被 deny_unknown_fields 拒"
503        );
504    }
505
506    #[test]
507    fn existing_source_serializes_without_new_fields() {
508        // 向后兼容:无 prelude/vars 的书源序列化输出不含任何新字段(逐字节)。
509        let bs = BookSource::from_json(BILIXS_V2).unwrap();
510        let json = serde_json::to_string(&bs).unwrap();
511        assert!(!json.contains("prelude"), "无前置链不应序列化 prelude");
512        assert!(!json.contains("\"vars\""), "空 vars 不应序列化");
513        assert!(!json.contains("skipIfPresent"));
514        assert!(!json.contains("\"capture\""));
515    }
516
517    // ── dynamic-explore-entries:explore 两阶段(entries + page,page.request 承载 render/interceptApi)──
518    #[test]
519    fn parses_explore_entries_and_page() {
520        // static + fetch(含 forEach)入口源数组 + render 列表页(render/interceptApi 在 page.request 上)。
521        let json = r#"{
522          "schema":"trnovel-booksource/v2","name":"t","url":"https://x",
523          "explore":{
524            "entries":[
525              { "static":[ {"title":"全部·最热","vars":{"filter":"all","sort":"hottest"}} ] },
526              { "fetch":{
527                  "forEach":[ {"gender":"0","audience":"女生"}, {"gender":"1","audience":"男生"} ],
528                  "request":{"url":{"template":"{{base}}/api/category?gender={{gender}}"}},
529                  "list":{"via":"json","select":"$.data[*]"},
530                  "item":{
531                    "title":{"concat":[{"template":"{{audience}}·"},{"via":"json","select":"$.name"}]},
532                    "vars":{"filter":{"via":"json","select":"$.id"},"sort":{"literal":"hottest"}}
533                  }
534              }}
535            ],
536            "page":{
537              "request":{
538                "url":{"template":"{{base}}/library/{{filter}}/page_{{page}}?sort={{sort}}"},
539                "render":true,
540                "interceptApi":"book_list/v0"
541              },
542              "list":{"via":"json","select":"$.data.book_list[*]"},
543              "item":{"name":{"via":"json","select":"$.book_name"}}
544            }
545          },
546          "bookInfo":{},
547          "toc":{"list":{"via":"css","select":"a"},"name":{"via":"css","select":"a"},"url":{"via":"css","select":"a","extract":{"attr":"href"}}},
548          "content":{"value":{"via":"css","select":".c"}}
549        }"#;
550        let bs = BookSource::from_json(json).expect("应解析 explore entries/page");
551        let ex = bs.explore.as_ref().unwrap();
552        assert_eq!(ex.entries.len(), 2, "static + fetch 两个入口源");
553        assert!(matches!(ex.entries[0], EntrySource::Static { .. }));
554        match &ex.entries[1] {
555            EntrySource::Fetch { fetch } => assert_eq!(fetch.for_each.len(), 2),
556            other => panic!("第二个入口源应为 fetch,实际 {other:?}"),
557        }
558        // render/interceptApi 在 page.request 上(与 search 同构)。
559        assert!(ex.page.request.render);
560        assert_eq!(
561            ex.page.request.intercept_api.as_deref(),
562            Some("book_list/v0")
563        );
564        // round-trip 相等。
565        let s = serde_json::to_string(&bs).unwrap();
566        assert_eq!(BookSource::from_json(&s).unwrap(), bs);
567    }
568
569    #[test]
570    fn explore_rejects_unknown_field() {
571        // deny_unknown_fields:explore 拼错字段被拒。
572        let bad = r#"{
573          "schema":"trnovel-booksource/v2","name":"t","url":"https://x",
574          "explore":{"entries":[],"page":{"request":{"url":{"template":"{{base}}/x"}},"list":{"via":"css","select":".i"},"item":{}},"entriez":[]},
575          "bookInfo":{},
576          "toc":{"list":{"via":"css","select":"a"},"name":{"via":"css","select":"a"},"url":{"via":"css","select":"a"}},
577          "content":{"value":{"via":"css","select":".c"}}
578        }"#;
579        assert!(
580            BookSource::from_json(bad).is_err(),
581            "explore 拼错字段(entriez)应被 deny_unknown_fields 拒"
582        );
583    }
584
585    #[test]
586    fn old_explore_categories_format_is_rejected() {
587        // 不兼容旧格式:`categories` + 内联 list/item 的旧 explore 必须被拒(deny_unknown_fields:
588        // 缺 entries/page 必填字段且含未知 categories/list/item),而非静默转换。
589        let old = r#"{
590          "schema":"trnovel-booksource/v2","name":"t","url":"https://x",
591          "explore":{"categories":[{"title":"全部","url":{"template":"{{base}}/all/{{page}}"}}],
592                     "list":{"via":"css","select":".i"},"item":{}},
593          "bookInfo":{},
594          "toc":{"list":{"via":"css","select":"a"},"name":{"via":"css","select":"a"},"url":{"via":"css","select":"a"}},
595          "content":{"value":{"via":"css","select":".c"}}
596        }"#;
597        assert!(
598            BookSource::from_json(old).is_err(),
599            "旧 explore.categories 格式应被拒绝,不静默转换"
600        );
601    }
602}
603
604/// 防漂移:`book-source.schema.json` 必须等于从类型现生成的 schema(`--features schema`)。
605/// 失败说明改了配置类型却没重新生成 schema——按提示重跑 gen_schema 即可。
606#[cfg(all(test, feature = "schema"))]
607mod schema_sync {
608    #[test]
609    fn schema_is_in_sync() {
610        let generated =
611            serde_json::to_string_pretty(&schemars::schema_for!(crate::BookSource)).unwrap();
612        let committed = include_str!("../../book-source.schema.json");
613        assert_eq!(
614            generated.trim(),
615            committed.trim(),
616            "book-source.schema.json 与配置类型不同步;请重新生成:\n  \
617             cargo run -p parse-book-source --features schema --example gen_schema \
618             > crates/parse-book-source/book-source.schema.json"
619        );
620    }
621}