use super::error::FetchError;
use super::fetch::{FetchRequest, 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::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
static SOLVE_FAILED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone)]
pub struct Clearance {
pub cookie_header: String,
pub user_agent: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthDecision {
Once,
Always,
Deny,
}
#[async_trait]
pub trait BrowserUi: Send + Sync {
async fn authorize(&self, source_name: &str) -> AuthDecision;
fn prompt_click(&self, url: &str, cancel: Arc<AtomicBool>);
fn done(&self);
}
#[derive(Clone)]
pub struct BrowserOptions {
pub profile_dir: PathBuf,
pub grace: Duration,
pub total_timeout: Duration,
pub poll_interval: Duration,
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),
poll_interval: Duration::from_millis(800),
ui: None,
}
}
}
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"),
}
}
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
}
pub struct BrowserFetcher {
exe: PathBuf,
opts: BrowserOptions,
}
impl BrowserFetcher {
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 }
}
pub fn ui(&self) -> Option<&Arc<dyn BrowserUi>> {
self.opts.ui.as_ref()
}
pub async fn solve(&self, url: &str) -> Result<Clearance, FetchError> {
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() .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.solve_inner(&browser, url).await;
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 {
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}")));
}
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;
}
}
}
fn browser_err(e: chromiumoxide::error::CdpError) -> FetchError {
FetchError::Browser(e.to_string())
}
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("; "))
}
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)
}
pub struct EscalatingFetcher {
reqwest: ReqwestFetcher,
browser: Option<BrowserFetcher>,
clearance: Mutex<Option<Clearance>>,
name: String,
}
impl EscalatingFetcher {
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(),
})
}
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());
req.headers
.insert("User-Agent".into(), c.user_agent.clone());
}
}
}
#[async_trait]
impl Fetcher for EscalatingFetcher {
async fn fetch(&self, mut req: FetchRequest) -> Result<String, FetchError> {
self.apply_clearance(&mut req).await;
match self.reqwest.fetch(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 重试)"
)));
}
let mut guard = self.clearance.lock().await;
if guard.is_none() {
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(req).await
}
other => other,
}
}
}
#[cfg(test)]
mod tests {
use super::detect_browser;
#[test]
fn detect_browser_does_not_panic() {
let _ = detect_browser();
}
}