Skip to main content

parse_book_source/engine/
mod.rs

1//! 用例引擎(Template Method + Paginator)。五个操作共享「取页 → 选列表/值 → 映射 →
2//! 可选有界分页」骨架;`Engine` 廉价 `Clone`(内部 `Arc`),操作不跨 await 持锁(D10)。
3//!
4//! 本文件是公开面(构造器 / 访问器 / 五个操作 / 预热);共享的私有管线在 `internal` 子模块。
5
6mod internal;
7#[cfg(test)]
8mod tests;
9
10use crate::error::{BookSourceError, Result};
11use crate::eval::{eval_list, eval_value};
12use crate::fetch::cookie::CookieJar;
13use crate::fetch::{Fetcher, ReqwestFetcher};
14use crate::model::{BookInfo, BookList, Chapter, ExploreEntry, Toc, Volume};
15use crate::source::BookSource;
16use std::collections::{BTreeMap, HashMap};
17use std::sync::{Arc, RwLock};
18
19/// [`Engine::send_templated_full`] 的渲染 + 点击翻页参数束(避免十参数):`render-fetcher` 的
20/// `render`/`ready_for`/`intercept_api` + `search-click-pagination` 的目标页 `page` 与「下一页」
21/// 选择器 `page_by`。普通请求(如 prelude)用 `RenderArgs::default()`(全关闭 = 现状 reqwest 单页)。
22#[derive(Clone, Copy, Default)]
23struct RenderArgs<'a> {
24    render: bool,
25    ready_for: Option<&'a str>,
26    intercept_api: Option<&'a str>,
27    /// 目标页码(1 基);仅 render+intercept 且 `> 1` + `page_by` 有值时驱动点击翻页。
28    page: u32,
29    /// 「下一页」CSS 选择器(`pageBy.click`)。
30    page_by: Option<&'a str>,
31}
32
33/// 书源运行时引擎。
34#[derive(Clone)]
35pub struct Engine {
36    source: Arc<BookSource>,
37    fetcher: Arc<dyn Fetcher>,
38    /// 登录态请求头(JWT/自定义头/Cookie 同路径),并入引擎构造的每个**同注册域**请求
39    /// (跨注册域请求跳过,防页面内容诱导的第三方 URL 外泄凭据,见 [`crate::fetch::cookie::merge_login_into_headers`])。
40    /// 由调用方在登录后经 [`Engine::with_login_header`] 注入(来自 per-source 状态)。
41    login_header: BTreeMap<String, String>,
42    /// cookie 库(按注册域,session/persistent 分离):请求前合并进 `Cookie` 头,
43    /// `enabledCookieJar` 时响应 `Set-Cookie` 回灌。`Arc<RwLock>` 使 `Clone` 的引擎共享同一库。
44    cookies: Arc<RwLock<CookieJar>>,
45    /// 书源级捕获变量(`scope=source`,D7-bis):跨 op 共享(随 `Clone` 的引擎共享),
46    /// flatten 时最低优先级;适合站级常量。
47    source_vars: Arc<RwLock<BTreeMap<String, String>>>,
48    /// 书籍级捕获变量(`scope=book`,D7-bis):per-book,由 app 经 [`Engine::with_book_vars`]
49    /// 注入、[`Engine::book_vars`] 导出(随 per-book 快照持久化)。
50    book_vars: Arc<RwLock<BTreeMap<String, String>>>,
51    /// **渲染取页**的搜索/浏览结果缓存(`键 -> BookList`,`search-click-pagination` 后续):使 UI
52    /// 回翻 / 重访已取页**无需重新驱动浏览器**(render 点击翻页 O(N) 点击成本只付一次)。键含
53    /// 操作 + 词/分类 + 页 + 页大小。随 `Clone` 的引擎共享(`Arc`),**per-source 会话级**(引擎重建即
54    /// 清空)。**仅 render 路径缓存**:reqwest 取页便宜,且缓存它会跳过 cookie 回灌 / 命名捕获等可观察
55    /// 副作用(故不缓存,保持现状语义)。
56    page_cache: Arc<RwLock<HashMap<String, BookList>>>,
57    /// explore 动态入口加载缓存(`dynamic-explore-entries`):入口源可能请求远端分类 API,
58    /// 缓存一次成功加载的 `Vec<ExploreEntry>`,避免 UI 每次进入 explore 都重抓。随 `Clone` 的引擎
59    /// 共享(`Arc`),per-source 会话级(引擎重建即清空)。**仅完全成功(无动态源报错)才缓存**:
60    /// 有动态源失败时返回已成功的部分入口但不缓存,使下次进入可重试。
61    entries_cache: Arc<RwLock<Option<Vec<ExploreEntry>>>>,
62}
63
64impl std::fmt::Debug for Engine {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("Engine")
67            .field("source", &self.source.name)
68            .finish_non_exhaustive()
69    }
70}
71
72impl Engine {
73    /// 用默认 reqwest 取页后端构建。
74    pub fn new(source: BookSource) -> Result<Self> {
75        let fetcher = Arc::new(ReqwestFetcher::new(&source)?);
76        Ok(Self::with_fetcher(source, fetcher))
77    }
78
79    /// 注入自定义取页后端(测试替身 / 反爬适配器)构建。
80    /// 这是**唯一真实构造器**:共享字段(登录态/cookie 库/作用域变量)的默认初始化只在此处,
81    /// 其余构造器([`Engine::new`] / `with_browser_assist`)一律委托,避免新增字段漏改。
82    pub fn with_fetcher(source: BookSource, fetcher: Arc<dyn Fetcher>) -> Self {
83        Self {
84            source: Arc::new(source),
85            fetcher,
86            login_header: BTreeMap::new(),
87            cookies: Arc::new(RwLock::new(CookieJar::default())),
88            source_vars: Arc::new(RwLock::new(BTreeMap::new())),
89            book_vars: Arc::new(RwLock::new(BTreeMap::new())),
90            page_cache: Arc::new(RwLock::new(HashMap::new())),
91            entries_cache: Arc::new(RwLock::new(None)),
92        }
93    }
94
95    /// 读渲染结果缓存(命中返回克隆)。键由 [`Engine::search`]/[`Engine::explore`] 构造(含页码)。
96    fn cached_page(&self, key: &str) -> Option<BookList> {
97        self.page_cache
98            .read()
99            .ok()
100            .and_then(|c| c.get(key).cloned())
101    }
102
103    /// 写渲染结果缓存(取页成功后)。锁竞争失败则静默跳过(缓存仅为优化,丢一条不影响正确性)。
104    fn cache_page(&self, key: String, list: &BookList) {
105        if let Ok(mut c) = self.page_cache.write() {
106            c.insert(key, list.clone());
107        }
108    }
109
110    /// 注入登录态请求头(登录后由调用方从 per-source 状态取出)。链式构造:
111    /// `Engine::new(src)?.with_login_header(state.login_header)`。空 map 等同未登录。
112    #[must_use]
113    pub fn with_login_header(mut self, login_header: BTreeMap<String, String>) -> Self {
114        self.login_header = login_header;
115        self
116    }
117
118    /// 用持久化 cookie(`注册域 -> "k=v"`,来自 per-source 状态)初始化 cookie 库。链式构造。
119    #[must_use]
120    pub fn with_cookies(self, persistent: &BTreeMap<String, String>) -> Self {
121        if let Ok(mut jar) = self.cookies.write() {
122            *jar = CookieJar::from_persistent(persistent);
123        }
124        self
125    }
126
127    /// 导出当前 cookie 库中的 **persistent** cookie(`注册域 -> "k=v"`),供调用方落盘。
128    /// session cookie 不导出(重启失效)。
129    pub fn persistent_cookies(&self) -> BTreeMap<String, String> {
130        self.cookies
131            .read()
132            .map(|j| j.persistent())
133            .unwrap_or_default()
134    }
135
136    /// 注入书籍级捕获变量(`scope=book`,来自 per-book 快照)。链式构造(贴 [`Engine::with_login_header`])。
137    #[must_use]
138    pub fn with_book_vars(self, book_vars: BTreeMap<String, String>) -> Self {
139        if let Ok(mut g) = self.book_vars.write() {
140            *g = book_vars;
141        }
142        self
143    }
144
145    /// 合并书源级捕获变量(`scope=source`,来自 per-source 状态)。链式构造。
146    #[must_use]
147    pub fn with_source_vars(self, source_vars: &BTreeMap<String, String>) -> Self {
148        if let Ok(mut g) = self.source_vars.write() {
149            for (k, v) in source_vars {
150                g.insert(k.clone(), v.clone());
151            }
152        }
153        self
154    }
155
156    /// 导出书籍级捕获变量,供 app 随 per-book 快照落盘(`scope=book` 跨会话复用的承载)。
157    pub fn book_vars(&self) -> BTreeMap<String, String> {
158        self.book_vars.read().map(|g| g.clone()).unwrap_or_default()
159    }
160
161    /// 导出书源级捕获变量,供 app 落盘(可选;默认构建为进程内)。
162    pub fn source_vars(&self) -> BTreeMap<String, String> {
163        self.source_vars
164            .read()
165            .map(|g| g.clone())
166            .unwrap_or_default()
167    }
168
169    /// 用「升级式取页」构建(`browser` feature):平时 reqwest,撞挑战且 `browser` 为
170    /// `Some` 时升级解挑战。是否传入浏览器(书源 `http.fetcher` ∧ 用户授权 ∧ 探测到)
171    /// 的策略由调用方(app)决定;`None` 等同纯 reqwest(撞挑战即降级)。
172    #[cfg(feature = "browser")]
173    pub fn with_browser_assist(
174        source: BookSource,
175        browser: Option<crate::fetch::browser::BrowserFetcher>,
176    ) -> Result<Self> {
177        let fetcher = crate::fetch::browser::EscalatingFetcher::new(&source, browser)?;
178        Ok(Self::with_fetcher(source, Arc::new(fetcher)))
179    }
180
181    /// 暴露只读配置。
182    pub fn source(&self) -> &BookSource {
183        &self.source
184    }
185
186    /// 书源 URL(per-source 登录态文件的 key,供 app 回写 persistent cookie 落盘时定位)。
187    pub fn source_url(&self) -> &str {
188        &self.source.url
189    }
190
191    /// 预热:按 `http.warmup` 先访问若干页以累积会话 cookie(失败忽略)。
192    /// 走 `Engine::run_request` 统一管线——`enabledCookieJar` 时预热页的 `Set-Cookie`
193    /// 才会回灌引擎 cookie 库(`loginCheckJs` 在预热页可能误判,但错误被吞,不影响预热语义)。
194    pub async fn warmup(&self) {
195        for u in &self.source.http.warmup {
196            let _ = self.run_request(self.get_req(u.clone())).await;
197        }
198    }
199
200    /// 书籍详情(可选前置请求链 → 取详情页 → 抽取)。
201    pub async fn book_info(&self, book_url: &str) -> Result<BookInfo> {
202        let mut chapter = self.base_vars();
203        self.run_prelude(&self.source.book_info.prelude, &mut chapter)
204            .await?;
205        let html = self.fetch_checked(book_url).await?;
206        let rules = self.source.book_info.as_book_rules();
207        self.eval_book_info(&rules, &html, &self.flatten(&chapter))
208    }
209
210    /// 目录(章节 + 分卷),支持前置请求链 + 有界分页。
211    pub async fn toc(&self, toc_url: &str) -> Result<Toc> {
212        let toc = &self.source.toc;
213        let mut chapter = self.base_vars();
214        self.run_prelude(&toc.prelude, &mut chapter).await?;
215        let vars = self.flatten(&chapter);
216        let pages = self
217            .fetch_pages(toc_url, toc.next_page.as_ref(), toc.max_pages, &vars)
218            .await?;
219
220        let mut chapters: Vec<Chapter> = Vec::new();
221        let mut volumes: Vec<Volume> = Vec::new();
222        for page in &pages {
223            for item in eval_list(&toc.list, page)? {
224                let title = eval_value(&toc.name, &item, &vars)?;
225                let is_volume = match &toc.is_volume {
226                    Some(r) => !eval_value(r, &item, &vars)?.trim().is_empty(),
227                    None => false,
228                };
229                if is_volume {
230                    volumes.push(Volume {
231                        title,
232                        first_chapter_index: chapters.len(),
233                    });
234                } else {
235                    let url = eval_value(&toc.url, &item, &vars)?;
236                    chapters.push(Chapter {
237                        title,
238                        url,
239                        is_volume: false,
240                    });
241                }
242            }
243        }
244        Ok(Toc { chapters, volumes })
245    }
246
247    /// 正文,支持前置请求链 + 有界分页。
248    pub async fn content(&self, chapter_url: &str) -> Result<String> {
249        let c = &self.source.content;
250        let mut chapter = self.base_vars();
251        self.run_prelude(&c.prelude, &mut chapter).await?;
252        let vars = self.flatten(&chapter);
253        let pages = self
254            .fetch_pages(chapter_url, c.next_page.as_ref(), c.max_pages, &vars)
255            .await?;
256        let mut parts = Vec::with_capacity(pages.len());
257        for page in &pages {
258            parts.push(eval_value(&c.value, page, &vars)?);
259        }
260        Ok(parts.join("\n"))
261    }
262
263    /// 搜索。返回书列表 + 可选精确总页数(`render-dual-source`)/ 边界(`list-has-more`)。
264    /// `key` 作为 `{{key}}` 变量并入共享列表页 runner。
265    pub async fn search(&self, key: &str, page: u32, page_size: u32) -> Result<BookList> {
266        let op = self
267            .source
268            .search
269            .as_ref()
270            .ok_or(BookSourceError::Missing("search"))?;
271        let mut extra = BTreeMap::new();
272        extra.insert("key".to_string(), key.to_string());
273        self.run_list_page(op, 's', &extra, page, page_size).await
274    }
275
276    /// 浏览选中入口的某一页(由用户递增 `page` 单页取)。用入口变量驱动共享列表页 runner;
277    /// 取页 URL 由 `explore.page.request` 用入口变量 + `{{page}}` 模板生成(入口不再持固定 URL)。
278    pub async fn explore(
279        &self,
280        entry: &ExploreEntry,
281        page: u32,
282        page_size: u32,
283    ) -> Result<BookList> {
284        let op = self
285            .source
286            .explore
287            .as_ref()
288            .ok_or(BookSourceError::Missing("explore"))?;
289        self.run_list_page(&op.page, 'e', &entry.vars, page, page_size)
290            .await
291    }
292
293    /// 加载 explore 入口(`dynamic-explore-entries`):按声明顺序遍历入口源数组,合并各源产出的
294    /// 扁平 `ExploreEntry`(静态固定入口 + 远端抓取动态入口)。
295    ///
296    /// 失败策略:某个(动态)源失败时保留已成功入口(含前面的静态源),不阻断;仅当**零入口**产出
297    /// 且有源报错时返回该错误。完全成功(无任何源报错)才写入口缓存,使下次进入可重试失败的动态源。
298    pub async fn explore_entries(&self) -> Result<Vec<ExploreEntry>> {
299        if let Some(cached) = self.entries_cache.read().ok().and_then(|g| g.clone()) {
300            return Ok(cached);
301        }
302        let Some(op) = self.source.explore.as_ref() else {
303            return Ok(Vec::new());
304        };
305        let mut out: Vec<ExploreEntry> = Vec::new();
306        let mut first_err: Option<BookSourceError> = None;
307        for src in &op.entries {
308            match self.load_entry_source(src).await {
309                Ok(mut entries) => out.append(&mut entries),
310                Err(e) => {
311                    first_err.get_or_insert(e);
312                }
313            }
314        }
315        // 零入口且有报错 → 把错误冒泡给 UI(否则用户看到空分类却无原因)。
316        if out.is_empty()
317            && let Some(e) = first_err.take()
318        {
319            return Err(e);
320        }
321        // 完全成功才缓存;有动态源失败时返回部分入口但不缓存(下次进入重试)。
322        if first_err.is_none()
323            && let Ok(mut g) = self.entries_cache.write()
324        {
325            *g = Some(out.clone());
326        }
327        Ok(out)
328    }
329}