1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7use super::AssetCategory;
8use super::dispatch::DispatchProfile;
9use crate::net::SsrfPolicy;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct ExtractionMeta {
15 pub cost: Option<f64>,
17 pub prompt_tokens: Option<u64>,
19 pub completion_tokens: Option<u64>,
21 pub model: Option<String>,
23 pub chunks_processed: usize,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum BrowserMode {
31 #[default]
33 Auto,
34 Always,
36 Never,
38 Stealth,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum BrowserWait {
58 #[default]
60 NetworkIdle,
61 Selector,
63 Fixed,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum BrowserBackend {
71 #[default]
73 Chromiumoxide,
74 Native,
76}
77
78pub(crate) mod duration_ms {
79 use serde::{Deserialize, Deserializer, Serialize, Serializer};
80 use std::time::Duration;
81
82 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
83 d.as_millis().serialize(s)
84 }
85
86 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
87 let ms = u64::deserialize(d)?;
88 Ok(Duration::from_millis(ms))
89 }
90}
91
92pub(crate) mod option_duration_ms {
93 use serde::{Deserialize, Deserializer, Serialize, Serializer};
94 use std::time::Duration;
95
96 pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
97 d.map(|d| d.as_millis() as u64).serialize(s)
98 }
99
100 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
101 let ms: Option<u64> = Option::deserialize(d)?;
102 Ok(ms.map(Duration::from_millis))
103 }
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ProxyConfig {
110 pub url: String,
112 pub username: Option<String>,
114 pub password: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(deny_unknown_fields, tag = "type")]
121pub enum AuthConfig {
122 #[serde(rename = "basic")]
124 Basic {
125 username: String,
127 password: String,
129 },
130 #[serde(rename = "bearer")]
132 Bearer {
133 token: String,
135 },
136 #[serde(rename = "header")]
138 Header {
139 name: String,
141 value: String,
143 },
144}
145
146impl Default for AuthConfig {
147 fn default() -> Self {
148 Self::Basic {
149 username: String::new(),
150 password: String::new(),
151 }
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(default)]
162pub struct ContentConfig {
163 pub output_format: String,
165 pub preprocessing_preset: String,
171 pub remove_navigation: bool,
173 pub remove_forms: bool,
175 #[serde(default)]
178 pub strip_tags: Vec<String>,
179 #[serde(default)]
181 pub preserve_tags: Vec<String>,
182 #[serde(default)]
190 pub exclude_selectors: Vec<String>,
191 pub skip_images: bool,
193 pub max_depth: Option<usize>,
195 pub wrap: bool,
197 pub wrap_width: usize,
199 pub include_document_structure: bool,
201}
202
203impl Default for ContentConfig {
204 fn default() -> Self {
205 Self {
206 output_format: "markdown".to_owned(),
207 preprocessing_preset: "standard".to_owned(),
208 remove_navigation: true,
209 remove_forms: true,
210 strip_tags: vec!["noscript".to_owned()],
211 preserve_tags: Vec::new(),
212 exclude_selectors: Vec::new(),
213 skip_images: false,
214 max_depth: None,
215 wrap: false,
216 wrap_width: 80,
217 include_document_structure: true,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields, default)]
225pub struct BrowserConfig {
226 pub mode: BrowserMode,
228 pub backend: BrowserBackend,
230 pub endpoint: Option<String>,
232 #[serde(with = "duration_ms")]
234 pub timeout: Duration,
235 pub wait: BrowserWait,
237 pub wait_selector: Option<String>,
239 #[serde(default, with = "option_duration_ms")]
241 pub extra_wait: Option<Duration>,
242 #[serde(default)]
245 pub proxy: Option<ProxyConfig>,
246 #[serde(default)]
250 pub block_url_patterns: Vec<String>,
251 #[serde(default)]
257 pub eval_script: Option<String>,
258 #[serde(default)]
261 pub robots_user_agent: Option<String>,
262 #[serde(default)]
265 pub capture_network_events: bool,
266 #[serde(default)]
270 pub session_affinity: bool,
271}
272
273impl Default for BrowserConfig {
274 fn default() -> Self {
275 Self {
276 mode: BrowserMode::Auto,
277 backend: BrowserBackend::Chromiumoxide,
278 endpoint: None,
279 timeout: Duration::from_secs(30),
280 wait: BrowserWait::default(),
281 wait_selector: None,
282 extra_wait: None,
283 proxy: None,
284 block_url_patterns: Vec::new(),
285 eval_script: None,
286 robots_user_agent: None,
287 capture_network_events: false,
288 session_affinity: true,
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(deny_unknown_fields, default)]
296pub struct CrawlConfig {
297 pub max_depth: Option<usize>,
299 pub max_pages: Option<usize>,
301 pub max_concurrent: Option<usize>,
303 pub respect_robots_txt: bool,
305 #[serde(default)]
313 pub soft_http_errors: bool,
314 pub user_agent: Option<String>,
316 pub stay_on_domain: bool,
318 pub allow_subdomains: bool,
320 #[serde(default)]
322 pub include_paths: Vec<String>,
323 #[serde(default)]
325 pub exclude_paths: Vec<String>,
326 #[serde(default)]
328 pub custom_headers: HashMap<String, String>,
329 #[serde(with = "duration_ms")]
331 pub request_timeout: Duration,
332 pub rate_limit_ms: Option<u64>,
335 pub max_redirects: usize,
337 pub retry_count: usize,
339 #[serde(default)]
341 pub retry_codes: Vec<u16>,
342 pub cookies_enabled: bool,
344 pub auth: Option<AuthConfig>,
346 pub max_body_size: Option<usize>,
348 #[serde(default)]
350 pub remove_tags: Vec<String>,
351 #[serde(default)]
353 pub content: ContentConfig,
354 pub map_limit: Option<usize>,
356 pub map_search: Option<String>,
358 pub download_assets: bool,
360 #[serde(default)]
362 pub asset_types: Vec<AssetCategory>,
363 pub max_asset_size: Option<usize>,
365 #[serde(default)]
367 pub browser: BrowserConfig,
368 pub proxy: Option<ProxyConfig>,
370 #[serde(default)]
372 pub user_agents: Vec<String>,
373 pub capture_screenshot: bool,
375 #[serde(default)]
379 pub follow_document_urls: bool,
380 #[serde(default)]
385 pub document_url_depth: Option<u32>,
386 pub download_documents: bool,
388 pub document_max_size: Option<usize>,
390 #[serde(default)]
392 pub document_mime_types: Vec<String>,
393 pub warc_output: Option<PathBuf>,
395 pub browser_profile: Option<String>,
397 pub save_browser_profile: bool,
399 #[serde(default = "SsrfPolicy::from_env")]
406 pub ssrf: SsrfPolicy,
407 #[serde(skip)]
422 #[cfg_attr(alef, alef(skip))]
423 pub dispatch: Option<DispatchProfile>,
424 #[cfg(feature = "browser")]
426 #[serde(skip)]
427 #[cfg_attr(alef, alef(skip))]
428 pub browser_pool: Option<std::sync::Arc<crate::browser_pool::BrowserPool>>,
429 #[serde(skip)]
433 #[cfg_attr(alef, alef(skip))]
434 pub proxy_provider: Option<std::sync::Arc<dyn crate::ProxyProvider>>,
435 #[cfg(feature = "browser")]
440 #[serde(skip)]
441 #[cfg_attr(alef, alef(skip))]
442 pub browser_session_pool: Option<std::sync::Arc<crate::browser_session_pool::BrowserSessionPool>>,
443}
444
445impl Default for CrawlConfig {
446 fn default() -> Self {
447 Self {
448 max_depth: None,
449 max_pages: None,
450 max_concurrent: None,
451 respect_robots_txt: false,
452 soft_http_errors: false,
453 user_agent: None,
454 stay_on_domain: false,
455 allow_subdomains: false,
456 include_paths: Vec::new(),
457 exclude_paths: Vec::new(),
458 custom_headers: HashMap::new(),
459 request_timeout: Duration::from_secs(30),
460 rate_limit_ms: None,
461 max_redirects: 10,
462 retry_count: 0,
463 retry_codes: Vec::new(),
464 cookies_enabled: false,
465 auth: None,
466 max_body_size: None,
467 remove_tags: Vec::new(),
468 content: ContentConfig::default(),
469 map_limit: None,
470 map_search: None,
471 download_assets: false,
472 asset_types: Vec::new(),
473 max_asset_size: None,
474 browser: BrowserConfig::default(),
475 proxy: None,
476 user_agents: Vec::new(),
477 capture_screenshot: false,
478 follow_document_urls: false,
479 document_url_depth: None,
480 download_documents: true,
481 document_max_size: Some(50 * 1024 * 1024), document_mime_types: Vec::new(),
483 warc_output: None,
484 browser_profile: None,
485 save_browser_profile: false,
486 ssrf: SsrfPolicy::from_env(),
487 dispatch: None,
488 #[cfg(feature = "browser")]
489 browser_pool: None,
490 #[cfg(feature = "browser")]
491 browser_session_pool: None,
492 proxy_provider: None,
493 }
494 }
495}
496
497impl CrawlConfig {
498 #[cfg_attr(alef, alef(skip))]
500 pub fn builder() -> crate::types::builder::CrawlConfigBuilder {
501 crate::types::builder::CrawlConfigBuilder::default()
502 }
503
504 pub fn validate(&self) -> Result<(), crate::error::CrawlError> {
506 use crate::error::CrawlError;
507
508 if let Some(0) = self.max_concurrent {
509 return Err(CrawlError::InvalidConfig("max_concurrent must be > 0".into()));
510 }
511 if self.browser.wait == BrowserWait::Selector && self.browser.wait_selector.is_none() {
512 return Err(CrawlError::InvalidConfig(
513 "browser.wait_selector required when browser.wait is Selector".into(),
514 ));
515 }
516 if let Some(max_depth) = self.max_depth
517 && max_depth > 100
518 {
519 return Err(CrawlError::InvalidConfig(format!(
520 "max_depth must be <= 100 (got {max_depth})"
521 )));
522 }
523 if let Some(max_pages) = self.max_pages
524 && max_pages == 0
525 {
526 return Err(CrawlError::InvalidConfig("max_pages must be > 0".into()));
527 }
528 if self.max_redirects > 100 {
529 return Err(CrawlError::InvalidConfig("max_redirects must be <= 100".into()));
530 }
531 if let Some(max_body_size) = self.max_body_size
532 && max_body_size == 0
533 {
534 return Err(CrawlError::InvalidConfig("max_body_size must be > 0".into()));
535 }
536 if let Some(ref proxy) = self.proxy {
537 let parsed = url::Url::parse(&proxy.url)
538 .map_err(|e| CrawlError::InvalidConfig(format!("invalid proxy URL '{}': {e}", proxy.url)))?;
539 let scheme = parsed.scheme();
540 if !matches!(scheme, "http" | "https" | "socks5" | "socks5h") {
541 return Err(CrawlError::InvalidConfig(format!(
542 "invalid proxy URL scheme '{scheme}' (expected http, https, socks5, or socks5h)"
543 )));
544 }
545 }
546 if let Some(ref auth) = self.auth {
547 match auth {
548 AuthConfig::Basic { username, .. } if username.is_empty() => {
549 return Err(CrawlError::InvalidConfig(
550 "auth.basic.username must not be empty".into(),
551 ));
552 }
553 AuthConfig::Bearer { token } if token.is_empty() => {
554 return Err(CrawlError::InvalidConfig("auth.bearer.token must not be empty".into()));
555 }
556 AuthConfig::Header { name, value } if name.is_empty() || value.is_empty() => {
557 return Err(CrawlError::InvalidConfig(
558 "auth.header.name and auth.header.value must not be empty".into(),
559 ));
560 }
561 _ => {}
562 }
563 }
564 for pattern in &self.include_paths {
565 regex::Regex::new(pattern)
566 .map_err(|e| CrawlError::InvalidConfig(format!("invalid include_path regex '{pattern}': {e}")))?;
567 }
568 for pattern in &self.exclude_paths {
569 regex::Regex::new(pattern)
570 .map_err(|e| CrawlError::InvalidConfig(format!("invalid exclude_path regex '{pattern}': {e}")))?;
571 }
572 for &code in &self.retry_codes {
573 if !(100..=599).contains(&code) {
574 return Err(CrawlError::InvalidConfig(format!("invalid retry code: {code}")));
575 }
576 }
577 if self.request_timeout.is_zero() {
578 return Err(CrawlError::InvalidConfig("request_timeout must be > 0".into()));
579 }
580 if let Some(ref endpoint) = self.browser.endpoint
581 && !endpoint.starts_with("ws://")
582 && !endpoint.starts_with("wss://")
583 {
584 return Err(CrawlError::InvalidConfig(format!(
585 "browser.endpoint must start with ws:// or wss://, got: {endpoint:?}"
586 )));
587 }
588 if self.browser.backend == BrowserBackend::Native && self.browser.endpoint.is_some() {
589 return Err(CrawlError::InvalidConfig(
590 "browser.endpoint is only supported by the chromiumoxide backend".into(),
591 ));
592 }
593 Ok(())
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn validate_rejects_http_browser_endpoint() {
603 let config = CrawlConfig {
604 browser: BrowserConfig {
605 endpoint: Some("http://not-websocket:3000".into()),
606 ..Default::default()
607 },
608 ..Default::default()
609 };
610 let err = config.validate().unwrap_err();
611 let msg = err.to_string();
612 assert!(msg.contains("endpoint"), "error should mention 'endpoint', got: {msg}");
613 }
614
615 #[test]
616 fn validate_accepts_ws_endpoint() {
617 let config = CrawlConfig {
618 browser: BrowserConfig {
619 endpoint: Some("ws://localhost:9222".into()),
620 ..Default::default()
621 },
622 ..Default::default()
623 };
624 assert!(config.validate().is_ok());
625 }
626
627 #[test]
628 fn validate_accepts_wss_endpoint() {
629 let config = CrawlConfig {
630 browser: BrowserConfig {
631 endpoint: Some("wss://remote-browser.example.com/devtools".into()),
632 ..Default::default()
633 },
634 ..Default::default()
635 };
636 assert!(config.validate().is_ok());
637 }
638
639 #[test]
640 fn validate_accepts_no_endpoint() {
641 let config = CrawlConfig {
642 browser: BrowserConfig {
643 endpoint: None,
644 ..Default::default()
645 },
646 ..Default::default()
647 };
648 assert!(config.validate().is_ok());
649 }
650
651 #[test]
652 fn browser_backend_defaults_to_chromiumoxide() {
653 assert_eq!(BrowserConfig::default().backend, BrowserBackend::Chromiumoxide);
654 }
655
656 #[test]
657 fn validate_rejects_native_endpoint() {
658 let config = CrawlConfig {
659 browser: BrowserConfig {
660 backend: BrowserBackend::Native,
661 endpoint: Some("ws://localhost:9222".into()),
662 ..Default::default()
663 },
664 ..Default::default()
665 };
666 let err = config.validate().unwrap_err();
667 let msg = err.to_string();
668 assert!(msg.contains("chromiumoxide"), "unexpected error: {msg}");
669 }
670}