1use std::collections::HashMap;
39use std::sync::atomic::{AtomicUsize, Ordering};
40use std::sync::Arc;
41use std::time::Duration;
42
43use async_trait::async_trait;
44use chromiumoxide::browser::Browser;
45use crawlkit_core::{CrawlError, HttpClient, Response};
46use futures::StreamExt;
47use tokio::sync::RwLock;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum CdpStrategy {
52 Random,
54 #[default]
56 RoundRobin,
57 Failover,
59}
60
61struct CdpEndpoint {
63 client: CdpClient,
64 name: String,
65 healthy: bool,
66}
67
68pub struct CdpClientBuilder {
74 endpoint: String,
75 name: Option<String>,
76 navigation_timeout: Duration,
77}
78
79impl Default for CdpClientBuilder {
80 fn default() -> Self {
81 Self {
82 endpoint: "http://127.0.0.1:9222".to_string(),
83 name: None,
84 navigation_timeout: Duration::from_secs(30),
85 }
86 }
87}
88
89impl CdpClientBuilder {
90 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
95 self.endpoint = endpoint.into();
96 self
97 }
98
99 pub fn with_name(mut self, name: impl Into<String>) -> Self {
101 self.name = Some(name.into());
102 self
103 }
104
105 pub fn with_navigation_timeout(mut self, timeout: Duration) -> Self {
107 self.navigation_timeout = timeout;
108 self
109 }
110
111 pub async fn build(self) -> anyhow::Result<CdpClient> {
115 let (browser, mut handler) = Browser::connect(&self.endpoint)
116 .await
117 .map_err(|e| anyhow::anyhow!("CDP 连接失败 ({}): {e}", self.endpoint))?;
118
119 tokio::spawn(async move {
120 while let Some(h) = handler.next().await {
121 if h.is_err() {
122 break;
123 }
124 }
125 });
126
127 Ok(CdpClient {
128 browser,
129 navigation_timeout: self.navigation_timeout,
130 name: self.name.unwrap_or(self.endpoint),
131 })
132 }
133}
134
135pub struct CdpClient {
139 browser: Browser,
140 navigation_timeout: Duration,
141 name: String,
142}
143
144impl CdpClient {
145 pub fn builder() -> CdpClientBuilder {
147 CdpClientBuilder::default()
148 }
149
150 pub fn browser(&self) -> &Browser {
152 &self.browser
153 }
154
155 pub fn endpoint_name(&self) -> &str {
157 &self.name
158 }
159}
160
161#[async_trait]
162impl HttpClient for CdpClient {
163 async fn get(
164 &self,
165 url: &str,
166 _headers: &HashMap<String, String>,
167 ) -> crawlkit_core::Result<Response> {
168 let page = self
169 .browser
170 .new_page(url)
171 .await
172 .map_err(|e| CrawlError::Http(format!("CDP 创建页面失败: {e}")))?;
173
174 match tokio::time::timeout(self.navigation_timeout, page.wait_for_navigation()).await {
175 Ok(Ok(_)) => {}
176 Ok(Err(e)) => {
177 eprintln!("[CDP:{}] 等待导航警告: {e}", self.name);
178 }
179 Err(_) => {
180 eprintln!(
181 "[CDP:{}] 导航超时 ({:?}),继续提取",
182 self.name, self.navigation_timeout
183 );
184 }
185 }
186
187 let html = page
188 .content()
189 .await
190 .map_err(|e| CrawlError::Http(format!("CDP 提取 HTML 失败: {e}")))?;
191
192 let final_url = page
193 .url()
194 .await
195 .unwrap_or(None)
196 .unwrap_or_else(|| url.to_string());
197
198 let _ = page.close().await;
199
200 Ok(Response {
201 url: final_url,
202 status: 200,
203 headers: Default::default(),
204 body: html,
205 })
206 }
207
208 async fn post(
209 &self,
210 url: &str,
211 headers: &HashMap<String, String>,
212 _body: Vec<u8>,
213 ) -> crawlkit_core::Result<Response> {
214 self.get(url, headers).await
215 }
216
217 fn name(&self) -> &str {
218 &self.name
219 }
220}
221
222pub struct CdpPoolBuilder {
228 endpoints: Vec<(String, Option<String>)>,
229 strategy: CdpStrategy,
230 navigation_timeout: Duration,
231}
232
233impl Default for CdpPoolBuilder {
234 fn default() -> Self {
235 Self {
236 endpoints: Vec::new(),
237 strategy: CdpStrategy::default(),
238 navigation_timeout: Duration::from_secs(30),
239 }
240 }
241}
242
243impl CdpPoolBuilder {
244 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
246 self.endpoints.push((endpoint.into(), None));
247 self
248 }
249
250 pub fn with_named_endpoint(
252 mut self,
253 name: impl Into<String>,
254 endpoint: impl Into<String>,
255 ) -> Self {
256 self.endpoints.push((endpoint.into(), Some(name.into())));
257 self
258 }
259
260 pub fn with_strategy(mut self, strategy: CdpStrategy) -> Self {
262 self.strategy = strategy;
263 self
264 }
265
266 pub fn with_navigation_timeout(mut self, timeout: Duration) -> Self {
268 self.navigation_timeout = timeout;
269 self
270 }
271
272 pub async fn build(self) -> anyhow::Result<CdpPool> {
277 if self.endpoints.is_empty() {
278 anyhow::bail!("CdpPool 至少需要一个 CDP 端点");
279 }
280
281 let mut endpoints = Vec::new();
282 let mut last_error = None;
283
284 for (url, name) in &self.endpoints {
285 let ep_name = name.clone().unwrap_or_else(|| url.clone());
286 match CdpClient::builder()
287 .with_endpoint(url)
288 .with_name(&ep_name)
289 .with_navigation_timeout(self.navigation_timeout)
290 .build()
291 .await
292 {
293 Ok(client) => {
294 endpoints.push(CdpEndpoint {
295 client,
296 name: ep_name,
297 healthy: true,
298 });
299 }
300 Err(e) => {
301 eprintln!("[CdpPool] 端点 {ep_name} ({url}) 连接失败: {e},标记为不健康");
302 last_error = Some(e);
303 }
304 }
305 }
306
307 if endpoints.is_empty() {
308 anyhow::bail!(
309 "CdpPool 所有端点均连接失败,最后错误: {}",
310 last_error.map(|e| e.to_string()).unwrap_or_else(|| "未知".into())
311 );
312 }
313
314 Ok(CdpPool {
315 endpoints: Arc::new(RwLock::new(endpoints)),
316 strategy: self.strategy,
317 counter: AtomicUsize::new(0),
318 })
319 }
320}
321
322pub struct CdpPool {
327 endpoints: Arc<RwLock<Vec<CdpEndpoint>>>,
328 strategy: CdpStrategy,
329 counter: AtomicUsize,
330}
331
332impl CdpPool {
333 pub fn builder() -> CdpPoolBuilder {
335 CdpPoolBuilder::default()
336 }
337
338 pub async fn healthy_count(&self) -> usize {
340 self.endpoints
341 .read()
342 .await
343 .iter()
344 .filter(|e| e.healthy)
345 .count()
346 }
347
348 pub async fn total_count(&self) -> usize {
350 self.endpoints.read().await.len()
351 }
352
353 async fn select_index(&self) -> Option<usize> {
355 let snapshot: Vec<(usize, bool, String)> = {
356 let endpoints = self.endpoints.read().await;
357 endpoints
358 .iter()
359 .enumerate()
360 .map(|(i, e)| (i, e.healthy, e.name.clone()))
361 .collect()
362 };
363
364 let healthy: Vec<(usize, &str)> = snapshot
365 .iter()
366 .filter(|(_, healthy, _)| *healthy)
367 .map(|(i, _, name)| (*i, name.as_str()))
368 .collect();
369
370 if healthy.is_empty() {
371 self.reset_all_health().await;
373 return Some(0);
374 }
375
376 match self.strategy {
377 CdpStrategy::Random => {
378 use rand::Rng;
379 let idx = rand::rng().random_range(0..healthy.len());
380 Some(healthy[idx].0)
381 }
382 CdpStrategy::RoundRobin => {
383 let counter = self.counter.fetch_add(1, Ordering::Relaxed);
384 let idx = counter % healthy.len();
385 Some(healthy[idx].0)
386 }
387 CdpStrategy::Failover => Some(healthy[0].0),
388 }
389 }
390
391 async fn mark_unhealthy(&self, index: usize) {
393 let mut endpoints = self.endpoints.write().await;
394 if let Some(ep) = endpoints.get_mut(index) {
395 eprintln!("[CdpPool] 端点 {} 标记为不健康", ep.name);
396 ep.healthy = false;
397 }
398 }
399
400 async fn reset_all_health(&self) {
402 let mut endpoints = self.endpoints.write().await;
403 for ep in endpoints.iter_mut() {
404 ep.healthy = true;
405 }
406 }
407}
408
409#[async_trait]
410impl HttpClient for CdpPool {
411 async fn get(
412 &self,
413 url: &str,
414 headers: &HashMap<String, String>,
415 ) -> crawlkit_core::Result<Response> {
416 let total = self.total_count().await;
417 let mut last_error = None;
418
419 for _ in 0..total {
420 let idx = match self.select_index().await {
421 Some(i) => i,
422 None => break,
423 };
424
425 let name = {
427 let endpoints = self.endpoints.read().await;
428 endpoints[idx].name.clone()
429 };
430
431 let result = {
433 let endpoints = self.endpoints.read().await;
434 endpoints[idx].client.get(url, headers).await
435 };
436
437 match result {
438 Ok(resp) => return Ok(resp),
439 Err(e) => {
440 eprintln!("[CdpPool] 端点 {name} 请求失败: {e}");
441 self.mark_unhealthy(idx).await;
442 last_error = Some(e);
443 }
444 }
445 }
446
447 Err(CrawlError::Http(format!(
448 "CdpPool: 所有 {total} 个端点均失败,最后错误: {}",
449 last_error.unwrap_or_else(|| CrawlError::Http("无可用端点".to_string()))
450 )))
451 }
452
453 async fn post(
454 &self,
455 url: &str,
456 headers: &HashMap<String, String>,
457 _body: Vec<u8>,
458 ) -> crawlkit_core::Result<Response> {
459 self.get(url, headers).await
460 }
461
462 fn name(&self) -> &str {
463 "cdp-pool"
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn test_cdp_strategy_default() {
473 assert_eq!(CdpStrategy::default(), CdpStrategy::RoundRobin);
474 }
475
476 #[test]
477 fn test_cdp_builder_defaults() {
478 let builder = CdpClientBuilder::default();
479 assert_eq!(builder.endpoint, "http://127.0.0.1:9222");
480 assert!(builder.name.is_none());
481 assert_eq!(builder.navigation_timeout, Duration::from_secs(30));
482 }
483
484 #[test]
485 fn test_cdp_pool_builder_defaults() {
486 let builder = CdpPoolBuilder::default();
487 assert!(builder.endpoints.is_empty());
488 assert_eq!(builder.strategy, CdpStrategy::RoundRobin);
489 assert_eq!(builder.navigation_timeout, Duration::from_secs(30));
490 }
491}