Skip to main content

parse_book_source/
verify.rs

1//! 样例校验回路:对 `samples` 跑完整流程并断言可执行不变量,返回结构化报告。
2//! 同一套断言用于生成期校验与运行期监控(见 design D5)。
3
4use super::engine::Engine;
5use super::error::{BookSourceError, Result};
6use super::source::Sample;
7
8/// 把取页/求值错误转为简短诊断文案;**反爬挑战给精确提示**而非笼统失败。
9fn 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    /// 对所有 URL 返回同一段组合 HTML(同时含 og:meta、目录、卡片、正文),
27    /// 使 book_info/toc/content/explore 各取所需,离线全流程体检。
28    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    /// 对任何 URL 都返回「被反爬挑战」错误,模拟整站被 Cloudflare 挑战。
37    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        // 6 项检查:配置/浏览/书详情/目录/正文/搜索
83        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        // 反爬挑战项给精确提示,而非笼统的错误字符串。
107        assert!(
108            report.checks.iter().any(|c| c.detail.contains("反爬挑战")),
109            "应有精确反爬提示,实际: {report}"
110        );
111    }
112}
113
114/// 一个样例的校验结果。
115#[derive(Debug, Default, Clone)]
116pub struct VerifyReport {
117    /// 是否全部不变量通过。
118    pub passed: bool,
119    /// 失败项的可读描述(期望 vs 实际)。
120    pub failures: Vec<String>,
121    pub name: String,
122    pub chapters: usize,
123    pub volumes: usize,
124    pub content_chars: usize,
125}
126
127/// 对单个样例跑 book_info → toc → 首章 content,并校验不变量。
128pub 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// ───────────────────────── 全流程体检(doctor)─────────────────────────
189
190/// 单项检查状态。
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum CheckStatus {
193    /// 正常(✓)。
194    Pass,
195    /// 异常(✗)。
196    Fail,
197    /// 跳过(未配置 / 缺前置条件,○)。
198    Skip,
199}
200
201impl CheckStatus {
202    /// 状态符号:✓ / ✗ / ○。
203    pub fn symbol(&self) -> char {
204        match self {
205            CheckStatus::Pass => '✓',
206            CheckStatus::Fail => '✗',
207            CheckStatus::Skip => '○',
208        }
209    }
210}
211
212/// 一项能力的检查结果。
213#[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/// 体检报告:逐能力的 ✓/✗/○ 列表。
245#[derive(Debug, Clone)]
246pub struct DiagnoseReport {
247    pub source_name: String,
248    pub checks: Vec<Check>,
249}
250
251impl DiagnoseReport {
252    /// 是否无任何异常项(Skip 不算失败)。
253    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
268/// 全流程体检:逐项跑「配置 / 浏览 / 搜索 / 书详情 / 目录 / 正文」并报告 ✓/✗/○。
269///
270/// 无 `samples` 时,会尝试用浏览/搜索探到的第一本书来测书详情/目录/正文,
271/// 使没有样例的 AI 生成书源也能被全流程验证。
272pub async fn diagnose(engine: &Engine) -> DiagnoseReport {
273    engine.warmup().await;
274    let src = engine.source();
275    let mut checks = Vec::new();
276
277    // 能构造出 Engine 即说明配置已成功解析。
278    checks.push(Check::pass("配置", format!("书源「{}」", src.name)));
279
280    // 浏览(同时探一个可用 book_url 供读取路径在无样例时使用)
281    let mut probe_book_url: Option<String> = None;
282    if src.explore.is_some() {
283        // 先加载动态入口(可能请求远端分类 API),再用首个可用入口测列表。
284        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    // 读取路径(书详情→目录→正文)先于搜索:这是最可靠的链路,
320    // 避免被搜索可能触发的反爬(如 Cloudflare)影响后续请求。
321    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    // 搜索最后做(最易触发反爬;失败也不影响其它检查项)
329    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
357/// 读取路径检查:书详情 → 目录 → 正文。把结果追加到 `checks`(失败不 panic、不冒泡)。
358async 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}