parse-book-source 0.6.0

Terminal reader for novel
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! 基于系统已装浏览器的反爬取页(`browser` feature)。
//!
//! 复用用户系统里的 Chromium 系浏览器(Chrome/Edge/Brave/…),**headful** 解 Cloudflare
//! 托管挑战、签发 `cf_clearance`,再把 cookie + 浏览器真实 UA 交回 reqwest 取页
//! (「cookie 烤箱」,见 OpenSpec change `browser-fetcher` 的 design)。
//!
//! 注:本模块的浏览器交互**仅编译验证**,真机联调留待运行环境(CI/沙箱跑不了浏览器)。

use super::error::FetchError;
use super::fetch::{FetchRequest, FetchResponse, Fetcher, ReqwestFetcher};
use super::source::BookSource;
use async_trait::async_trait;
use chromiumoxide::cdp::browser_protocol::network::Cookie;
use chromiumoxide::cdp::browser_protocol::page::BringToFrontParams;
use chromiumoxide::{Browser, BrowserConfig, Page};
use futures_util::StreamExt;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;

/// 本会话浏览器解挑战是否已判定不可用(启动失败等)。一旦置真,后续撞挑战直接降级,
/// **不再反复启动浏览器**(避免页面反复重跑取页时浏览器频闪)。重启 app 复位。
static SOLVE_FAILED: AtomicBool = AtomicBool::new(false);

/// 解挑战产出:可注入 reqwest 的 Cookie 头 + 浏览器真实 UA。
///
/// UA 必须随 cookie 一起带走:`cf_clearance` 绑签发它的 UA(见 design D6)。
#[derive(Debug, Clone)]
pub struct Clearance {
    pub cookie_header: String,
    pub user_agent: String,
}

/// 浏览器登录提取的一条 cookie(含 HttpOnly;经 CDP `get_cookies` 取得,reqwest 拿不到 HttpOnly)。
#[derive(Debug, Clone)]
pub struct BrowserCookie {
    pub domain: String,
    pub name: String,
    pub value: String,
}

/// headful 浏览器登录产出:cookie(含 HttpOnly)+ localStorage + 登录后页面(见 design D6)。
#[derive(Debug, Clone, Default)]
pub struct LoginOutcome {
    /// 浏览器全部 cookie(含 HttpOnly)。
    pub cookies: Vec<BrowserCookie>,
    /// localStorage 键值(站点把 JWT 存这里时由此取出)。
    pub local_storage: BTreeMap<String, String>,
    /// 登录后页面 HTML(可选用作 refetch / 直接解析)。
    pub html: String,
    /// 登录后页面最终 URL。
    pub url: String,
}

impl LoginOutcome {
    /// 按**注册域(eTLD+1)**归并 cookie 为 `注册域 -> "k=v; k2=v2"`,供并入 cookie 库 / 落盘
    /// (与 [`crate::cookie::CookieJar::from_persistent`] / [`crate::Engine::with_cookies`] 对接)。
    pub fn cookies_by_registrable_domain(&self) -> BTreeMap<String, String> {
        use crate::cookie::{pairs_to_str, registrable_domain};
        let mut by: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
        for c in &self.cookies {
            let dom = registrable_domain(c.domain.trim_start_matches('.'));
            by.entry(dom)
                .or_default()
                .insert(c.name.clone(), c.value.clone());
        }
        by.into_iter()
            .map(|(d, kv)| (d, pairs_to_str(&kv)))
            .collect()
    }
}

/// 登录成功的判定条件:任一目标 cookie 名 / localStorage 键出现非空即视为成功。
/// 二者皆空时仅靠用户在 TUI 确认([`LoginSignal::done`])。
#[derive(Debug, Clone, Default)]
pub struct LoginCriteria {
    pub cookie_names: Vec<String>,
    pub local_storage_keys: Vec<String>,
}

/// 登录交互信号:由 TUI/调用方持同一 `Arc` 翻转(用原子标志轮询,替代 Java LockSupport park/unpark)。
#[derive(Debug, Clone, Default)]
pub struct LoginSignal {
    /// 用户「登录完成」。
    pub done: Arc<AtomicBool>,
    /// 用户取消登录。
    pub cancel: Arc<AtomicBool>,
}

impl LoginSignal {
    /// 复位 `done`/`cancel` 标志。每次发起新一轮登录前须调用:信号跨登录尝试共享同一 `Arc`,
    /// 残留 `cancel` 会让重试在首轮轮询即被判「用户取消」而立即失败;残留 `done` 更危险——
    /// 会把下一次尝试的未登录/空 cookie 当「成功」落盘。
    pub fn reset(&self) {
        self.done.store(false, Ordering::Relaxed);
        self.cancel.store(false, Ordering::Relaxed);
    }
}

