1use crw_core::Deadline;
2use crw_core::config::AppConfig;
3use crw_core::error::{CrwError, CrwResult};
4use crw_core::types::{
5 CrawlRequest, CrawlState, CrawlStatus, RequestedRenderer, ScrapeRequest,
6 resolve_pinned_renderer, resolve_render_js,
7};
8use crw_crawl::crawl::{CrawlOptions, run_crawl};
9use crw_crawl::single::scrape_url;
10use crw_renderer::FallbackRenderer;
11use crw_search::SearxngClient;
12use futures::stream::StreamExt;
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16use tokio::sync::{RwLock, watch};
17use uuid::Uuid;
18
19pub(crate) fn validate_renderer_pin(
30 renderer: Option<RequestedRenderer>,
31 render_js: Option<bool>,
32 state: &AppState,
33) -> CrwResult<()> {
34 let Some(name) = resolve_pinned_renderer(renderer) else {
35 return Ok(());
36 };
37
38 let effective_request = if render_js.is_none() {
44 Some(true)
45 } else {
46 render_js
47 };
48 let effective_render_js =
49 resolve_render_js(effective_request, state.config.renderer.render_js_default);
50
51 if effective_render_js == Some(false) {
52 return Ok(());
53 }
54
55 let available = state.renderer.js_renderer_names();
56 if !available.contains(&name) {
57 return Err(CrwError::InvalidRequest(format!(
58 "renderer '{}' not available; configured renderers: [{}]. \
59 Update server config or omit the 'renderer' field.",
60 name,
61 available.join(", ")
62 )));
63 }
64 Ok(())
65}
66
67pub(crate) fn validate_crawl_renderer(req: &CrawlRequest, state: &AppState) -> CrwResult<()> {
69 validate_renderer_pin(req.renderer, req.render_js, state)
70}
71
72pub struct CrawlJob {
74 pub rx: watch::Receiver<CrawlState>,
75 pub tx: watch::Sender<CrawlState>,
78 pub created_at: Instant,
79 pub abort_handle: Option<tokio::task::AbortHandle>,
81}
82
83const MAX_CONCURRENT_CRAWLS: usize = 10;
85const JOB_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ExtractStatus {
91 Processing,
92 Completed,
93 Failed,
94}
95
96impl ExtractStatus {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 ExtractStatus::Processing => "processing",
100 ExtractStatus::Completed => "completed",
101 ExtractStatus::Failed => "failed",
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
110pub struct ExtractRecord {
111 pub status: ExtractStatus,
112 pub data: Option<serde_json::Value>,
113 pub tokens_used: u32,
114 pub credits_used: u32,
115 pub error: Option<String>,
116 pub created_at: Instant,
117}
118
119#[derive(Clone)]
121pub struct AppState {
122 pub config: Arc<AppConfig>,
123 pub renderer: Arc<FallbackRenderer>,
124 pub crawl_jobs: Arc<RwLock<HashMap<Uuid, CrawlJob>>>,
125 pub extract_jobs: Arc<RwLock<HashMap<Uuid, ExtractRecord>>>,
128 pub crawl_semaphore: Arc<tokio::sync::Semaphore>,
129 pub searxng: Option<Arc<SearxngClient>>,
132 pub url_filter: Option<Arc<crw_crawl::url_filter::UrlFilterCfg>>,
136}
137
138impl AppState {
139 pub fn new(config: AppConfig) -> CrwResult<Self> {
140 let proxy_rotator = crw_core::ProxyRotator::build(
146 &config.crawler.proxy_list,
147 config.crawler.proxy.as_deref(),
148 config.crawler.proxy_rotation,
149 )
150 .map_err(CrwError::ConfigError)?
151 .map(Arc::new);
152 let renderer = FallbackRenderer::new(
153 &config.renderer,
154 &config.crawler.user_agent,
155 None,
156 &config.crawler.stealth,
157 )?
158 .with_proxy_rotator(proxy_rotator)?
159 .with_host_limits(
160 config.crawler.requests_per_second,
161 config.crawler.per_host_max_concurrent,
162 );
163
164 let searxng = if config.search.enabled
165 && let Some(url) = config.search.searxng_url.as_ref()
166 {
167 let http = reqwest::Client::builder()
172 .connect_timeout(Duration::from_secs(5))
173 .build()
174 .map_err(|e| {
175 CrwError::Internal(format!("failed to build SearXNG http client: {e}"))
176 })?;
177 let timeout = Duration::from_millis(config.search.timeout_ms);
178 Some(Arc::new(SearxngClient::new(Arc::new(http), url, timeout)))
179 } else {
180 None
181 };
182
183 let url_filter_cfg =
184 crw_crawl::url_filter::UrlFilterCfg::from_map_config(&config.map.url_filter);
185 let m = crw_core::metrics::metrics();
188 m.map_filter_rules_loaded
189 .with_label_values(&["action"])
190 .inc_by(
191 (crw_crawl::url_filter_data::DEFAULT_ACTION_PARAMS.len()
192 + url_filter_cfg.action_params.len()) as u64,
193 );
194 m.map_filter_rules_loaded
195 .with_label_values(&["tracking"])
196 .inc_by(
197 (crw_crawl::url_filter_data::DEFAULT_TRACKING_PARAMS.len()
198 + url_filter_cfg.tracking_params.len()) as u64,
199 );
200 m.map_filter_rules_loaded
201 .with_label_values(&["preserve"])
202 .inc_by(
203 (crw_crawl::url_filter_data::ALWAYS_PRESERVE.len()
204 + url_filter_cfg.preserve_params.len()) as u64,
205 );
206 m.map_filter_rules_loaded
207 .with_label_values(&["host_override"])
208 .inc_by(url_filter_cfg.host_overrides.len() as u64);
209 let url_filter = Some(Arc::new(url_filter_cfg));
210
211 let state = Self {
212 config: Arc::new(config),
213 renderer: Arc::new(renderer),
214 crawl_jobs: Arc::new(RwLock::new(HashMap::new())),
215 extract_jobs: Arc::new(RwLock::new(HashMap::new())),
216 crawl_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CRAWLS)),
217 searxng,
218 url_filter,
219 };
220
221 let cleanup_state = state.clone();
224 tokio::spawn(async move {
225 let ttl = Duration::from_secs(cleanup_state.config.crawler.job_ttl_secs);
226 loop {
227 tokio::time::sleep(JOB_CLEANUP_INTERVAL).await;
228 let mut jobs = cleanup_state.crawl_jobs.write().await;
229 let before = jobs.len();
230 jobs.retain(|_id, job| {
231 let is_done = matches!(
232 job.rx.borrow().status,
233 CrawlStatus::Completed | CrawlStatus::Failed | CrawlStatus::Cancelled
234 );
235 !is_done || job.created_at.elapsed() < ttl
237 });
238 let removed = before - jobs.len();
239 if removed > 0 {
240 tracing::info!(
241 removed,
242 remaining = jobs.len(),
243 "Cleaned up expired crawl jobs"
244 );
245 }
246 drop(jobs);
247
248 let mut ejobs = cleanup_state.extract_jobs.write().await;
250 ejobs.retain(|_id, rec| {
251 matches!(rec.status, ExtractStatus::Processing)
252 || rec.created_at.elapsed() < ttl
253 });
254 }
255 });
256
257 Ok(state)
258 }
259
260 pub async fn start_crawl_job(&self, req: CrawlRequest) -> Uuid {
263 let id = Uuid::new_v4();
264 let initial = CrawlState {
265 id,
266 success: true,
267 status: CrawlStatus::InProgress,
268 total: 0,
269 completed: 0,
270 data: vec![],
271 error: None,
272 };
273
274 let (tx, rx) = watch::channel(initial);
275
276 {
277 let mut jobs = self.crawl_jobs.write().await;
278 jobs.insert(
279 id,
280 CrawlJob {
281 rx,
282 tx: tx.clone(),
283 created_at: Instant::now(),
284 abort_handle: None,
285 },
286 );
287 }
288
289 let renderer = self.renderer.clone();
290 let max_concurrency = self.config.crawler.max_concurrency;
291 let respect_robots = self.config.crawler.respect_robots_txt;
292 let rps = self.config.crawler.requests_per_second;
293 let user_agent = self.config.crawler.user_agent.clone();
294 let crawl_semaphore = self.crawl_semaphore.clone();
295 let llm_config = self.config.extraction.llm.clone();
296 let proxy = self.config.crawler.proxy.clone();
297 let jitter_factor = self.config.crawler.stealth.jitter_factor;
298 let deadline_ms_per_page = self.config.effective_deadline_ms(None, req.wait_for);
299 let per_host_max_concurrent = self.config.crawler.per_host_max_concurrent;
300
301 let handle = tokio::spawn(async move {
302 let _permit = match crawl_semaphore.acquire().await {
303 Ok(p) => p,
304 Err(_) => {
305 let _ = tx.send(CrawlState {
306 id,
307 success: false,
308 status: CrawlStatus::Failed,
309 total: 0,
310 completed: 0,
311 data: vec![],
312 error: Some("Server is overloaded, try again later".into()),
313 });
314 return;
315 }
316 };
317 run_crawl(CrawlOptions {
318 id,
319 req,
320 renderer,
321 max_concurrency,
322 respect_robots,
323 requests_per_second: rps,
324 user_agent: &user_agent,
325 state_tx: tx,
326 llm_config: llm_config.as_ref(),
327 proxy,
328 jitter_factor,
329 deadline_ms_per_page,
330 per_host_max_concurrent,
331 })
332 .await;
333 });
334
335 {
337 let mut jobs = self.crawl_jobs.write().await;
338 if let Some(job) = jobs.get_mut(&id) {
339 job.abort_handle = Some(handle.abort_handle());
340 }
341 }
342
343 id
344 }
345
346 pub async fn start_batch_job(&self, urls: Vec<String>, template: ScrapeRequest) -> Uuid {
351 let id = Uuid::new_v4();
352 let total = urls.len() as u32;
353 let (tx, rx) = watch::channel(CrawlState {
354 id,
355 success: true,
356 status: CrawlStatus::InProgress,
357 total,
358 completed: 0,
359 data: vec![],
360 error: None,
361 });
362 {
363 let mut jobs = self.crawl_jobs.write().await;
364 jobs.insert(
365 id,
366 CrawlJob {
367 rx,
368 tx: tx.clone(),
369 created_at: Instant::now(),
370 abort_handle: None,
371 },
372 );
373 }
374
375 let renderer = self.renderer.clone();
376 let crawl_semaphore = self.crawl_semaphore.clone();
377 let config = self.config.clone();
378 let max_concurrency = config.crawler.max_concurrency.max(1);
379
380 let handle = tokio::spawn(async move {
381 let _permit = match crawl_semaphore.acquire().await {
382 Ok(p) => p,
383 Err(_) => {
384 let _ = tx.send(CrawlState {
385 id,
386 success: false,
387 status: CrawlStatus::Failed,
388 total,
389 completed: 0,
390 data: vec![],
391 error: Some("Server is overloaded, try again later".into()),
392 });
393 return;
394 }
395 };
396
397 if total == 0 {
398 let _ = tx.send(CrawlState {
399 id,
400 success: true,
401 status: CrawlStatus::Completed,
402 total: 0,
403 completed: 0,
404 data: vec![],
405 error: None,
406 });
407 return;
408 }
409
410 let user_agent = config.crawler.user_agent.clone();
411 let default_stealth =
412 config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
413 let render_js_default = config.renderer.render_js_default;
414 let deadline_ms = config.effective_deadline_ms(template.deadline_ms, template.wait_for);
415
416 let reqs: Vec<ScrapeRequest> = urls
417 .into_iter()
418 .map(|u| {
419 let mut r = template.clone();
420 r.url = u;
421 r
422 })
423 .collect();
424
425 futures::stream::iter(reqs)
426 .for_each_concurrent(max_concurrency, |req| {
427 let renderer = renderer.clone();
428 let config = config.clone();
429 let user_agent = user_agent.clone();
430 let tx = tx.clone();
431 async move {
432 let deadline = Deadline::from_request_ms(deadline_ms);
433 let scraped = scrape_url(
434 &req,
435 &renderer,
436 config.extraction.llm.as_ref(),
437 &config.extraction,
438 &user_agent,
439 default_stealth,
440 render_js_default,
441 deadline,
442 )
443 .await
444 .ok();
445 tx.send_modify(|st| {
450 if let Some(d) = scraped {
451 st.data.push(d);
452 }
453 st.completed += 1;
454 if st.completed >= total && st.status == CrawlStatus::InProgress {
457 st.status = CrawlStatus::Completed;
458 }
459 });
460 }
461 })
462 .await;
463 });
464
465 {
466 let mut jobs = self.crawl_jobs.write().await;
467 if let Some(job) = jobs.get_mut(&id) {
468 job.abort_handle = Some(handle.abort_handle());
469 }
470 }
471
472 id
473 }
474
475 pub async fn start_extract_job(&self, urls: Vec<String>, template: ScrapeRequest) -> Uuid {
479 let id = Uuid::new_v4();
480 {
481 let mut jobs = self.extract_jobs.write().await;
482 jobs.insert(
483 id,
484 ExtractRecord {
485 status: ExtractStatus::Processing,
486 data: None,
487 tokens_used: 0,
488 credits_used: 0,
489 error: None,
490 created_at: Instant::now(),
491 },
492 );
493 }
494
495 let renderer = self.renderer.clone();
496 let config = self.config.clone();
497 let extract_jobs = self.extract_jobs.clone();
498
499 tokio::spawn(async move {
500 let user_agent = config.crawler.user_agent.clone();
501 let default_stealth =
502 config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
503 let render_js_default = config.renderer.render_js_default;
504 let deadline_ms = config.effective_deadline_ms(template.deadline_ms, template.wait_for);
505
506 let mut merged = serde_json::Map::new();
507 let mut tokens = 0u32;
508 let mut credits = 0u32;
509 let mut last_err: Option<String> = None;
510 let mut any_ok = false;
511
512 for u in urls {
513 let mut req = template.clone();
514 req.url = u;
515 let deadline = Deadline::from_request_ms(deadline_ms);
516 match scrape_url(
517 &req,
518 &renderer,
519 config.extraction.llm.as_ref(),
520 &config.extraction,
521 &user_agent,
522 default_stealth,
523 render_js_default,
524 deadline,
525 )
526 .await
527 {
528 Ok(d) => {
529 any_ok = true;
530 if let Some(serde_json::Value::Object(obj)) = d.json {
531 for (k, v) in obj {
532 merged.insert(k, v);
533 }
534 }
535 if let Some(usage) = d.llm_usage {
536 tokens += usage.total_tokens;
537 }
538 credits += if d.credit_cost == 0 { 1 } else { d.credit_cost };
539 }
540 Err(e) => last_err = Some(e.to_string()),
541 }
542 }
543
544 let mut jobs = extract_jobs.write().await;
545 if let Some(rec) = jobs.get_mut(&id) {
546 if !any_ok && last_err.is_some() {
547 rec.status = ExtractStatus::Failed;
548 rec.error = last_err;
549 } else {
550 rec.status = ExtractStatus::Completed;
551 rec.data = Some(serde_json::Value::Object(merged));
552 }
553 rec.tokens_used = tokens;
554 rec.credits_used = credits.max(1);
555 }
556 });
557
558 id
559 }
560}