Skip to main content

parse_book_source/fetch/
mod.rs

1//! 取页端口(Ports & Adapters)。`trait Fetcher` 抽象「拿一个 URL 的解码后正文」,
2//! 默认实现 [`ReqwestFetcher`];反爬后端(wreq / FlareSolverr)可作为另一个 `Fetcher`
3//! 适配器接入而不动引擎(见 design D8/D10)。
4
5#[cfg(feature = "browser")]
6pub mod browser;
7pub mod cookie;
8
9use crate::error::FetchError;
10use crate::fetch::cookie::{merge_cookie_str, sanitize_header_value};
11use crate::source::{BookSource, Charset, Method, RateLimit, Retry};
12use async_trait::async_trait;
13use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
14use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18/// 简单令牌桶式限速器:保证两次请求间隔 >= `interval`。
19/// 锁仅用于读改时间戳,在 `.await` 前释放(不跨 await 持锁,符合 design D10)。
20struct RateLimiter {
21    interval: Duration,
22    last: Mutex<Option<Instant>>,
23}
24
25impl RateLimiter {
26    fn from_config(rl: &RateLimit) -> Option<Self> {
27        if rl.max_count == 0 || rl.per_ms == 0 {
28            return None;
29        }
30        Some(Self {
31            interval: Duration::from_millis(rl.per_ms / rl.max_count),
32            last: Mutex::new(None),
33        })
34    }
35
36    /// 解析 `concurrentRate` 字符串:`"N/ms"`(N 次每 ms)或纯毫秒间隔(`"1000"` = 每 1000ms 一次)。
37    fn from_rate_str(s: &str) -> Option<Self> {
38        let s = s.trim();
39        if s.is_empty() {
40            return None;
41        }
42        let (max_count, per_ms) = match s.split_once('/') {
43            Some((n, ms)) => (n.trim().parse().ok()?, ms.trim().parse().ok()?),
44            None => (1, s.parse().ok()?),
45        };
46        Self::from_config(&RateLimit { max_count, per_ms })
47    }
48
49    async fn acquire(&self) {
50        let wait = {
51            let mut last = self.last.lock().expect("rate limiter mutex poisoned");
52            let now = Instant::now();
53            let wait = match *last {
54                Some(prev) => self
55                    .interval
56                    .checked_sub(now.duration_since(prev))
57                    .unwrap_or(Duration::ZERO),
58                None => Duration::ZERO,
59            };
60            // 预约下一次可用时刻(即便本次需等待),使并发请求依次错开。
61            *last = Some(now + wait);
62            wait
63        }; // 锁在此释放,sleep 不持锁
64        if !wait.is_zero() {
65            tokio::time::sleep(wait).await;
66        }
67    }
68}
69
70/// 判定一次响应是否为反爬挑战(纯函数,便于离线测试)。
71///
72/// 命中任一即视为挑战页(而非真实内容):
73/// ① 响应头 `cf-mitigated: challenge`(最干净、机器可读);
74/// ② HTTP 403/503 且 body 含 Cloudflare 挑战脚本特征
75///    (`_cf_chl_opt` / `/cdn-cgi/challenge-platform/` / `<title>Just a moment`)。
76pub fn is_challenge(status: u16, cf_mitigated: Option<&str>, body: &str) -> bool {
77    if cf_mitigated == Some("challenge") {
78        return true;
79    }
80    matches!(status, 403 | 503)
81        && (body.contains("_cf_chl_opt")
82            || body.contains("/cdn-cgi/challenge-platform/")
83            || body.contains("<title>Just a moment"))
84}
85
86/// 一次取页请求(URL 已是最终待请求地址或相对路径)。
87#[derive(Debug, Clone, Default)]
88pub struct FetchRequest {
89    pub url: String,
90    pub method: Method,
91    pub body: Option<String>,
92    pub headers: HashMap<String, String>,
93    /// 渲染型取页(`render-fetcher`):为真则用受控浏览器渲染本 URL(默认 headless),
94    /// 跑站点自身 JS,而非 reqwest 直取。仅 [`crate::fetch::browser::EscalatingFetcher`] 在
95    /// `browser` feature 下识别;其它 fetcher 忽略本字段(退化为普通取页)。
96    pub render: bool,
97    /// 渲染就绪等待选择器。两种用法:
98    /// - **无 `intercept_api`**(方式 A):渲染后轮询该选择器出现,取渲染后 DOM 作 body;
99    /// - **与 `intercept_api` 共存**(`render-dual-source`):拦 API 取 body 之外,等该选择器出现后
100    ///   另抓渲染 DOM 入 [`FetchResponse::dom_html`](供 `via:css` 规则对 DOM 求值,如分页器总页数)。
101    pub ready_for: Option<String>,
102    /// CDP 拦截:渲染时拦截 URL 含此子串的响应体作为取页 body。可与 `ready_for` 共存(见上)。
103    pub intercept_api: Option<String>,
104    /// 目标页码(`search-click-pagination`,1 基):仅 render + `intercept_api` 的点击翻页路径用——
105    /// 与 `page_by` 共存且 `> 1` 时,引擎在一张活页内点 `page-1` 次翻到此页。其它路径忽略(默认 0)。
106    pub page: u32,
107    /// 点击驱动翻页的「下一页」CSS 选择器(`search-click-pagination`):URL 不认页码的 SPA
108    /// (如番茄 search)靠点它递增页码。`Some` 且 `page > 1` + 有 `intercept_api` 时启用点击翻页;
109    /// 其它路径忽略(默认 `None` = 现状单拦截 / `{{page}}` URL 模板翻页)。
110    pub page_by: Option<String>,
111}
112
113impl FetchRequest {
114    /// 便捷构造一个 GET 请求。
115    pub fn get(url: impl Into<String>) -> Self {
116        Self {
117            url: url.into(),
118            ..Default::default()
119        }
120    }
121}
122
123/// 一次取页的完整响应:解码后 body + HTTP 状态码 + 响应头。
124///
125/// 供 `net.connect` 读取 `Set-Cookie` / `Location` / 状态码等(`fetch` 只回 body)。
126/// 同名多值头(如多个 `Set-Cookie`)以 `\n` 连接。
127#[derive(Debug, Clone, Default)]
128pub struct FetchResponse {
129    pub body: String,
130    pub status: u16,
131    pub headers: HashMap<String, String>,
132    /// 渲染后 DOM(`render-dual-source`):**仅** render + `interceptApi` 且调用方要 DOM 时有值
133    /// (拦到 API body 后另抓 `outerHTML`,供 `via:css`/`xpath` 规则对 DOM 求值,如分页器总页数)。
134    /// 其它取页路径恒为 `None`。
135    pub dom_html: Option<String>,
136}
137
138/// 取页抽象。实现者负责发请求 + 按目标站字符集解码为文本。
139#[async_trait]
140pub trait Fetcher: Send + Sync {
141    /// 取一个页面的解码后文本。
142    async fn fetch(&self, req: FetchRequest) -> Result<String, FetchError>;
143
144    /// 取完整响应(body + 状态码 + 响应头)。默认仅回 body(状态 200、头为空);
145    /// 需要 headers/状态码的实现(如 [`ReqwestFetcher`])应覆盖本方法。
146    async fn fetch_full(&self, req: FetchRequest) -> Result<FetchResponse, FetchError> {
147        let body = self.fetch(req).await?;
148        Ok(FetchResponse {
149            body,
150            status: 200,
151            headers: HashMap::new(),
152            dom_html: None,
153        })
154    }
155}
156
157/// 基于 reqwest + rustls + cookie_store 的默认取页实现(含限速与重试)。
158pub struct ReqwestFetcher {
159    client: reqwest::Client,
160    base: String,
161    charset: Charset,
162    retry: Option<Retry>,
163    limiter: Option<RateLimiter>,
164    /// 书源静态 `http.cookies` 合成串:除进 `default_headers` 外留存一份,
165    /// 供请求级 `Cookie` 出现时在 [`final_header_value`] 合并(reqwest 的 `default_headers`
166    /// 对同名请求级头是「整体替换」而非合并语义)。
167    static_cookie: Option<String>,
168}
169
170impl ReqwestFetcher {
171    /// 依据书源的 `http` 配置构建客户端(默认头、静态 cookie、超时)。
172    pub fn new(source: &BookSource) -> Result<Self, FetchError> {
173        let http = &source.http;
174        let mut headers = HeaderMap::new();
175        for (k, v) in &http.headers {
176            let name = HeaderName::from_bytes(k.as_bytes())
177                .map_err(|e| FetchError::Header(e.to_string()))?;
178            let val = HeaderValue::from_str(v).map_err(|e| FetchError::Header(e.to_string()))?;
179            headers.insert(name, val);
180        }
181        // 静态 cookie 合成为 Cookie 头(会话 cookie 仍由 cookie_store 自动累积);
182        // 原串同时留存于 self.static_cookie,供请求级 Cookie 出现时合并(见 final_header_value)。
183        let static_cookie = if http.cookies.is_empty() {
184            None
185        } else {
186            let cookie = http
187                .cookies
188                .iter()
189                .map(|(k, v)| format!("{k}={v}"))
190                .collect::<Vec<_>>()
191                .join("; ");
192            let val =
193                HeaderValue::from_str(&cookie).map_err(|e| FetchError::Header(e.to_string()))?;
194            headers.insert(reqwest::header::COOKIE, val);
195            Some(cookie)
196        };
197
198        let mut builder = reqwest::Client::builder()
199            .cookie_store(true)
200            .default_headers(headers);
201        if let Some(ms) = http.timeout {
202            builder = builder.timeout(Duration::from_millis(ms));
203        }
204        let client = builder.build()?;
205
206        Ok(Self {
207            client,
208            base: source.url.trim_end_matches('/').to_string(),
209            charset: http.charset,
210            retry: http.retry.clone(),
211            // 限速来源:优先 http.rateLimit,否则 concurrentRate("N/ms" 或间隔)。
212            limiter: http
213                .rate_limit
214                .as_ref()
215                .and_then(RateLimiter::from_config)
216                .or_else(|| RateLimiter::from_rate_str(&source.concurrent_rate)),
217            static_cookie,
218        })
219    }
220
221    /// 发起一次请求并解码(单次,不含重试),返回完整响应(body + 状态码 + 响应头)。
222    async fn send_once(&self, url: &str, req: &FetchRequest) -> Result<FetchResponse, FetchError> {
223        let mut builder = match req.method {
224            Method::Get => self.client.get(url),
225            Method::Post => self.client.post(url),
226        };
227        for (k, v) in &req.headers {
228            builder = builder.header(k, final_header_value(self.static_cookie.as_deref(), k, v));
229        }
230        if let Some(body) = &req.body {
231            builder = builder.body(body.clone());
232        }
233        let resp = builder.send().await?;
234        let status = resp.status();
235        // 收集响应头(同名多值以 `\n` 连接,保留多个 Set-Cookie)。
236        let mut headers = HashMap::new();
237        for (name, value) in resp.headers() {
238            if let Ok(v) = value.to_str() {
239                headers
240                    .entry(name.as_str().to_string())
241                    .and_modify(|e: &mut String| {
242                        e.push('\n');
243                        e.push_str(v);
244                    })
245                    .or_insert_with(|| v.to_string());
246            }
247        }
248        // 反爬信号:`cf-mitigated: challenge` 头(最干净、机器可读)。
249        let cf_mitigated = headers.get("cf-mitigated").cloned();
250        // 先取出 HTTP 状态错误(error_for_status_ref 不消费 body),
251        // 再读 body 以便识别挑战页特征(挑战常以 403 返回)。
252        let status_err = resp.error_for_status_ref().err();
253        let bytes = resp.bytes().await?;
254        let text = self.decode(&bytes);
255        if is_challenge(status.as_u16(), cf_mitigated.as_deref(), &text) {
256            return Err(FetchError::Challenged(format!(
257                "Cloudflare/反爬挑战 @ {url}"
258            )));
259        }
260        if let Some(e) = status_err {
261            return Err(FetchError::Http(e));
262        }
263        Ok(FetchResponse {
264            body: text,
265            status: status.as_u16(),
266            headers,
267            dom_html: None,
268        })
269    }
270
271    /// 限速 + resolve + 重试 的取页主循环,返回完整响应。
272    async fn fetch_full_inner(&self, req: FetchRequest) -> Result<FetchResponse, FetchError> {
273        // 渲染型取页需浏览器(见 `render-fetcher`):reqwest 不支持,给精确信息供上层降级/诊断
274        // (否则会拿到 SPA 空壳、下游 via:json 解析失败报「invalid json」误导)。
275        // 用常驻的 `Challenged`(`Browser` 变体仅 `browser` feature):本 fetcher 无 feature 门控。
276        if req.render {
277            return Err(FetchError::Challenged(format!(
278                "此请求需渲染型取页(浏览器辅助),reqwest 不支持 @ {}",
279                req.url
280            )));
281        }
282        // 限速(如配置):错开请求间隔。
283        if let Some(limiter) = &self.limiter {
284            limiter.acquire().await;
285        }
286        let url = self.resolve(&req.url);
287
288        // 重试:失败后按 backoff 退避,最多重试 retry.max 次。
289        let max = self.retry.as_ref().map(|r| r.max).unwrap_or(0);
290        let backoff = self.retry.as_ref().map(|r| r.backoff_ms).unwrap_or(0);
291        let mut attempt = 0u32;
292        loop {
293            match self.send_once(&url, &req).await {
294                Ok(resp) => return Ok(resp),
295                Err(e) => {
296                    // 反爬挑战重试无意义(仍会被挑战),直接返回交上层升级/降级。
297                    if matches!(e, FetchError::Challenged(_)) || attempt >= max {
298                        return Err(e);
299                    }
300                    attempt += 1;
301                    if backoff > 0 {
302                        tokio::time::sleep(Duration::from_millis(backoff)).await;
303                    }
304                }
305            }
306        }
307    }
308
309    /// 把相对路径解析为绝对 URL(`http(s)` 开头则原样返回)。
310    pub(crate) fn resolve(&self, url: &str) -> String {
311        if url.starts_with("http://") || url.starts_with("https://") {
312            url.to_string()
313        } else if let Some(rest) = url.strip_prefix('/') {
314            format!("{}/{}", self.base, rest)
315        } else {
316            format!("{}/{}", self.base, url)
317        }
318    }
319
320    /// 按 charset 把响应字节解码为文本。
321    fn decode(&self, bytes: &[u8]) -> String {
322        use encoding_rs::{BIG5, GB18030, GBK, UTF_8};
323        match self.charset {
324            Charset::Utf8 => UTF_8.decode(bytes).0.into_owned(),
325            Charset::Gbk => GBK.decode(bytes).0.into_owned(),
326            Charset::Gb18030 => GB18030.decode(bytes).0.into_owned(),
327            Charset::Big5 => BIG5.decode(bytes).0.into_owned(),
328            Charset::Auto => {
329                let (text, _, had_err) = UTF_8.decode(bytes);
330                if had_err {
331                    GBK.decode(bytes).0.into_owned()
332                } else {
333                    text.into_owned()
334                }
335            }
336        }
337    }
338}
339
340/// 计算一个请求级 header 的最终出站值(纯函数,便于离线单测):
341///
342/// - 值一律剥 CR/LF——纵深防御:已落盘的脏 loginHeader/cookie(多 `Set-Cookie` 以 `\n` 连接)
343///   不致让 reqwest builder 构建失败、拖垮该书源全部请求;
344/// - `Cookie` 头(大小写不敏感)与书源静态 `http.cookies` 串合并:reqwest 对 `default_headers`
345///   是「请求级同名头存在时整体替换」语义,不合并的话登录/jar Cookie 一注入,静态的设备/风控
346///   cookie 就被整串顶掉。静态串为最低优先级基底(请求级同名 key 胜出)。
347///   注:服务端 `Max-Age=0` 删除与静态配置同名的 cookie 时,静态值会被「复活」——
348///   这是书源静态 cookie 的固有语义(Legado 亦始终发送书源配置 cookie),可接受。
349fn final_header_value(static_cookie: Option<&str>, key: &str, value: &str) -> String {
350    let value = sanitize_header_value(value);
351    match static_cookie {
352        Some(s) if key.eq_ignore_ascii_case("cookie") => merge_cookie_str(s, &value),
353        _ => value,
354    }
355}
356
357#[async_trait]
358impl Fetcher for ReqwestFetcher {
359    async fn fetch(&self, req: FetchRequest) -> Result<String, FetchError> {
360        self.fetch_full_inner(req).await.map(|r| r.body)
361    }
362
363    async fn fetch_full(&self, req: FetchRequest) -> Result<FetchResponse, FetchError> {
364        self.fetch_full_inner(req).await
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{final_header_value, is_challenge};
371
372    /// Cloudflare 托管挑战页的最小特征(取自实测 bilixs 响应)。
373    const CHALLENGE_HTML: &str = r#"<html><head><title>Just a moment...</title></head>
374        <body><script>window._cf_chl_opt={cType:'managed'};
375        a.src='/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1';</script></body></html>"#;
376
377    const NORMAL_HTML: &str =
378        r#"<html><head><title>蛊真人 搜索结果</title></head><body>正文</body></html>"#;
379
380    #[test]
381    fn cf_mitigated_header_is_challenge() {
382        // 即便状态 200,带 cf-mitigated: challenge 头也判为挑战。
383        assert!(is_challenge(200, Some("challenge"), NORMAL_HTML));
384    }
385
386    #[test]
387    fn challenge_body_with_403_is_challenge() {
388        assert!(is_challenge(403, None, CHALLENGE_HTML));
389        assert!(is_challenge(503, None, CHALLENGE_HTML));
390    }
391
392    #[test]
393    fn normal_200_page_is_not_challenge() {
394        assert!(!is_challenge(200, None, NORMAL_HTML));
395        // 仅有挑战特征但状态 200(无 cf-mitigated)不误判,避免正文含 cdn-cgi 字样被冤枉。
396        assert!(!is_challenge(200, None, CHALLENGE_HTML));
397    }
398
399    #[test]
400    fn challenge_markers_without_bad_status_not_challenge() {
401        // 403 但 body 无挑战特征 → 不是挑战(交由普通 HTTP 错误处理)。
402        assert!(!is_challenge(403, None, NORMAL_HTML));
403    }
404
405    // ── 审查/correctness:请求级 Cookie 与静态 http.cookies 合并(default_headers 是替换语义)──
406    #[test]
407    fn final_header_value_merges_static_cookie_and_strips_crlf() {
408        // 请求级 Cookie 与静态基底合并(请求级同名 key 胜出),key 大小写不敏感。
409        assert_eq!(
410            final_header_value(Some("device=d1; sid=old"), "Cookie", "sid=new"),
411            "device=d1; sid=new"
412        );
413        assert_eq!(
414            final_header_value(Some("device=d1"), "cookie", "sid=1"),
415            "device=d1; sid=1"
416        );
417        // 非 Cookie 头不掺静态基底,但仍剥 CR/LF(纵深防御,脏落盘数据不致构建失败)。
418        assert_eq!(
419            final_header_value(Some("device=d1"), "Authorization", "Bearer\r\nT"),
420            "BearerT"
421        );
422        // 无静态 cookie:原样透传(剥 CR/LF)。
423        assert_eq!(final_header_value(None, "Cookie", "a=1\nb=2"), "a=1b=2");
424        assert_eq!(final_header_value(None, "X-Test", "42"), "42");
425    }
426}