/// 浏览器授权决定(由 [`BrowserUi::authorize`] 返回)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthDecision {
    /// 本次允许。
    Once,
    /// 总是允许(实现方应持久化)。
    Always,
    /// 拒绝:不开浏览器,降级。
    Deny,
}

/// 解挑战期间与用户交互的 UI 回调(由 app/TUI 实现;非交互场景可不提供)。
#[async_trait]
pub trait BrowserUi: Send + Sync {
    /// 撞挑战、需要打开浏览器前征求用户授权(可 await 用户决定)。
    async fn authorize(&self, source_name: &str) -> AuthDecision;
    /// 出现 Turnstile 勾选框:提示用户去弹出的浏览器里点「确认您是真人」。
    /// 用户主动取消时把 `cancel` 置真,解挑战会随即中止并降级。
    fn prompt_click(&self, url: &str, cancel: Arc<AtomicBool>);
    /// 解挑战结束(成功 / 失败 / 取消),撤下提示。
    fn done(&self);
}

/// 浏览器解挑战的可调参数。
#[derive(Clone)]
pub struct BrowserOptions {
    /// 持久化 profile 目录(养号,降低被升级为勾选框的概率,见 design D4)。
    pub profile_dir: PathBuf,
    /// 非交互宽限期:超过仍未解开则视为可能需用户点击。
    pub grace: Duration,
    /// 总超时上限(到点放弃,交上层降级,见 design D5/D11)。
    pub total_timeout: Duration,
    /// 登录总超时(用户手动登录/2FA 较慢,故远大于解挑战的 `total_timeout`)。
    pub login_timeout: Duration,
    /// 轮询间隔。
    pub poll_interval: Duration,
    /// 交互式 UI 回调(可选;授权 + Turnstile 点击提示)。
    pub ui: Option<Arc<dyn BrowserUi>>,
}

impl Default for BrowserOptions {
    fn default() -> Self {
        Self {
            profile_dir: default_profile_dir(),
            grace: Duration::from_secs(5),
            total_timeout: Duration::from_secs(60),
            login_timeout: Duration::from_secs(300),
            poll_interval: Duration::from_millis(800),
            ui: None,
        }
    }
}

/// 默认 profile 目录:`~/.novel/browser-profile`(与 app 的 `~/.novel` 对齐)。
fn default_profile_dir() -> PathBuf {
    match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
        Some(home) => PathBuf::from(home).join(".novel").join("browser-profile"),
        None => std::env::temp_dir().join("trnovel-browser-profile"),
    }
}

/// 探测系统已装的 Chromium 系浏览器,返回可执行路径;找不到返回 `None`。
pub fn detect_browser() -> Option<PathBuf> {
    detect_browser_impl()
}

#[cfg(target_os = "macos")]
fn detect_browser_impl() -> Option<PathBuf> {
    const CANDIDATES: &[&str] = &[
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
        "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",
    ];
    CANDIDATES.iter().map(PathBuf::from).find(|p| p.is_file())
}

#[cfg(target_os = "windows")]
fn detect_browser_impl() -> Option<PathBuf> {
    const REL: &[&str] = &[
        r"Google\Chrome\Application\chrome.exe",
        r"Microsoft\Edge\Application\msedge.exe",
        r"BraveSoftware\Brave-Browser\Application\brave.exe",
        r"Chromium\Application\chrome.exe",
    ];
    for var in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
        let Some(root) = std::env::var_os(var).map(PathBuf::from) else {
            continue;
        };
        for rel in REL {
            let p = root.join(rel);
            if p.is_file() {
                return Some(p);
            }
        }
    }
    None
}

#[cfg(target_os = "linux")]
fn detect_browser_impl() -> Option<PathBuf> {
    const NAMES: &[&str] = &[
        "google-chrome",
        "google-chrome-stable",
        "chromium",
        "chromium-browser",
        "microsoft-edge",
        "brave-browser",
    ];
    let paths = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&paths) {
        for name in NAMES {
            let p = dir.join(name);
            if p.is_file() {
                return Some(p);
            }
        }
    }
    None
}

#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn detect_browser_impl() -> Option<PathBuf> {
    None
}

/// 基于系统浏览器的解挑战器(cookie 烤箱)。
pub struct BrowserFetcher {
    exe: PathBuf,
    opts: BrowserOptions,
}

