Skip to main content

crw_server/
state.rs

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
19/// Validate that a request's pinned renderer is available before accepting
20/// the job. Returns `InvalidRequest` (→ HTTP 400) when the named renderer is
21/// not in the configured pool. Skipped when `renderJs:false` is set, since
22/// HTTP-only ignores the pin.
23///
24/// We surface this explicitly (rather than silently falling back to "auto")
25/// so users get clear feedback when they ask for a renderer the operator
26/// hasn't configured. Sites that fail under one renderer often need a
27/// specific other one — silent fallback would leave callers wondering why
28/// "chrome" gave them the same broken result as "auto".
29pub(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    // Mirror the fetch-path resolution at `crw-crawl/src/single.rs:41-50` so
39    // validation is consistent with what the actual request does. "Pinned
40    // implies JS" — when a renderer is pinned and the request omits
41    // `renderJs`, force the request to JS=true so a `render_js_default=false`
42    // server config doesn't silently send the request through HTTP-only.
43    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
67/// Crawl-specific wrapper around [`validate_renderer_pin`].
68pub(crate) fn validate_crawl_renderer(req: &CrawlRequest, state: &AppState) -> CrwResult<()> {
69    validate_renderer_pin(req.renderer, req.render_js, state)
70}
71
72/// Tracks a crawl job receiver + creation time for TTL cleanup.
73pub struct CrawlJob {
74    pub rx: watch::Receiver<CrawlState>,
75    /// Sender kept alongside the receiver so cancel handlers can flip the
76    /// job to a terminal `Cancelled` state after aborting the task.
77    pub tx: watch::Sender<CrawlState>,
78    pub created_at: Instant,
79    /// Handle to abort the crawl task.
80    pub abort_handle: Option<tokio::task::AbortHandle>,
81}
82
83/// Maximum number of concurrent crawl jobs.
84const MAX_CONCURRENT_CRAWLS: usize = 10;
85/// Interval between expired crawl job cleanup runs.
86const JOB_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
87
88/// Lifecycle of an async `/v2/extract` job.
89#[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/// A `/v2/extract` job record. `data` is the single merged JSON object (the
107/// scrape's `json` field unioned across URLs), matching the live API's
108/// `GET /v2/extract/{id}` `data` shape (an object, not an array of documents).
109#[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/// Shared application state.
120#[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    /// `/v2/extract` jobs. Separate from `crawl_jobs` because an extract result
126    /// is a single merged JSON object, not a `Vec<ScrapeData>`.
127    pub extract_jobs: Arc<RwLock<HashMap<Uuid, ExtractRecord>>>,
128    pub crawl_semaphore: Arc<tokio::sync::Semaphore>,
129    /// SearXNG client. `None` when `[search].searxng_url` is unset, in which
130    /// case `/v1/search` returns a clear `search_disabled` error.
131    pub searxng: Option<Arc<SearxngClient>>,
132    /// Server-wide default /map URL filter. `None` disables the filter
133    /// entirely (legacy behaviour). Per-request overrides may swap or
134    /// extend this at handler time.
135    pub url_filter: Option<Arc<crw_crawl::url_filter::UrlFilterCfg>>,
136}
137
138impl AppState {
139    pub fn new(config: AppConfig) -> CrwResult<Self> {
140        // Build the proxy rotator from config (list takes precedence over the
141        // single `proxy`). When present, it owns ALL proxy routing (HTTP pool +
142        // per-request CDP proxyServer), so `new()` gets `proxy = None` and the
143        // rotator is attached via `with_proxy_rotator`. An invalid proxy URL is
144        // a hard startup error — never a silent direct-connection fallback.
145        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            // Dedicated reqwest client for SearXNG so its connection pool is
168            // hot and isolated from the renderer / scrape paths. SearXNG runs
169            // on the same docker network in the bundled compose so a 5s
170            // connect_timeout is generous.
171            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        // One-shot snapshot of how many rules the filter knows about. Helps
186        // operators confirm at boot that the deny-lists actually loaded.
187        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        // Wrap the not-yet-returned state in a block to keep the Ok() shape at the end.
222        // Spawn background job cleanup task.
223        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                    // Keep if not done, or if done but within TTL.
236                    !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                // Prune finished extract jobs past TTL (keep in-flight ones).
249                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    /// Start a new crawl job and return its UUID.
261    /// Spawns a background task that acquires the crawl semaphore before running.
262    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        // Store the abort handle so the job can be cancelled via DELETE.
336        {
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    /// Start a `/v2/batch/scrape` job over an explicit URL list and return its
347    /// UUID. Reuses the crawl-job machinery (`crawl_jobs` + `CrawlState`) but
348    /// scrapes the given URLs directly — no link discovery, no same-origin
349    /// filtering, no dedup; input order is recoverable via `metadata.sourceURL`.
350    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                        // Mutate the shared status in place — push one document and
446                        // bump the counter without cloning the whole accumulated Vec
447                        // on every completion (avoids O(n^2) copying on large
448                        // batches). A failed scrape still advances `completed`.
449                        tx.send_modify(|st| {
450                            if let Some(d) = scraped {
451                                st.data.push(d);
452                            }
453                            st.completed += 1;
454                            // Only flip to Completed from InProgress — never
455                            // overwrite a terminal Cancelled set by DELETE.
456                            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    /// Start a `/v2/extract` job. Scrapes each URL with `formats:[json]` + the
476    /// shared schema (already set on `template`) and merges the per-URL `json`
477    /// objects into one — matching the live API's single-object `data` shape.
478    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}