use super::*;
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions, TryLockError},
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
struct Resident {
browser: Browser,
handler: tokio::task::JoinHandle<()>,
headless: bool,
marker_path: PathBuf,
browser_pid: Option<u32>,
profile_dir: PathBuf,
remove_profile_on_drop: bool,
}
static RENDER_POOL: Mutex<Option<Resident>> = Mutex::const_new(None);
const OWNER_MARKER: &str = ".trnovel-browser.json";
const SPAWN_LOCK: &str = ".trnovel-browser.lock";
#[derive(Debug, Deserialize, Serialize)]
struct BrowserOwnerMarker {
owner_pid: u32,
browser_pid: Option<u32>,
started_at_ms: u64,
headless: bool,
executable: String,
}
struct ProfileSpawnLock {
_file: File,
}
struct SpawnProfile {
dir: PathBuf,
remove_on_drop: bool,
notice: Option<String>,
_lock: ProfileSpawnLock,
}
struct SpawnedBrowser {
browser: Browser,
handler: tokio::task::JoinHandle<()>,
browser_pid: Option<u32>,
marker_path: PathBuf,
profile_dir: PathBuf,
remove_profile_on_drop: bool,
}
const HEADFUL_DEFAULT_ARGS: &[&str] = &[
"--disable-background-networking",
"--enable-features=NetworkService,NetworkServiceInProcess",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-features=TranslateUI",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--disable-sync",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--password-store=basic",
"--use-mock-keychain",
];
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> {
let result = self
.with_ephemeral(false, async |browser| self.solve_inner(browser, url).await)
.await;
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;
}
}
pub async fn render_dom(
&self,
url: &str,
ready_for: &str,
timeout: Duration,
headless: bool,
) -> Result<String, FetchError> {
self.with_pool_page(url, headless, async |page| {
Self::render_dom_page(page, url, ready_for, timeout).await
})
.await
}
async fn render_dom_page(
page: &Page,
url: &str,
ready_for: &str,
timeout: Duration,
) -> Result<String, FetchError> {
if !Self::wait_ready(page, ready_for, timeout).await? {
return Err(FetchError::Challenged(format!(
"渲染就绪超时(等待「{ready_for}」)@ {url}"
)));
}
Self::outer_html(page).await
}
async fn wait_ready(
page: &Page,
ready_for: &str,
timeout: Duration,
) -> Result<bool, FetchError> {
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u64;
let wait_js = format!(
"new Promise((resolve)=>{{const s={ready_for:?};\
if(document.querySelector(s))return resolve(true);\
const o=new MutationObserver(()=>{{if(document.querySelector(s)){{o.disconnect();resolve(true);}}}});\
o.observe(document.documentElement,{{childList:true,subtree:true}});\
setTimeout(()=>{{o.disconnect();resolve(false);}},{timeout_ms});}})"
);
let params = EvaluateParams::builder()
.expression(wait_js)
.await_promise(true)
.return_by_value(true)
.build()
.map_err(|e| FetchError::Browser(format!("构建 evaluate 参数失败: {e}")))?;
Ok(page
.evaluate(params)
.await
.map_err(browser_err)?
.into_value::<bool>()
.unwrap_or(false))
}
async fn outer_html(page: &Page) -> Result<String, FetchError> {
page.evaluate("document.documentElement.outerHTML")
.await
.map_err(browser_err)?
.into_value::<String>()
.map_err(|e| FetchError::Browser(format!("渲染后取 DOM 失败: {e}")))
}
async fn spawn_browser(&self, headless: bool) -> Result<SpawnedBrowser, FetchError> {
let profile = prepare_spawn_profile(&self.opts.profile_dir)?;
if let Some(message) = &profile.notice
&& let Some(ui) = &self.opts.ui
{
ui.notice(message);
}
let mut builder = BrowserConfig::builder()
.chrome_executable(&self.exe)
.user_data_dir(&profile.dir)
.arg("--no-first-run")
.arg("--no-default-browser-check");
if !headless {
builder = builder.disable_default_args().hide().with_head();
for &arg in HEADFUL_DEFAULT_ARGS {
builder = builder.arg(arg);
}
} else {
builder = builder.arg("--disable-blink-features=AutomationControlled");
}
let config = builder.build().map_err(FetchError::Browser)?;
let (mut browser, mut handler) = Browser::launch(config).await.map_err(browser_err)?;
let browser_pid = browser
.get_mut_child()
.and_then(|child| child.as_mut_inner().id());
let marker_path = owner_marker_path(&profile.dir);
write_owner_marker(&marker_path, browser_pid, headless, &self.exe)?;
let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
Ok(SpawnedBrowser {
browser,
handler: handler_task,
browser_pid,
marker_path,
profile_dir: profile.dir,
remove_profile_on_drop: profile.remove_on_drop,
})
}
async fn launch_ephemeral(
&self,
headless: bool,
) -> Result<
(
Browser,
tokio::task::JoinHandle<()>,
Option<u32>,
PathBuf,
PathBuf,
bool,
tokio::sync::MutexGuard<'static, ()>,
),
FetchError,
> {
let guard = BROWSER_LOCK.lock().await;
shutdown_render_pool().await;
let spawned = self.spawn_browser(headless).await?;
Ok((
spawned.browser,
spawned.handler,
spawned.browser_pid,
spawned.marker_path,
spawned.profile_dir,
spawned.remove_profile_on_drop,
guard,
))
}
async fn with_ephemeral<T>(
&self,
headless: bool,
f: impl AsyncFnOnce(&Browser) -> Result<T, FetchError>,
) -> Result<T, FetchError> {
let (
mut browser,
handler_task,
browser_pid,
marker_path,
profile_dir,
remove_profile_on_drop,
_guard,
) = self.launch_ephemeral(headless).await?;
let result = f(&browser).await;
let _ = browser.close().await;
remove_owner_marker(&marker_path, browser_pid);
handler_task.abort();
drop(browser);
cleanup_spawn_profile(&profile_dir, remove_profile_on_drop);
result
}
async fn with_pool_page<T>(
&self,
open_url: &str,
headless: bool,
f: impl AsyncFnOnce(&Page) -> Result<T, FetchError>,
) -> Result<T, FetchError> {
let _guard = BROWSER_LOCK.lock().await;
let mut pool = RENDER_POOL.lock().await;
let page = self.new_pool_page(&mut pool, headless, open_url).await?;
let result = f(&page).await;
let _ = page.close().await; result
}
async fn new_pool_page(
&self,
pool: &mut Option<Resident>,
headless: bool,
url: &str,
) -> Result<Page, FetchError> {
const OPEN_PAGE_TIMEOUT: Duration = Duration::from_secs(8);
let mut last_err = None;
for _ in 0..2 {
let healthy = matches!(
pool.as_ref(),
Some(r) if !r.handler.is_finished() && r.headless == headless
);
if !healthy {
drop_resident(pool); let spawned = self.spawn_browser(headless).await?;
*pool = Some(Resident {
browser: spawned.browser,
handler: spawned.handler,
headless,
marker_path: spawned.marker_path,
browser_pid: spawned.browser_pid,
profile_dir: spawned.profile_dir,
remove_profile_on_drop: spawned.remove_profile_on_drop,
});
}
let opened = {
let browser = &pool.as_ref().expect("健康检查后池必为 Some").browser;
tokio::time::timeout(OPEN_PAGE_TIMEOUT, browser.new_page(url)).await
};
match opened {
Ok(Ok(page)) => return Ok(page),
Ok(Err(e)) => {
drop_resident(pool);
last_err = Some(browser_err(e));
}
Err(_) => {
drop_resident(pool);
last_err = Some(FetchError::Browser(format!(
"常驻浏览器开页超时(疑似断连,{OPEN_PAGE_TIMEOUT:?})"
)));
}
}
}
Err(last_err.unwrap_or_else(|| FetchError::Browser("常驻浏览器开页反复失败".into())))
}
pub async fn render_intercept(
&self,
url: &str,
api_contains: &str,
timeout: Duration,
headless: bool,
dom_ready: Option<&str>,
paging: Option<(u32, &str)>,
) -> Result<(String, Option<String>), FetchError> {
self.with_pool_page("about:blank", headless, async |page| {
Self::intercept(page, url, api_contains, timeout, dom_ready, paging).await
})
.await
}
async fn intercept(
page: &Page,
url: &str,
api_contains: &str,
timeout: Duration,
dom_ready: Option<&str>,
paging: Option<(u32, &str)>,
) -> Result<(String, Option<String>), FetchError> {
use chromiumoxide::cdp::browser_protocol::network::{
EnableParams, EventLoadingFinished, EventResponseReceived,
};
page.execute(EnableParams::default())
.await
.map_err(browser_err)?;
let mut responses = page
.event_listener::<EventResponseReceived>()
.await
.map_err(browser_err)?;
let mut finished = page
.event_listener::<EventLoadingFinished>()
.await
.map_err(browser_err)?;
let first_idx = paging.map(|_| 0);
page.goto(url).await.map_err(browser_err)?;
let mut body = Self::wait_matching_body(
page,
&mut responses,
&mut finished,
api_contains,
timeout,
first_idx,
)
.await?;
if body.trim().is_empty() {
page.goto(url).await.map_err(browser_err)?;
body = Self::wait_matching_body(
page,
&mut responses,
&mut finished,
api_contains,
timeout,
first_idx,
)
.await?;
}
if let Some((target_page, next_selector)) = paging {
const CLICK_RETRY: u32 = 2; 'pages: for human_page in 2..=target_page {
for attempt in 0..CLICK_RETRY {
match Self::click_next(page, next_selector).await? {
ClickOutcome::AtEnd => break 'pages,
ClickOutcome::Clicked => {}
}
match Self::wait_matching_body(
page,
&mut responses,
&mut finished,
api_contains,
timeout,
Some(human_page - 1),
)
.await
{
Ok(b) => {
body = b;
continue 'pages; }
Err(e) if attempt + 1 >= CLICK_RETRY => return Err(e),
Err(_) => {} }
}
}
}
let dom = match dom_ready {
Some(sel) => {
let _ = Self::wait_ready(page, sel, timeout).await;
Self::outer_html(page).await.ok()
}
None => None,
};
Ok((body, dom))
}
async fn wait_matching_body<R, F>(
page: &Page,
responses: &mut R,
finished: &mut F,
api_contains: &str,
timeout: Duration,
expect_idx: Option<u32>,
) -> Result<String, FetchError>
where
R: futures_util::Stream<
Item = Arc<chromiumoxide::cdp::browser_protocol::network::EventResponseReceived>,
> + Unpin,
F: futures_util::Stream<
Item = Arc<chromiumoxide::cdp::browser_protocol::network::EventLoadingFinished>,
> + Unpin,
{
let deadline = Instant::now() + timeout;
let mut rid = None;
while rid.is_none() {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, responses.next()).await {
Ok(Some(ev))
if ev.response.url.contains(api_contains)
&& expect_idx.is_none_or(|exp| {
url_page_index(&ev.response.url).is_none_or(|got| got == exp)
}) =>
{
rid = Some(ev.request_id.clone());
}
Ok(Some(_)) => {} Ok(None) | Err(_) => break, }
}
let Some(rid) = rid else {
return Err(FetchError::Challenged(format!(
"未拦截到含「{api_contains}」{}的响应(疑似未发出/被风控)",
expect_idx
.map(|i| format!("(page_index={i})"))
.unwrap_or_default()
)));
};
if let Some(body) = response_body(page, &rid).await {
return Ok(body);
}
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, finished.next()).await {
Ok(Some(ev)) if ev.request_id == rid => {
if let Some(body) = response_body(page, &rid).await {
return Ok(body);
}
break; }
Ok(Some(_)) => {} Ok(None) | Err(_) => break, }
}
Ok(String::new())
}
async fn click_next(page: &Page, next_selector: &str) -> Result<ClickOutcome, FetchError> {
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
let js = format!(
"(function(){{const el=document.querySelector({next_selector:?});\
if(!el)return 'missing';\
if(el.classList.contains('disabled')||el.getAttribute('aria-disabled')==='true')return 'disabled';\
el.scrollIntoView({{block:'center',inline:'center'}});\
['mousedown','mouseup','click'].forEach(t=>el.dispatchEvent(\
new MouseEvent(t,{{bubbles:true,cancelable:true,view:window}})));\
return 'clicked';}})()"
);
let params = EvaluateParams::builder()
.expression(js)
.return_by_value(true)
.build()
.map_err(|e| FetchError::Browser(format!("构建 evaluate 参数失败: {e}")))?;
let state = page
.evaluate(params)
.await
.map_err(browser_err)?
.into_value::<String>()
.unwrap_or_default();
Ok(if state == "clicked" {
ClickOutcome::Clicked
} else {
ClickOutcome::AtEnd })
}
}
enum ClickOutcome {
Clicked,
AtEnd,
}
fn url_page_index(url: &str) -> Option<u32> {
let query = url.split_once('?')?.1;
let value = query
.split('&')
.find_map(|kv| kv.strip_prefix("page_index="))?;
value
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.ok()
}
async fn response_body(
page: &Page,
rid: &chromiumoxide::cdp::browser_protocol::network::RequestId,
) -> Option<String> {
use chromiumoxide::cdp::browser_protocol::network::GetResponseBodyParams;
let resp = page
.execute(GetResponseBodyParams::new(rid.clone()))
.await
.ok()?;
if resp.result.body.is_empty() {
return None;
}
if resp.result.base64_encoded {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&resp.result.body)
.ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
} else {
Some(resp.result.body)
}
}
impl BrowserFetcher {
pub async fn login(
&self,
url: &str,
criteria: &LoginCriteria,
signal: &LoginSignal,
) -> Result<LoginOutcome, FetchError> {
self.with_ephemeral(false, async |browser| {
self.login_inner(browser, url, criteria, signal).await
})
.await
}
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();
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) {
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 再关浏览器)"
)));
}
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;
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 {
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;
}
}
}
fn to_browser_cookie(c: Cookie) -> BrowserCookie {
BrowserCookie {
domain: c.domain,
name: c.name,
value: c.value,
}
}
fn owner_marker_path(profile_dir: &Path) -> PathBuf {
profile_dir.join(OWNER_MARKER)
}
fn prepare_spawn_profile(main_profile_dir: &Path) -> Result<SpawnProfile, FetchError> {
match try_prepare_main_profile(main_profile_dir) {
Ok(profile) => Ok(profile),
Err(ProfileBusyReason::SpawnLockBusy) | Err(ProfileBusyReason::LiveOwner) => {
prepare_session_profile(main_profile_dir)
}
Err(ProfileBusyReason::Error(e)) => Err(e),
}
}
fn try_prepare_main_profile(profile_dir: &Path) -> Result<SpawnProfile, ProfileBusyReason> {
let lock = acquire_profile_spawn_lock(profile_dir)?;
if live_foreign_owner_pid(profile_dir).is_some() {
return Err(ProfileBusyReason::LiveOwner);
}
prepare_profile_for_spawn(profile_dir).map_err(ProfileBusyReason::Error)?;
Ok(SpawnProfile {
dir: profile_dir.to_path_buf(),
remove_on_drop: false,
notice: None,
_lock: lock,
})
}
fn prepare_session_profile(main_profile_dir: &Path) -> Result<SpawnProfile, FetchError> {
let profile_dir = session_profile_dir(main_profile_dir);
let lock = acquire_profile_spawn_lock(&profile_dir).map_err(|reason| match reason {
ProfileBusyReason::SpawnLockBusy => {
FetchError::Browser("本进程临时浏览器 profile 正在启动,请稍后重试".into())
}
ProfileBusyReason::LiveOwner => {
FetchError::Browser("本进程临时浏览器 profile 正被占用,请稍后重试".into())
}
ProfileBusyReason::Error(e) => e,
})?;
prepare_profile_for_spawn(&profile_dir)?;
Ok(SpawnProfile {
dir: profile_dir,
remove_on_drop: true,
notice: Some(
"主浏览器会话正在被另一个 TRNovel 终端使用。\n本终端已改用临时浏览器会话继续取页;如需共享登录态,请关闭另一个终端后重试。"
.to_string(),
),
_lock: lock,
})
}
fn session_profile_dir(main_profile_dir: &Path) -> PathBuf {
let base = main_profile_dir
.parent()
.unwrap_or(main_profile_dir)
.join("browser-sessions");
base.join(std::process::id().to_string())
}
enum ProfileBusyReason {
SpawnLockBusy,
LiveOwner,
Error(FetchError),
}
fn acquire_profile_spawn_lock(profile_dir: &Path) -> Result<ProfileSpawnLock, ProfileBusyReason> {
fs::create_dir_all(profile_dir).map_err(|e| {
ProfileBusyReason::Error(FetchError::Browser(format!(
"创建浏览器 profile 目录失败: {e}"
)))
})?;
let path = profile_dir.join(SPAWN_LOCK);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|e| {
ProfileBusyReason::Error(FetchError::Browser(format!("打开浏览器启动锁失败: {e}")))
})?;
match file.try_lock() {
Ok(()) => Ok(ProfileSpawnLock { _file: file }),
Err(TryLockError::WouldBlock) => Err(ProfileBusyReason::SpawnLockBusy),
Err(TryLockError::Error(e)) => Err(ProfileBusyReason::Error(FetchError::Browser(format!(
"获取浏览器启动锁失败({}): {e}",
path.display()
)))),
}
}
fn prepare_profile_for_spawn(profile_dir: &Path) -> Result<(), FetchError> {
fs::create_dir_all(profile_dir)
.map_err(|e| FetchError::Browser(format!("创建浏览器 profile 目录失败: {e}")))?;
recover_stale_owner(profile_dir)?;
cleanup_singleton_files(profile_dir);
Ok(())
}
fn live_foreign_owner_pid(profile_dir: &Path) -> Option<u32> {
let marker = read_owner_marker(&owner_marker_path(profile_dir))?;
(marker.owner_pid != std::process::id() && process_exists(marker.owner_pid))
.then_some(marker.owner_pid)
}
fn recover_stale_owner(profile_dir: &Path) -> Result<(), FetchError> {
let marker_path = owner_marker_path(profile_dir);
let Some(marker) = read_owner_marker(&marker_path) else {
return Ok(());
};
let current_pid = std::process::id();
if marker.owner_pid != current_pid && process_exists(marker.owner_pid) {
return Err(FetchError::Browser(format!(
"浏览器 profile 正由另一个 TRNovel 进程占用(pid={}),暂不抢占",
marker.owner_pid
)));
}
if let Some(pid) = marker.browser_pid
&& pid != 0
&& process_exists(pid)
{
terminate_process_tree(pid);
}
let _ = fs::remove_file(marker_path);
Ok(())
}
fn read_owner_marker(path: &Path) -> Option<BrowserOwnerMarker> {
let text = fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn write_owner_marker(
path: &Path,
browser_pid: Option<u32>,
headless: bool,
executable: &Path,
) -> Result<(), FetchError> {
let marker = BrowserOwnerMarker {
owner_pid: std::process::id(),
browser_pid,
started_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(u64::MAX as u128) as u64)
.unwrap_or_default(),
headless,
executable: executable.display().to_string(),
};
let text = serde_json::to_string_pretty(&marker)
.map_err(|e| FetchError::Browser(format!("序列化浏览器 owner marker 失败: {e}")))?;
fs::write(path, text)
.map_err(|e| FetchError::Browser(format!("写入浏览器 owner marker 失败: {e}")))
}
fn remove_owner_marker(path: &Path, browser_pid: Option<u32>) {
let Some(marker) = read_owner_marker(path) else {
return;
};
if marker.owner_pid == std::process::id() && marker.browser_pid == browser_pid {
let _ = fs::remove_file(path);
}
}
fn cleanup_spawn_profile(profile_dir: &Path, remove_profile_on_drop: bool) {
if remove_profile_on_drop {
let _ = fs::remove_dir_all(profile_dir);
}
}
fn cleanup_singleton_files(profile_dir: &Path) {
for name in ["SingletonLock", "SingletonSocket", "SingletonCookie"] {
let _ = fs::remove_file(profile_dir.join(name));
}
}
#[cfg(target_os = "windows")]
fn process_exists(pid: u32) -> bool {
let filter = format!("PID eq {pid}");
let output = Command::new("tasklist")
.args(["/FI", filter.as_str(), "/NH"])
.output();
output
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.is_some_and(|stdout| {
let needle = pid.to_string();
stdout
.lines()
.any(|line| line.split_whitespace().nth(1) == Some(needle.as_str()))
})
}
#[cfg(not(target_os = "windows"))]
fn process_exists(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|status| status.success())
}
#[cfg(target_os = "windows")]
fn terminate_process_tree(pid: u32) {
let pid = pid.to_string();
let _ = Command::new("taskkill")
.args(["/PID", pid.as_str(), "/T", "/F"])
.status();
}
#[cfg(not(target_os = "windows"))]
fn terminate_process_tree(pid: u32) {
let pid_arg = pid.to_string();
let _ = Command::new("kill").args(["-TERM", &pid_arg]).status();
std::thread::sleep(Duration::from_millis(500));
if process_exists(pid) {
let _ = Command::new("kill").args(["-KILL", &pid_arg]).status();
}
}
fn drop_resident(slot: &mut Option<Resident>) {
if let Some(r) = slot.take() {
remove_owner_marker(&r.marker_path, r.browser_pid);
r.handler.abort();
drop(r.browser); cleanup_spawn_profile(&r.profile_dir, r.remove_profile_on_drop);
}
}
pub async fn shutdown_render_pool() {
let mut pool = RENDER_POOL.lock().await;
if let Some(r) = pool.as_mut() {
let _ = tokio::time::timeout(Duration::from_secs(3), r.browser.close()).await;
}
drop_resident(&mut pool);
}
fn browser_err(e: chromiumoxide::error::CdpError) -> FetchError {
FetchError::Browser(e.to_string())
}
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()
}
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)
}
#[cfg(test)]
mod tests {
use super::url_page_index;
#[test]
fn url_page_index_parses_exact_key() {
assert_eq!(
url_page_index(
"https://x/api/author/search/search_book/v1?filter=127&page_count=10&page_index=2&query_word=k"
),
Some(2)
);
assert_eq!(url_page_index("https://x/api?page_index=0&a=1"), Some(0));
assert_eq!(
url_page_index("https://x/api?item_page_index=9&page_index=3"),
Some(3)
);
assert_eq!(url_page_index("https://x/api?page=2&offset=10"), None);
assert_eq!(url_page_index("https://x/api/search_book/v1"), None);
assert_eq!(url_page_index("https://x/api?page_index=&foo=1"), None);
}
}