impl BrowserFetcher {
    /// 探测系统浏览器并构建;无可用浏览器返回 `None`。
    pub fn detect(opts: BrowserOptions) -> Option<Self> {
        detect_browser().map(|exe| Self { exe, opts })
    }

    /// 已知浏览器路径时直接构建。
    pub fn with_executable(exe: PathBuf, opts: BrowserOptions) -> Self {
        Self { exe, opts }
    }

    /// 交互 UI 回调(供上层在升级前征求授权)。
    pub fn ui(&self) -> Option<&Arc<dyn BrowserUi>> {
        self.opts.ui.as_ref()
    }

    /// headful 打开 `url` 解挑战,轮询取得 `cf_clearance`,返回可注入 reqwest 的 [`Clearance`]。
    pub async fn solve(&self, url: &str) -> Result<Clearance, FetchError> {
        // 清理上次异常退出残留的单例锁:此 profile 由本 app 独占,残留 SingletonLock
        // 会让新启动的浏览器因「profile 被占用」瞬间退出(表现为频闪 + "Browser process exit")。
        for name in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
            let _ = std::fs::remove_file(self.opts.profile_dir.join(name));
        }

        let config = BrowserConfig::builder()
            .chrome_executable(&self.exe)
            .user_data_dir(&self.opts.profile_dir)
            .with_head() // 必须 headful(headless 解不开,见 design D4)
            .arg("--no-first-run")
            .arg("--no-default-browser-check")
            .arg("--disable-blink-features=AutomationControlled")
            .build()
            .map_err(FetchError::Browser)?;

        let (mut browser, mut handler) = Browser::launch(config).await.map_err(browser_err)?;
        // 持续驱动 CDP 连接直到关闭(stream 返回 None)。
        // 不能因单个错误事件就退出 —— 否则会把正在进行的命令的响应通道丢掉,报 "oneshot canceled"。
        let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });

        let result = self.solve_inner(&browser, url).await;

        // 生命周期:无论成败都关闭浏览器、回收事件循环(D11),并撤下交互提示。
        let _ = browser.close().await;
        handler_task.abort();
        if let Some(ui) = &self.opts.ui {
            ui.done();
        }
        result
    }

    async fn solve_inner(&self, browser: &Browser, url: &str) -> Result<Clearance, FetchError> {
        let page = browser.new_page(url).await.map_err(browser_err)?;

        let user_agent: String = page
            .evaluate("navigator.userAgent")
            .await
            .ok()
            .and_then(|v| v.into_value::<String>().ok())
            .unwrap_or_default();

        let cancel = Arc::new(AtomicBool::new(false));
        let start = Instant::now();
        let mut prompted = false;
        loop {
            // 用户从 TUI 取消 → 中止解挑战、降级。
            if cancel.load(Ordering::Relaxed) {
                return Err(FetchError::Challenged(format!("用户取消解挑战 @ {url}")));
            }
            if let Ok(cookies) = page.get_cookies().await
                && let Some(cookie_header) = clearance_header(&cookies)
            {
                return Ok(Clearance {
                    cookie_header,
                    user_agent,
                });
            }

            let elapsed = start.elapsed();
            if elapsed >= self.opts.total_timeout {
                return Err(FetchError::Challenged(format!("浏览器解挑战超时 @ {url}")));
            }
            // 超宽限期仍未解开 → 可能需用户点击 Turnstile:前置窗口 + 提示(绝不模拟点击)。
            if !prompted && elapsed >= self.opts.grace && challenge_visible(&page).await {
                let _ = page.execute(BringToFrontParams::default()).await;
                if let Some(ui) = &self.opts.ui {
                    ui.prompt_click(url, cancel.clone());
                }
                prompted = true;
            }
            tokio::time::sleep(self.opts.poll_interval).await;
        }
    }
}

