use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chromiumoxide::browser::Browser;
use crawlkit_core::{CrawlError, HttpClient, Response};
use futures::StreamExt;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CdpStrategy {
Random,
RoundRobin,
Failover,
}
impl Default for CdpStrategy {
fn default() -> Self {
Self::RoundRobin
}
}
struct CdpEndpoint {
client: CdpClient,
name: String,
healthy: bool,
}
pub struct CdpClientBuilder {
endpoint: String,
name: Option<String>,
navigation_timeout: Duration,
}
impl Default for CdpClientBuilder {
fn default() -> Self {
Self {
endpoint: "http://127.0.0.1:9222".to_string(),
name: None,
navigation_timeout: Duration::from_secs(30),
}
}
}
impl CdpClientBuilder {
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_navigation_timeout(mut self, timeout: Duration) -> Self {
self.navigation_timeout = timeout;
self
}
pub async fn build(self) -> anyhow::Result<CdpClient> {
let (browser, mut handler) = Browser::connect(&self.endpoint)
.await
.map_err(|e| anyhow::anyhow!("CDP 连接失败 ({}): {e}", self.endpoint))?;
tokio::spawn(async move {
while let Some(h) = handler.next().await {
if h.is_err() {
break;
}
}
});
Ok(CdpClient {
browser,
navigation_timeout: self.navigation_timeout,
name: self.name.unwrap_or(self.endpoint),
})
}
}
pub struct CdpClient {
browser: Browser,
navigation_timeout: Duration,
name: String,
}
impl CdpClient {
pub fn builder() -> CdpClientBuilder {
CdpClientBuilder::default()
}
pub fn browser(&self) -> &Browser {
&self.browser
}
pub fn endpoint_name(&self) -> &str {
&self.name
}
}
#[async_trait]
impl HttpClient for CdpClient {
async fn get(
&self,
url: &str,
_headers: &HashMap<String, String>,
) -> crawlkit_core::Result<Response> {
let page = self
.browser
.new_page(url)
.await
.map_err(|e| CrawlError::Http(format!("CDP 创建页面失败: {e}")))?;
match tokio::time::timeout(self.navigation_timeout, page.wait_for_navigation()).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
eprintln!("[CDP:{}] 等待导航警告: {e}", self.name);
}
Err(_) => {
eprintln!(
"[CDP:{}] 导航超时 ({:?}),继续提取",
self.name, self.navigation_timeout
);
}
}
let html = page
.content()
.await
.map_err(|e| CrawlError::Http(format!("CDP 提取 HTML 失败: {e}")))?;
let final_url = page
.url()
.await
.unwrap_or(None)
.unwrap_or_else(|| url.to_string());
let _ = page.close().await;
Ok(Response {
url: final_url,
status: 200,
headers: Default::default(),
body: html,
})
}
async fn post(
&self,
url: &str,
headers: &HashMap<String, String>,
_body: Vec<u8>,
) -> crawlkit_core::Result<Response> {
self.get(url, headers).await
}
fn name(&self) -> &str {
&self.name
}
}
pub struct CdpPoolBuilder {
endpoints: Vec<(String, Option<String>)>,
strategy: CdpStrategy,
navigation_timeout: Duration,
}
impl Default for CdpPoolBuilder {
fn default() -> Self {
Self {
endpoints: Vec::new(),
strategy: CdpStrategy::default(),
navigation_timeout: Duration::from_secs(30),
}
}
}
impl CdpPoolBuilder {
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoints.push((endpoint.into(), None));
self
}
pub fn with_named_endpoint(
mut self,
name: impl Into<String>,
endpoint: impl Into<String>,
) -> Self {
self.endpoints.push((endpoint.into(), Some(name.into())));
self
}
pub fn with_strategy(mut self, strategy: CdpStrategy) -> Self {
self.strategy = strategy;
self
}
pub fn with_navigation_timeout(mut self, timeout: Duration) -> Self {
self.navigation_timeout = timeout;
self
}
pub async fn build(self) -> anyhow::Result<CdpPool> {
if self.endpoints.is_empty() {
anyhow::bail!("CdpPool 至少需要一个 CDP 端点");
}
let mut endpoints = Vec::new();
let mut last_error = None;
for (url, name) in &self.endpoints {
let ep_name = name.clone().unwrap_or_else(|| url.clone());
match CdpClient::builder()
.with_endpoint(url)
.with_name(&ep_name)
.with_navigation_timeout(self.navigation_timeout)
.build()
.await
{
Ok(client) => {
endpoints.push(CdpEndpoint {
client,
name: ep_name,
healthy: true,
});
}
Err(e) => {
eprintln!("[CdpPool] 端点 {ep_name} ({url}) 连接失败: {e},标记为不健康");
last_error = Some(e);
}
}
}
if endpoints.is_empty() {
anyhow::bail!(
"CdpPool 所有端点均连接失败,最后错误: {}",
last_error.map(|e| e.to_string()).unwrap_or_else(|| "未知".into())
);
}
Ok(CdpPool {
endpoints: Arc::new(RwLock::new(endpoints)),
strategy: self.strategy,
counter: AtomicUsize::new(0),
})
}
}
pub struct CdpPool {
endpoints: Arc<RwLock<Vec<CdpEndpoint>>>,
strategy: CdpStrategy,
counter: AtomicUsize,
}
impl CdpPool {
pub fn builder() -> CdpPoolBuilder {
CdpPoolBuilder::default()
}
pub async fn healthy_count(&self) -> usize {
self.endpoints
.read()
.await
.iter()
.filter(|e| e.healthy)
.count()
}
pub async fn total_count(&self) -> usize {
self.endpoints.read().await.len()
}
async fn select_index(&self) -> Option<usize> {
let snapshot: Vec<(usize, bool, String)> = {
let endpoints = self.endpoints.read().await;
endpoints
.iter()
.enumerate()
.map(|(i, e)| (i, e.healthy, e.name.clone()))
.collect()
};
let healthy: Vec<(usize, &str)> = snapshot
.iter()
.filter(|(_, healthy, _)| *healthy)
.map(|(i, _, name)| (*i, name.as_str()))
.collect();
if healthy.is_empty() {
self.reset_all_health().await;
return Some(0);
}
match self.strategy {
CdpStrategy::Random => {
use rand::Rng;
let idx = rand::rng().random_range(0..healthy.len());
Some(healthy[idx].0)
}
CdpStrategy::RoundRobin => {
let counter = self.counter.fetch_add(1, Ordering::Relaxed);
let idx = counter % healthy.len();
Some(healthy[idx].0)
}
CdpStrategy::Failover => Some(healthy[0].0),
}
}
async fn mark_unhealthy(&self, index: usize) {
let mut endpoints = self.endpoints.write().await;
if let Some(ep) = endpoints.get_mut(index) {
eprintln!("[CdpPool] 端点 {} 标记为不健康", ep.name);
ep.healthy = false;
}
}
async fn reset_all_health(&self) {
let mut endpoints = self.endpoints.write().await;
for ep in endpoints.iter_mut() {
ep.healthy = true;
}
}
}
#[async_trait]
impl HttpClient for CdpPool {
async fn get(
&self,
url: &str,
headers: &HashMap<String, String>,
) -> crawlkit_core::Result<Response> {
let total = self.total_count().await;
let mut last_error = None;
for _ in 0..total {
let idx = match self.select_index().await {
Some(i) => i,
None => break,
};
let name = {
let endpoints = self.endpoints.read().await;
endpoints[idx].name.clone()
};
let result = {
let endpoints = self.endpoints.read().await;
endpoints[idx].client.get(url, headers).await
};
match result {
Ok(resp) => return Ok(resp),
Err(e) => {
eprintln!("[CdpPool] 端点 {name} 请求失败: {e}");
self.mark_unhealthy(idx).await;
last_error = Some(e);
}
}
}
Err(CrawlError::Http(format!(
"CdpPool: 所有 {total} 个端点均失败,最后错误: {}",
last_error.unwrap_or_else(|| CrawlError::Http("无可用端点".to_string()))
)))
}
async fn post(
&self,
url: &str,
headers: &HashMap<String, String>,
_body: Vec<u8>,
) -> crawlkit_core::Result<Response> {
self.get(url, headers).await
}
fn name(&self) -> &str {
"cdp-pool"
}
}