1pub 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#[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 pub schema: String,
33 pub name: String,
34 #[serde(default, skip_serializing_if = "String::is_empty")]
35 pub group: String,
36 pub url: String,
38 #[serde(default)]
39 pub http: Http,
40 #[serde(default, skip_serializing_if = "String::is_empty")]
43 pub login_url: String,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub login_ui: Vec<RowUi>,
47 #[serde(default, skip_serializing_if = "String::is_empty")]
49 pub login_check_js: String,
50 #[serde(default)]
52 pub enabled_cookie_jar: bool,
53 #[serde(default, skip_serializing_if = "String::is_empty")]
55 pub concurrent_rate: String,
56 #[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
72pub const SCHEMA_ID: &str = "trnovel-booksource/v2";
74
75fn 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 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, ®istry);
112 }
113 Ok(serde_json::from_value(v)?)
114 }
115
116 pub fn has_login(&self) -> bool {
119 !self.login_url.trim().is_empty() || !self.login_ui.is_empty()
120 }
121
122 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 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, ®);
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 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 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 .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 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 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 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 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 assert!(
322 serde_json::from_value::<Vec<BookSource>>(array.clone()).is_err(),
323 "裸反序列化应在命名 fontMap 字符串引用上失败"
324 );
325
326 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]
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 bs.login_ui = vec![RowUi {
435 name: "用户名".into(),
436 ..Default::default()
437 }];
438 assert!(bs.has_login(), "loginUi 非空应计入登录入口判定");
439 }
440
441 #[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(); assert_eq!(bs.get_login_js(), Some("BODY"));
453 bs.login_url = "https://site/login".into(); assert_eq!(bs.get_login_js(), None);
455 bs.login_url = "".into();
456 assert_eq!(bs.get_login_js(), None);
457 }
458
459 #[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 assert_eq!(bs.book_info.prelude[0].capture[0].scope, VarScope::Chapter);
486 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 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 #[test]
519 fn parses_explore_entries_and_page() {
520 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 assert!(ex.page.request.render);
560 assert_eq!(
561 ex.page.request.intercept_api.as_deref(),
562 Some("book_list/v0")
563 );
564 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 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 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#[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}