impl BrowserFetcher {
    /// headful 打开 `url` 让用户在真实页面**手动登录**,轮询直到成功判定满足
    /// (`criteria` 的目标 cookie/localStorage 键出现,或用户在 TUI 确认 `signal.done`),
    /// 提取 cookie(含 HttpOnly,经 CDP)+ localStorage + 登录后页面 HTML 返回。
    /// 启动失败 / 用户取消(`signal.cancel`)/ 超时 → `Err`(上层降级到脚本登录或提示)。
    pub async fn login(
        &self,
        url: &str,
        criteria: &LoginCriteria,
        signal: &LoginSignal,
    ) -> Result<LoginOutcome, FetchError> {
        // 同 solve():清残留单例锁(否则新浏览器因 profile 被占瞬退、频闪)。
        for name in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
            let _ = std::fs::remove_file(self.opts.profile_dir.join(name));
        }
        let config = BrowserConfig::builder()
            .chrome_executable(&self.exe)
            .user_data_dir(&self.opts.profile_dir)
            .with_head() // 登录必须 headful,用户在真实页面操作。
            .arg("--no-first-run")
            .arg("--no-default-browser-check")
            .arg("--disable-blink-features=AutomationControlled")
            .build()
            .map_err(FetchError::Browser)?;

        let (mut browser, mut handler) = Browser::launch(config).await.map_err(browser_err)?;
        let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });

        let result = self.login_inner(&browser, url, criteria, signal).await;

        // 生命周期:无论成败都关闭浏览器、回收事件循环(同 solve)。
        let _ = browser.close().await;
        handler_task.abort();
        result
    }

    async fn login_inner(
        &self,
        browser: &Browser,
        url: &str,
        criteria: &LoginCriteria,
        signal: &LoginSignal,
    ) -> Result<LoginOutcome, FetchError> {
        let page = browser.new_page(url).await.map_err(browser_err)?;
        // 立即前置窗口,让用户看到登录页(绝不模拟点击/填表,纯人工)。
        let _ = page.execute(BringToFrontParams::default()).await;

        let start = Instant::now();
        // 区分「取数失败(浏览器被关 / CDP 断链)」与「尚无目标值」:
        // - 连续失败计数:跨域跳转/导航瞬间 get_cookies 可能瞬时报错,单次失败不误杀;
        // - 最近一次成功读取的 cookie 快照:兼容「登录完顺手关窗、再回终端按 Enter」的自然操作流
        //   (快照最多滞后一个轮询间隔,属降级兜底)。
        let mut consecutive_failures = 0u32;
        let mut last_good: Vec<Cookie> = Vec::new();
        loop {
            if signal.cancel.load(Ordering::Relaxed) {
                return Err(FetchError::Challenged(format!("用户取消登录 @ {url}")));
            }
            let cookies = match page.get_cookies().await {
                Ok(c) => {
                    consecutive_failures = 0;
                    last_good = c.clone();
                    c
                }
                Err(_) => {
                    consecutive_failures += 1;
                    if signal.done.load(Ordering::Relaxed) {
                        // 用户已确认完成但页面取不到数(浏览器已关闭):优先用最近一次成功快照
                        // 成交(localStorage/HTML 已不可得,降级为空);快照也空则明确报错——
                        // 绝不把空登录态当「成功」落盘。
                        if !last_good.is_empty() {
                            return Ok(LoginOutcome {
                                cookies: last_good.into_iter().map(to_browser_cookie).collect(),
                                local_storage: BTreeMap::new(),
                                html: String::new(),
                                url: url.to_string(),
                            });
                        }
                        return Err(FetchError::Challenged(format!(
                            "浏览器已关闭、未能读取登录态 @ {url}(请重试,登录完成后先回终端按 Enter 再关浏览器)"
                        )));
                    }
                    // 连续多次失败 → 浏览器已被关闭 / CDP 死链:数秒内报错,而非傻轮询到登录超时。
                    if consecutive_failures >= 3 {
                        return Err(FetchError::Challenged(format!(
                            "浏览器已关闭或连接中断 @ {url}"
                        )));
                    }
                    tokio::time::sleep(self.opts.poll_interval).await;
                    continue;
                }
            };
            let local_storage = read_local_storage(&page).await;
            // 成功:用户确认,或目标 cookie/localStorage 键出现非空。
            let by_criteria = criteria
                .cookie_names
                .iter()
                .any(|n| cookies.iter().any(|c| &c.name == n && !c.value.is_empty()))
                || criteria
                    .local_storage_keys
                    .iter()
                    .any(|k| local_storage.get(k).is_some_and(|v| !v.is_empty()));
            if signal.done.load(Ordering::Relaxed) || by_criteria {
                // HTML/URL 经 evaluate 取(避免不同 chromiumoxide 版本的 page API 差异)。
                let html = page
                    .evaluate("document.documentElement.outerHTML")
                    .await
                    .ok()
                    .and_then(|v| v.into_value::<String>().ok())
                    .unwrap_or_default();
                let final_url = page
                    .evaluate("location.href")
                    .await
                    .ok()
                    .and_then(|v| v.into_value::<String>().ok())
                    .unwrap_or_else(|| url.to_string());
                let cookies = cookies.into_iter().map(to_browser_cookie).collect();
                return Ok(LoginOutcome {
                    cookies,
                    local_storage,
                    html,
                    url: final_url,
                });
            }
            if start.elapsed() >= self.opts.login_timeout {
                return Err(FetchError::Challenged(format!("浏览器登录超时 @ {url}")));
            }
            tokio::time::sleep(self.opts.poll_interval).await;
        }
    }
}

