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::time::{Duration, Instant};
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct Clearance {
pub cookie_header: String,
pub user_agent: String,
}
pub trait SolvePrompt: Send + Sync {
fn needs_user_click(&self, url: &str);
fn resolved(&self);
}
#[derive(Clone)]
pub struct BrowserOptions {
pub profile_dir: PathBuf,
pub grace: Duration,
pub total_timeout: Duration,
pub poll_interval: Duration,
pub prompt: Option<Arc<dyn SolvePrompt>>,
}
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),
prompt: 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 async fn solve(&self, url: &str) -> Result<Clearance, FetchError> {
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 let Some(ev) = handler.next().await {
if ev.is_err() {
break;
}
}
});
let result = self.solve_inner(&browser, url).await;
let _ = browser.close().await;
handler_task.abort();
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 start = Instant::now();
let mut prompted = false;
loop {
if let Ok(cookies) = page.get_cookies().await
&& let Some(cookie_header) = clearance_header(&cookies)
{
if prompted && let Some(p) = &self.opts.prompt {
p.resolved();
}
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(p) = &self.opts.prompt {
p.needs_user_click(url);
}
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>>,
}
impl EscalatingFetcher {
pub fn new(source: &BookSource, browser: Option<BrowserFetcher>) -> Result<Self, FetchError> {
Ok(Self {
reqwest: ReqwestFetcher::new(source)?,
browser,
clearance: Mutex::new(None),
})
}
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));
};
let abs = self.reqwest.resolve(&req.url);
let c = browser.solve(&abs).await?;
*self.clearance.lock().await = Some(c);
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();
}
}