1use super::engine::Engine;
5use super::error::{BookSourceError, Result};
6use super::source::Sample;
7
8fn err_detail(e: &BookSourceError) -> String {
10 if e.is_challenge() {
11 "被反爬挑战拦截(如 Cloudflare),需浏览器辅助或改用浏览".into()
12 } else {
13 e.to_string()
14 }
15}
16
17#[cfg(test)]
18mod tests {
19 use super::*;
20 use crate::error::FetchError;
21 use crate::fetch::{FetchRequest, Fetcher};
22 use crate::source::BookSource;
23 use async_trait::async_trait;
24 use std::sync::Arc;
25
26 struct MockFetcher(String);
29 #[async_trait]
30 impl Fetcher for MockFetcher {
31 async fn fetch(&self, _req: FetchRequest) -> std::result::Result<String, FetchError> {
32 Ok(self.0.clone())
33 }
34 }
35
36 struct ChallengeFetcher;
38 #[async_trait]
39 impl Fetcher for ChallengeFetcher {
40 async fn fetch(&self, _req: FetchRequest) -> std::result::Result<String, FetchError> {
41 Err(FetchError::Challenged("Cloudflare/反爬挑战 @ test".into()))
42 }
43 }
44
45 const HTML: &str = r#"<html><head>
46 <meta property="og:novel:book_name" content="测试书">
47 <meta property="og:novel:read_url" content="/toc">
48 </head><body>
49 <div class="module-item"><a class="module-item-title" href="/b1">书一</a></div>
50 <div class="box">
51 <h2 class="module-title type">第一卷</h2>
52 <div class="module-row-info"><a class="module-row-text" href="/c1"><div class="module-row-title"><span>第一章</span></div></a></div>
53 </div>
54 <div class="article-content"><p>正文内容。</p></div>
55 </body></html>"#;
56
57 const SOURCE: &str = r#"{
58 "schema":"trnovel-booksource/v2","name":"mock","url":"https://x",
59 "search":{"request":{"url":{"template":"{{base}}/s?q={{key}}"}},
60 "list":{"via":"css","select":".module-item"},
61 "item":{"bookUrl":{"via":"css","select":".module-item-title","extract":{"attr":"href"}},"name":{"via":"css","select":".module-item-title","extract":"text"}}},
62 "explore":{"entries":[{"static":[{"title":"全部"}]}],
63 "page":{"request":{"url":{"template":"{{base}}/all_{{page}}"}},
64 "list":{"via":"css","select":".module-item"},
65 "item":{"bookUrl":{"via":"css","select":".module-item-title","extract":{"attr":"href"}},"name":{"via":"css","select":".module-item-title","extract":"text"}}}},
66 "bookInfo":{"name":{"via":"css","select":"[property=\"og:novel:book_name\"]","extract":{"attr":"content"}},
67 "tocUrl":{"via":"css","select":"[property=\"og:novel:read_url\"]","extract":{"attr":"content"}}},
68 "toc":{"list":{"via":"css","select":".box > h2.module-title.type, .box a.module-row-text"},
69 "name":{"firstOf":[{"via":"css","select":".module-row-title","extract":"text"},{"via":"css","select":"h2","extract":"text"}]},
70 "url":{"via":"css","select":"a","extract":{"attr":"href"}},
71 "isVolume":{"via":"css","select":"h2","extract":"text"},"maxPages":1},
72 "content":{"value":{"via":"css","select":".article-content","extract":"html"}},
73 "samples":[{"bookUrl":"/b1","expect":{"name":"测试书"}}]
74 }"#;
75
76 #[tokio::test]
77 async fn diagnose_all_capabilities_pass_offline() {
78 let src = BookSource::from_json(SOURCE).unwrap();
79 let engine = Engine::with_fetcher(src, Arc::new(MockFetcher(HTML.to_string())));
80 let report = diagnose(&engine).await;
81 assert!(report.healthy(), "应全部通过,实际: {report}");
82 assert_eq!(report.checks.len(), 6);
84 let toc = report.checks.iter().find(|c| c.name == "目录").unwrap();
85 assert_eq!(toc.status, CheckStatus::Pass);
86 assert!(toc.detail.contains("1 卷 / 1 章"));
87 }
88
89 #[tokio::test]
90 async fn verify_sample_offline() {
91 let src = BookSource::from_json(SOURCE).unwrap();
92 let engine = Engine::with_fetcher(src.clone(), Arc::new(MockFetcher(HTML.to_string())));
93 let report = verify_sample(&engine, &src.samples[0]).await.unwrap();
94 assert!(report.passed, "failures: {:?}", report.failures);
95 assert_eq!(report.name, "测试书");
96 assert_eq!(report.chapters, 1);
97 assert_eq!(report.volumes, 1);
98 }
99
100 #[tokio::test]
101 async fn diagnose_reports_challenge_precisely() {
102 let src = BookSource::from_json(SOURCE).unwrap();
103 let engine = Engine::with_fetcher(src, Arc::new(ChallengeFetcher));
104 let report = diagnose(&engine).await;
105 assert!(!report.healthy(), "被挑战应不健康");
106 assert!(
108 report.checks.iter().any(|c| c.detail.contains("反爬挑战")),
109 "应有精确反爬提示,实际: {report}"
110 );
111 }
112}
113
114#[derive(Debug, Default, Clone)]
116pub struct VerifyReport {
117 pub passed: bool,
119 pub failures: Vec<String>,
121 pub name: String,
122 pub chapters: usize,
123 pub volumes: usize,
124 pub content_chars: usize,
125}
126
127pub async fn verify_sample(engine: &Engine, sample: &Sample) -> Result<VerifyReport> {
129 let mut report = VerifyReport::default();
130
131 let info = engine.book_info(&sample.book_url).await?;
132 report.name = info.name.clone();
133 if info.name.trim().is_empty() {
134 report.failures.push("bookInfo.name 为空".into());
135 }
136
137 let toc_url = if info.toc_url.trim().is_empty() {
138 sample.book_url.clone()
139 } else {
140 info.toc_url.clone()
141 };
142 let toc = engine.toc(&toc_url).await?;
143 report.chapters = toc.chapters.len();
144 report.volumes = toc.volumes.len();
145 if toc.chapters.is_empty() {
146 report.failures.push("目录无章节".into());
147 }
148
149 if let Some(first) = toc.chapters.first() {
150 let content = engine.content(&first.url).await?;
151 report.content_chars = content.chars().count();
152 }
153
154 let e = &sample.expect;
155 if let Some(n) = &e.name
156 && &info.name != n
157 {
158 report
159 .failures
160 .push(format!("name 期望 {:?},实际 {:?}", n, info.name));
161 }
162 if let Some(m) = e.min_chapters
163 && report.chapters < m
164 {
165 report
166 .failures
167 .push(format!("章节数 {} < 期望 {}", report.chapters, m));
168 }
169 if let Some(v) = e.volumes
170 && report.volumes != v
171 {
172 report
173 .failures
174 .push(format!("卷数 {} != 期望 {}", report.volumes, v));
175 }
176 if let Some(c) = e.min_content_chars
177 && report.content_chars < c
178 {
179 report
180 .failures
181 .push(format!("正文 {} 字 < 期望 {}", report.content_chars, c));
182 }
183
184 report.passed = report.failures.is_empty();
185 Ok(report)
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum CheckStatus {
193 Pass,
195 Fail,
197 Skip,
199}
200
201impl CheckStatus {
202 pub fn symbol(&self) -> char {
204 match self {
205 CheckStatus::Pass => '✓',
206 CheckStatus::Fail => '✗',
207 CheckStatus::Skip => '○',
208 }
209 }
210}
211
212#[derive(Debug, Clone)]
214pub struct Check {
215 pub name: &'static str,
216 pub status: CheckStatus,
217 pub detail: String,
218}
219
220impl Check {
221 fn pass(name: &'static str, detail: impl Into<String>) -> Self {
222 Self {
223 name,
224 status: CheckStatus::Pass,
225 detail: detail.into(),
226 }
227 }
228 fn fail(name: &'static str, detail: impl Into<String>) -> Self {
229 Self {
230 name,
231 status: CheckStatus::Fail,
232 detail: detail.into(),
233 }
234 }
235 fn skip(name: &'static str, detail: impl Into<String>) -> Self {
236 Self {
237 name,
238 status: CheckStatus::Skip,
239 detail: detail.into(),
240 }
241 }
242}
243
244#[derive(Debug, Clone)]
246pub struct DiagnoseReport {
247 pub source_name: String,
248 pub checks: Vec<Check>,
249}
250
251impl DiagnoseReport {
252 pub fn healthy(&self) -> bool {
254 self.checks.iter().all(|c| c.status != CheckStatus::Fail)
255 }
256}
257
258impl std::fmt::Display for DiagnoseReport {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 writeln!(f, "书源诊断:{}", self.source_name)?;
261 for c in &self.checks {
262 writeln!(f, " {} {:<6} {}", c.status.symbol(), c.name, c.detail)?;
263 }
264 Ok(())
265 }
266}
267
268pub async fn diagnose(engine: &Engine) -> DiagnoseReport {
273 engine.warmup().await;
274 let src = engine.source();
275 let mut checks = Vec::new();
276
277 checks.push(Check::pass("配置", format!("书源「{}」", src.name)));
279
280 let mut probe_book_url: Option<String> = None;
282 if src.explore.is_some() {
283 match engine.explore_entries().await {
285 Ok(entries) => match entries.first() {
286 Some(entry) => match engine.explore(entry, 1, 20).await {
287 Ok(books) if !books.items.is_empty() => {
288 probe_book_url = books
289 .items
290 .iter()
291 .find(|b| !b.book_url.is_empty())
292 .map(|b| b.book_url.clone());
293 let pages = books
294 .total_pages
295 .map(|m| format!(", 共 {m} 页"))
296 .unwrap_or_default();
297 checks.push(Check::pass(
298 "浏览",
299 format!(
300 "{} 本(入口「{}」,共 {} 个入口{})",
301 books.items.len(),
302 entry.title,
303 entries.len(),
304 pages
305 ),
306 ));
307 }
308 Ok(_) => checks.push(Check::fail("浏览", "结果为空")),
309 Err(e) => checks.push(Check::fail("浏览", err_detail(&e))),
310 },
311 None => checks.push(Check::skip("浏览", "无可用入口")),
312 },
313 Err(e) => checks.push(Check::fail("浏览", err_detail(&e))),
314 }
315 } else {
316 checks.push(Check::skip("浏览", "未配置"));
317 }
318
319 let book_url = src
322 .samples
323 .first()
324 .map(|s| s.book_url.clone())
325 .or(probe_book_url);
326 read_path_checks(engine, book_url, &mut checks).await;
327
328 if src.search.is_some() {
330 match src.samples.first().and_then(|s| s.expect.name.clone()) {
331 Some(q) => match engine.search(&q, 1, 20).await {
332 Ok(books) if !books.items.is_empty() => {
333 let pages = books
334 .total_pages
335 .map(|m| format!(", 共 {m} 页"))
336 .unwrap_or_default();
337 checks.push(Check::pass(
338 "搜索",
339 format!("「{q}」→ {} 本{}", books.items.len(), pages),
340 ))
341 }
342 Ok(_) => checks.push(Check::fail("搜索", format!("「{q}」无结果"))),
343 Err(e) => checks.push(Check::fail("搜索", err_detail(&e))),
344 },
345 None => checks.push(Check::skip("搜索", "无样例查询词 samples[].expect.name")),
346 }
347 } else {
348 checks.push(Check::skip("搜索", "未配置"));
349 }
350
351 DiagnoseReport {
352 source_name: src.name.clone(),
353 checks,
354 }
355}
356
357async fn read_path_checks(engine: &Engine, book_url: Option<String>, checks: &mut Vec<Check>) {
359 let Some(book_url) = book_url else {
360 checks.push(Check::skip("书详情", "无 book_url(需 samples 或可浏览)"));
361 checks.push(Check::skip("目录", "无 book_url"));
362 checks.push(Check::skip("正文", "无 book_url"));
363 return;
364 };
365
366 let info = match engine.book_info(&book_url).await {
367 Ok(info) if !info.name.trim().is_empty() => {
368 checks.push(Check::pass("书详情", info.name.clone()));
369 info
370 }
371 Ok(_) => {
372 checks.push(Check::fail("书详情", "name 为空"));
373 checks.push(Check::skip("目录", "书详情失败"));
374 checks.push(Check::skip("正文", "书详情失败"));
375 return;
376 }
377 Err(e) => {
378 checks.push(Check::fail("书详情", err_detail(&e)));
379 checks.push(Check::skip("目录", "书详情失败"));
380 checks.push(Check::skip("正文", "书详情失败"));
381 return;
382 }
383 };
384
385 let toc_url = if info.toc_url.trim().is_empty() {
386 book_url
387 } else {
388 info.toc_url
389 };
390 let first_chapter_url = match engine.toc(&toc_url).await {
391 Ok(toc) if !toc.chapters.is_empty() => {
392 checks.push(Check::pass(
393 "目录",
394 format!("{} 卷 / {} 章", toc.volumes.len(), toc.chapters.len()),
395 ));
396 Some(toc.chapters[0].url.clone())
397 }
398 Ok(_) => {
399 checks.push(Check::fail("目录", "无章节"));
400 None
401 }
402 Err(e) => {
403 checks.push(Check::fail("目录", err_detail(&e)));
404 None
405 }
406 };
407
408 match first_chapter_url {
409 Some(url) => match engine.content(&url).await {
410 Ok(c) if c.trim().chars().count() >= 1 => {
411 checks.push(Check::pass("正文", format!("{} 字", c.chars().count())))
412 }
413 Ok(_) => checks.push(Check::fail("正文", "正文为空")),
414 Err(e) => checks.push(Check::fail("正文", err_detail(&e))),
415 },
416 None => checks.push(Check::skip("正文", "目录无可用章节")),
417 }
418}