/// CDP cookie → 登录产物 cookie。
fn to_browser_cookie(c: Cookie) -> BrowserCookie {
    BrowserCookie {
        domain: c.domain,
        name: c.name,
        value: c.value,
    }
}

fn browser_err(e: chromiumoxide::error::CdpError) -> FetchError {
    FetchError::Browser(e.to_string())
}

/// 读页面 localStorage 为键值表(取 JWT 等登录态);读失败 / 无则返回空表。
async fn read_local_storage(page: &Page) -> BTreeMap<String, String> {
    const JS: &str = r#"(function(){var o={};try{for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i);o[k]=localStorage.getItem(k);}}catch(e){}return JSON.stringify(o);})()"#;
    page.evaluate(JS)
        .await
        .ok()
        .and_then(|v| v.into_value::<String>().ok())
        .and_then(|s| serde_json::from_str::<BTreeMap<String, String>>(&s).ok())
        .unwrap_or_default()
}

/// 把浏览器 cookie 拼成可注入 reqwest 的 Cookie 头(仅 `cf_clearance` / `__cf*` 通行证)。
/// 无 `cf_clearance` 视为尚未解开,返回 `None`。
fn clearance_header(cookies: &[Cookie]) -> Option<String> {
    let mut parts = Vec::new();
    let mut has_clearance = false;
    for c in cookies {
        if c.name == "cf_clearance" {
            has_clearance = true;
            parts.push(format!("{}={}", c.name, c.value));
        } else if c.name.starts_with("__cf") {
            parts.push(format!("{}={}", c.name, c.value));
        }
    }
    has_clearance.then(|| parts.join("; "))
}

/// 页面是否仍停在挑战页(标题 / Turnstile iframe 判定)。
async fn challenge_visible(page: &Page) -> bool {
    const JS: &str = r#"document.title.indexOf('Just a moment')>=0
        || document.title.indexOf('请稍候')>=0
        || !!document.querySelector('iframe[src*="challenges.cloudflare.com"]')"#;
    page.evaluate(JS)
        .await
        .ok()
        .and_then(|v| v.into_value::<bool>().ok())
        .unwrap_or(false)
}

/// 升级式取页(装饰器 / 责任链,见 design D10):
/// 先走 reqwest;命中反爬挑战且浏览器可用 → 用 [`BrowserFetcher`] 解出 `cf_clearance`,
/// 注入后重试 reqwest;之后请求复用该 clearance(cookie 烤箱)。
pub struct EscalatingFetcher {
    reqwest: ReqwestFetcher,
    browser: Option<BrowserFetcher>,
    clearance: Mutex<Option<Clearance>>,
    /// 书源名,用于授权弹窗展示。
    name: String,
}

impl EscalatingFetcher {
    /// `browser` 为 `None` 时退化为纯 reqwest(撞挑战即返回 `Challenged` 由上层降级)。
    pub fn new(source: &BookSource, browser: Option<BrowserFetcher>) -> Result<Self, FetchError> {
        Ok(Self {
            reqwest: ReqwestFetcher::new(source)?,
            browser,
            clearance: Mutex::new(None),
            name: source.name.clone(),
        })
    }

    /// 把已有 clearance(cookie + 真实 UA)注入请求头。
    async fn apply_clearance(&self, req: &mut FetchRequest) {
        if let Some(c) = self.clearance.lock().await.as_ref() {
            req.headers
                .entry("Cookie".into())
                .or_insert_with(|| c.cookie_header.clone());
            // UA 必须与签发 cf_clearance 的浏览器一致(覆盖书源配置的 UA,见 design D6)。
            req.headers
                .insert("User-Agent".into(), c.user_agent.clone());
        }
    }
}

#[async_trait]
impl Fetcher for EscalatingFetcher {
    async fn fetch(&self, req: FetchRequest) -> Result<String, FetchError> {
        self.fetch_full(req).await.map(|r| r.body)
    }

    /// 升级式取完整响应:透传真实状态码与响应头(`net.connect` 依赖之),并保留解挑战逻辑。
    /// `fetch` 委托本方法 —— 升级语义实现一次,避免漂移。
    async fn fetch_full(&self, mut req: FetchRequest) -> Result<FetchResponse, FetchError> {
        self.apply_clearance(&mut req).await;
        match self.reqwest.fetch_full(req.clone()).await {
            Err(FetchError::Challenged(msg)) => {
                let Some(browser) = &self.browser else {
                    return Err(FetchError::Challenged(msg));
                };
                // 本会话已判定浏览器不可用 → 直接降级,不再启动(避免频闪)。
                if SOLVE_FAILED.load(Ordering::Relaxed) {
                    return Err(FetchError::Challenged(format!(
                        "{msg}(浏览器辅助不可用,已降级;可重启 app 重试)"
                    )));
                }
                // 串行化解挑战:持锁期间若并发的其它取页已解出 clearance,直接复用,
                // 避免重复开浏览器 / 重复弹授权窗。
                let mut guard = self.clearance.lock().await;
                if guard.is_none() {
                    // 升级前征求用户授权(若提供了 UI);拒绝则降级。
                    if let Some(ui) = browser.ui()
                        && ui.authorize(&self.name).await == AuthDecision::Deny
                    {
                        return Err(FetchError::Challenged(format!(
                            "{msg}(用户未授权浏览器辅助)"
                        )));
                    }
                    let abs = self.reqwest.resolve(&req.url);
                    match browser.solve(&abs).await {
                        Ok(c) => *guard = Some(c),
                        Err(e) => {
                            // 启动/解挑战失败:本会话停用浏览器辅助,避免反复重试导致频闪。
                            SOLVE_FAILED.store(true, Ordering::Relaxed);
                            return Err(e);
                        }
                    }
                }
                drop(guard);
                self.apply_clearance(&mut req).await;
                self.reqwest.fetch_full(req).await
            }
            other => other,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{BrowserCookie, EscalatingFetcher, LoginOutcome, detect_browser};
    use crate::fetch::{FetchRequest, Fetcher};
    use crate::testutil::{book_source, spawn_fixed_server};

    #[test]
    fn detect_browser_does_not_panic() {
        // 探测不应 panic;有无浏览器取决于运行机器,这里只验证可调用。
        let _ = detect_browser();
    }

    // ── 7.x:登录产出按注册域归并 cookie(纯逻辑,无需真浏览器)──
    #[test]
    fn login_outcome_groups_cookies_by_registrable_domain() {
        let out = LoginOutcome {
            cookies: vec![
                BrowserCookie {
                    domain: ".www.site.com".into(),
                    name: "sid".into(),
                    value: "1".into(),
                },
                BrowserCookie {
                    domain: "api.site.com".into(),
                    name: "t".into(),
                    value: "2".into(),
                },
                BrowserCookie {
                    domain: "a.example.co.uk".into(),
                    name: "x".into(),
                    value: "9".into(),
                },
            ],
            ..Default::default()
        };
        let by = out.cookies_by_registrable_domain();
        // 子域按二级域 site.com 归并、键有序拼接。
        assert_eq!(by.get("site.com").map(String::as_str), Some("sid=1; t=2"));
        // 公共后缀 co.uk → 注册域 example.co.uk。
        assert_eq!(by.get("example.co.uk").map(String::as_str), Some("x=9"));
    }

    // ── 审查/correctness:EscalatingFetcher 必须覆盖 fetch_full,透传真实状态码与响应头 ──
    // 否则落到默认实现 → net.connect 静默退化为 {code:200, headers:{}},打掉登录脚本读 Set-Cookie 的能力。
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn escalating_fetcher_fetch_full_passes_status_and_headers() {
        let (base, server) = spawn_fixed_server(
            "HTTP/1.1 201 Created\r\nSet-Cookie: sid=zzz; Path=/\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
                .to_string(),
        );
        // browser=None:不解挑战,但 fetch_full 仍须透传 reqwest 的真实 status/headers。
        let fetcher = EscalatingFetcher::new(&book_source(&base), None).unwrap();
        let resp = fetcher.fetch_full(FetchRequest::get("/x")).await.unwrap();
        server.join().unwrap();
        assert_eq!(resp.status, 201, "应透传真实状态码,而非默认 200");
        assert_eq!(
            resp.headers.get("set-cookie").map(String::as_str),
            Some("sid=zzz; Path=/"),
            "应透传响应头(Set-Cookie),而非默认空 headers"
        );
    }
}