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/// RAII guard that inc/decrements the `crw_batch_pipelines_inflight` gauge for
84/// the lifetime of one in-flight batch URL-pipeline.
85struct InflightGuard;
86impl InflightGuard {
87    fn new() -> Self {
88        crw_core::metrics::metrics().batch_pipelines_inflight.inc();
89        InflightGuard
90    }
91}
92impl Drop for InflightGuard {
93    fn drop(&mut self) {
94        crw_core::metrics::metrics().batch_pipelines_inflight.dec();
95    }
96}
97
98/// Maximum number of concurrent crawl jobs.
99const MAX_CONCURRENT_CRAWLS: usize = 10;
100/// Interval between expired crawl job cleanup runs.
101const JOB_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
102
103/// Lifecycle of an async `/v2/extract` job.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ExtractStatus {
106    Processing,
107    Completed,
108    Failed,
109}
110
111impl ExtractStatus {
112    pub fn as_str(self) -> &'static str {
113        match self {
114            ExtractStatus::Processing => "processing",
115            ExtractStatus::Completed => "completed",
116            ExtractStatus::Failed => "failed",
117        }
118    }
119}
120
121/// One URL's extraction outcome. Powers the native `/v1/extract` per-URL array
122/// contract (`results:[{url,status,data,error,llmUsage}]`), which sidesteps the
123/// FC-legacy last-write-wins merge. `llm_usage` lets the SaaS settle real cost.
124///
125/// The `basis*` / `llm_input_hash` fields carry per-field evidence and are
126/// populated only when the request set `basis: true`. They stay per-URL on
127/// purpose: a citation is only meaningful next to the document it came from, so
128/// merging them into the FC-legacy flattened `data` object would destroy the
129/// attribution.
130#[derive(Debug, Clone)]
131pub struct UrlResult {
132    pub url: String,
133    pub status: ExtractStatus,
134    pub data: Option<serde_json::Value>,
135    pub error: Option<String>,
136    pub llm_usage: Option<crw_core::types::LlmUsage>,
137    pub basis: Option<Vec<crw_core::evidence::Basis>>,
138    pub basis_warnings: Vec<crw_core::evidence::BasisWarning>,
139    pub llm_input_hash: Option<String>,
140}
141
142/// A URL prepared by the handler for the worker, in original request order.
143/// `preflight_error: Some(..)` marks a parse/SSRF failure that must surface as a
144/// `failed` result without being fetched (native contract: no silent drops).
145#[derive(Debug, Clone)]
146pub struct PreparedUrl {
147    pub url: String,
148    pub preflight_error: Option<String>,
149}
150
151/// An async extract job record. `data` is the single merged JSON object (the
152/// scrape's `json` field unioned across URLs), preserved for the FC-legacy
153/// `GET /v2/extract/{id}` `data` shape. `per_url` is the native per-URL array
154/// (`GET /v1/extract/{id}`), in original request order.
155#[derive(Debug, Clone)]
156pub struct ExtractRecord {
157    pub status: ExtractStatus,
158    pub data: Option<serde_json::Value>,
159    pub per_url: Vec<UrlResult>,
160    pub tokens_used: u32,
161    pub credits_used: u32,
162    pub error: Option<String>,
163    pub created_at: Instant,
164}
165
166/// Shared application state.
167#[derive(Clone)]
168pub struct AppState {
169    pub config: Arc<AppConfig>,
170    pub renderer: Arc<FallbackRenderer>,
171    pub crawl_jobs: Arc<RwLock<HashMap<Uuid, CrawlJob>>>,
172    /// `/v2/extract` jobs. Separate from `crawl_jobs` because an extract result
173    /// is a single merged JSON object, not a `Vec<ScrapeData>`.
174    pub extract_jobs: Arc<RwLock<HashMap<Uuid, ExtractRecord>>>,
175    pub crawl_semaphore: Arc<tokio::sync::Semaphore>,
176    /// Process-wide cap on the total in-flight `/v2/batch/scrape` URL-pipelines
177    /// across all batch-scrape jobs (aggregate bound so `N jobs × width` can't
178    /// explode). Targets batch scrape specifically because that's the only wide
179    /// fan-out: crawl is BFS-sequential and `/v2/extract` scrapes one URL at a
180    /// time, both already bounded by the `crawl_semaphore` job cap. `None` =
181    /// unbounded (config `max_aggregate_batch_pipelines = 0`/absent). Acquired
182    /// as the first op in each batch URL future, before fetch.
183    pub batch_pipeline_sem: Option<Arc<tokio::sync::Semaphore>>,
184    /// SearXNG client. `None` when `[search].searxng_url` is unset, in which
185    /// case `/v1/search` returns a clear `search_disabled` error.
186    pub searxng: Option<Arc<SearxngClient>>,
187    /// Server-wide default /map URL filter. `None` disables the filter
188    /// entirely (legacy behaviour). Per-request overrides may swap or
189    /// extend this at handler time.
190    pub url_filter: Option<Arc<crw_crawl::url_filter::UrlFilterCfg>>,
191}
192
193impl AppState {
194    pub fn new(config: AppConfig) -> CrwResult<Self> {
195        // Build the proxy rotator from config (list takes precedence over the
196        // single `proxy`). When present, it owns ALL proxy routing (HTTP pool +
197        // per-request CDP proxyServer), so `new()` gets `proxy = None` and the
198        // rotator is attached via `with_proxy_rotator`. An invalid proxy URL is
199        // a hard startup error — never a silent direct-connection fallback.
200        let proxy_rotator = crw_core::ProxyRotator::build(
201            &config.crawler.proxy_list,
202            config.crawler.proxy.as_deref(),
203            config.crawler.proxy_rotation,
204        )
205        .map_err(CrwError::ConfigError)?
206        .map(Arc::new);
207        let renderer = FallbackRenderer::new(
208            &config.renderer,
209            &config.crawler.user_agent,
210            None,
211            &config.crawler.stealth,
212        )?
213        .with_proxy_rotator(proxy_rotator)?
214        .with_host_limits(
215            config.crawler.requests_per_second,
216            config.crawler.per_host_max_concurrent,
217            config.crawler.per_host_interactive_reserve,
218        );
219
220        let searxng = if config.search.enabled
221            && let Some(url) = config.search.searxng_url.as_ref()
222        {
223            // Dedicated reqwest client for SearXNG so its connection pool is
224            // hot and isolated from the renderer / scrape paths. SearXNG runs
225            // on the same docker network in the bundled compose so a 5s
226            // connect_timeout is generous.
227            let http = reqwest::Client::builder()
228                .connect_timeout(Duration::from_secs(5))
229                .build()
230                .map_err(|e| {
231                    CrwError::Internal(format!("failed to build SearXNG http client: {e}"))
232                })?;
233            let timeout = Duration::from_millis(config.search.timeout_ms);
234            Some(Arc::new(SearxngClient::new(Arc::new(http), url, timeout)))
235        } else {
236            None
237        };
238
239        let url_filter_cfg =
240            crw_crawl::url_filter::UrlFilterCfg::from_map_config(&config.map.url_filter);
241        // One-shot snapshot of how many rules the filter knows about. Helps
242        // operators confirm at boot that the deny-lists actually loaded.
243        let m = crw_core::metrics::metrics();
244        m.map_filter_rules_loaded
245            .with_label_values(&["action"])
246            .inc_by(
247                (crw_crawl::url_filter_data::DEFAULT_ACTION_PARAMS.len()
248                    + url_filter_cfg.action_params.len()) as u64,
249            );
250        m.map_filter_rules_loaded
251            .with_label_values(&["tracking"])
252            .inc_by(
253                (crw_crawl::url_filter_data::DEFAULT_TRACKING_PARAMS.len()
254                    + url_filter_cfg.tracking_params.len()) as u64,
255            );
256        m.map_filter_rules_loaded
257            .with_label_values(&["preserve"])
258            .inc_by(
259                (crw_crawl::url_filter_data::ALWAYS_PRESERVE.len()
260                    + url_filter_cfg.preserve_params.len()) as u64,
261            );
262        m.map_filter_rules_loaded
263            .with_label_values(&["host_override"])
264            .inc_by(url_filter_cfg.host_overrides.len() as u64);
265        let url_filter = Some(Arc::new(url_filter_cfg));
266
267        // Install the process-wide reserved-lane limits (extract / PDF / LLM)
268        // HERE — inside `AppState::new` — so every entry point that builds an
269        // AppState (the `crw-server` binary AND `crw serve` / embedded CLI) gets
270        // the configured concurrency + reservations, not just the fallbacks.
271        // All three are idempotent (first-call-wins).
272        let extract_total = config.extraction.max_concurrent_extracts;
273        crw_crawl::extract_pool::configure_extract_limit(
274            extract_total,
275            crw_core::config::resolve_interactive_reserve(
276                config.extraction.reserved_interactive_extracts,
277                extract_total,
278            ),
279        );
280        crw_crawl::pdf::configure_limits(&config.document);
281        if let Some(llm) = &config.extraction.llm {
282            crw_extract::llm_gate::configure_llm_limits(
283                llm.max_concurrency,
284                crw_core::config::resolve_interactive_reserve(
285                    llm.reserved_interactive_llm,
286                    llm.max_concurrency,
287                ),
288            );
289        }
290
291        // `0`/absent = unbounded aggregate (no cap); any n>0 bounds total
292        // in-flight batch URL-pipelines process-wide.
293        let batch_pipeline_sem = match config.crawler.max_aggregate_batch_pipelines {
294            0 => None,
295            n => Some(Arc::new(tokio::sync::Semaphore::new(n))),
296        };
297
298        let state = Self {
299            config: Arc::new(config),
300            renderer: Arc::new(renderer),
301            crawl_jobs: Arc::new(RwLock::new(HashMap::new())),
302            extract_jobs: Arc::new(RwLock::new(HashMap::new())),
303            crawl_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CRAWLS)),
304            batch_pipeline_sem,
305            searxng,
306            url_filter,
307        };
308
309        // Wrap the not-yet-returned state in a block to keep the Ok() shape at the end.
310        // Spawn background job cleanup task.
311        let cleanup_state = state.clone();
312        tokio::spawn(async move {
313            let ttl = Duration::from_secs(cleanup_state.config.crawler.job_ttl_secs);
314            loop {
315                tokio::time::sleep(JOB_CLEANUP_INTERVAL).await;
316                let mut jobs = cleanup_state.crawl_jobs.write().await;
317                let before = jobs.len();
318                jobs.retain(|_id, job| {
319                    let is_done = matches!(
320                        job.rx.borrow().status,
321                        CrawlStatus::Completed | CrawlStatus::Failed | CrawlStatus::Cancelled
322                    );
323                    // Keep if not done, or if done but within TTL.
324                    !is_done || job.created_at.elapsed() < ttl
325                });
326                let removed = before - jobs.len();
327                if removed > 0 {
328                    tracing::info!(
329                        removed,
330                        remaining = jobs.len(),
331                        "Cleaned up expired crawl jobs"
332                    );
333                }
334                drop(jobs);
335
336                // Prune finished extract jobs past TTL (keep in-flight ones).
337                let mut ejobs = cleanup_state.extract_jobs.write().await;
338                ejobs.retain(|_id, rec| {
339                    matches!(rec.status, ExtractStatus::Processing)
340                        || rec.created_at.elapsed() < ttl
341                });
342            }
343        });
344
345        Ok(state)
346    }
347
348    /// Start a new crawl job and return its UUID.
349    /// Spawns a background task that acquires the crawl semaphore before running.
350    pub async fn start_crawl_job(&self, req: CrawlRequest) -> Uuid {
351        let id = Uuid::new_v4();
352        let initial = CrawlState {
353            id,
354            success: true,
355            status: CrawlStatus::InProgress,
356            total: 0,
357            completed: 0,
358            data: vec![],
359            error: None,
360        };
361
362        let (tx, rx) = watch::channel(initial);
363
364        {
365            let mut jobs = self.crawl_jobs.write().await;
366            jobs.insert(
367                id,
368                CrawlJob {
369                    rx,
370                    tx: tx.clone(),
371                    created_at: Instant::now(),
372                    abort_handle: None,
373                },
374            );
375        }
376
377        let renderer = self.renderer.clone();
378        let max_concurrency = self.config.crawler.max_concurrency;
379        let respect_robots = self.config.crawler.respect_robots_txt;
380        let rps = self.config.crawler.requests_per_second;
381        let user_agent = self.config.crawler.user_agent.clone();
382        let crawl_semaphore = self.crawl_semaphore.clone();
383        let llm_config = self.config.extraction.llm.clone();
384        let proxy = self.config.crawler.proxy.clone();
385        let jitter_factor = self.config.crawler.stealth.jitter_factor;
386        let deadline_ms_per_page = self.config.effective_deadline_ms(None, req.wait_for);
387        let per_host_max_concurrent = self.config.crawler.per_host_max_concurrent;
388
389        let handle = tokio::spawn(async move {
390            let _permit = match crawl_semaphore.acquire().await {
391                Ok(p) => p,
392                Err(_) => {
393                    let _ = tx.send(CrawlState {
394                        id,
395                        success: false,
396                        status: CrawlStatus::Failed,
397                        total: 0,
398                        completed: 0,
399                        data: vec![],
400                        error: Some("Server is overloaded, try again later".into()),
401                    });
402                    return;
403                }
404            };
405            // Crawl pages are `Batch` traffic (same reserved-lane treatment as
406            // batch scrape). Scoped inside the job's spawned task so the
407            // task-local reaches every per-page fetch/extract; a handler-level
408            // scope would be lost across this `tokio::spawn`.
409            crw_core::REQUEST_CLASS
410                .scope(crw_core::ScrapeClass::Batch, async {
411                    run_crawl(CrawlOptions {
412                        id,
413                        req,
414                        renderer,
415                        max_concurrency,
416                        respect_robots,
417                        requests_per_second: rps,
418                        user_agent: &user_agent,
419                        state_tx: tx,
420                        llm_config: llm_config.as_ref(),
421                        proxy,
422                        jitter_factor,
423                        deadline_ms_per_page,
424                        per_host_max_concurrent,
425                    })
426                    .await;
427                })
428                .await;
429        });
430
431        // Store the abort handle so the job can be cancelled via DELETE.
432        {
433            let mut jobs = self.crawl_jobs.write().await;
434            if let Some(job) = jobs.get_mut(&id) {
435                job.abort_handle = Some(handle.abort_handle());
436            }
437        }
438
439        id
440    }
441
442    /// Start a `/v2/batch/scrape` job over an explicit URL list and return its
443    /// UUID. Reuses the crawl-job machinery (`crawl_jobs` + `CrawlState`) but
444    /// scrapes the given URLs directly — no link discovery, no same-origin
445    /// filtering, no dedup; input order is recoverable via `metadata.sourceURL`.
446    pub async fn start_batch_job(
447        &self,
448        urls: Vec<String>,
449        template: ScrapeRequest,
450        max_concurrency_override: Option<usize>,
451    ) -> Uuid {
452        let id = Uuid::new_v4();
453        let total = urls.len() as u32;
454        let (tx, rx) = watch::channel(CrawlState {
455            id,
456            success: true,
457            status: CrawlStatus::InProgress,
458            total,
459            completed: 0,
460            data: vec![],
461            error: None,
462        });
463        {
464            let mut jobs = self.crawl_jobs.write().await;
465            jobs.insert(
466                id,
467                CrawlJob {
468                    rx,
469                    tx: tx.clone(),
470                    created_at: Instant::now(),
471                    abort_handle: None,
472                },
473            );
474        }
475
476        let renderer = self.renderer.clone();
477        let crawl_semaphore = self.crawl_semaphore.clone();
478        let batch_pipeline_sem = self.batch_pipeline_sem.clone();
479        let config = self.config.clone();
480        // Per-job OUTER pipeline width: the SaaS-injected (plan-scaled)
481        // `maxConcurrency`, or `max_concurrency` when absent. BOTH paths are
482        // clamped to `[1, max_batch_concurrency]` so a batch job never exceeds
483        // the ceiling regardless of source (wire value never trusted).
484        let width_ceiling = config.crawler.max_batch_concurrency.max(1);
485        let max_concurrency = max_concurrency_override
486            .unwrap_or(config.crawler.max_concurrency)
487            .clamp(1, width_ceiling);
488
489        let handle = tokio::spawn(async move {
490            let _permit = match crawl_semaphore.acquire().await {
491                Ok(p) => p,
492                Err(_) => {
493                    let _ = tx.send(CrawlState {
494                        id,
495                        success: false,
496                        status: CrawlStatus::Failed,
497                        total,
498                        completed: 0,
499                        data: vec![],
500                        error: Some("Server is overloaded, try again later".into()),
501                    });
502                    return;
503                }
504            };
505
506            if total == 0 {
507                let _ = tx.send(CrawlState {
508                    id,
509                    success: true,
510                    status: CrawlStatus::Completed,
511                    total: 0,
512                    completed: 0,
513                    data: vec![],
514                    error: None,
515                });
516                return;
517            }
518
519            let user_agent = config.crawler.user_agent.clone();
520            let default_stealth =
521                config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
522            let render_js_default = config.renderer.render_js_default;
523            let deadline_ms = config.effective_deadline_ms(template.deadline_ms, template.wait_for);
524
525            let reqs: Vec<ScrapeRequest> = urls
526                .into_iter()
527                .map(|u| {
528                    let mut r = template.clone();
529                    r.url = u;
530                    r
531                })
532                .collect();
533
534            // Stamp every URL in this job as `Batch` traffic. The scope wraps the
535            // whole `for_each_concurrent` stream; that combinator polls its
536            // futures cooperatively within THIS task (no `tokio::spawn` per URL),
537            // so the task-local propagates to each per-URL `scrape_url` and on to
538            // the reserved lanes it reads. Scoped here (inside the job's spawned
539            // task), not at the handler, because the task-local would be lost
540            // across this job's `tokio::spawn`.
541            crw_core::REQUEST_CLASS
542                .scope(crw_core::ScrapeClass::Batch, async move {
543                    futures::stream::iter(reqs)
544                        .for_each_concurrent(max_concurrency, |req| {
545                            let renderer = renderer.clone();
546                            let config = config.clone();
547                            let user_agent = user_agent.clone();
548                            let tx = tx.clone();
549                            let batch_pipeline_sem = batch_pipeline_sem.clone();
550                            async move {
551                                // Aggregate cap: acquire a process-wide pipeline
552                                // permit BEFORE fetching so `N jobs × width` can't
553                                // explode. `None` = unbounded. Held for this URL's
554                                // whole lifetime.
555                                let _pipeline_permit = match &batch_pipeline_sem {
556                                    Some(sem) => sem.acquire().await.ok(),
557                                    None => None,
558                                };
559                                // In-flight batch-pipeline gauge (RAII inc/dec).
560                                let _inflight = InflightGuard::new();
561                                let deadline = Deadline::from_request_ms(deadline_ms);
562                                let scraped = scrape_url(
563                                    &req,
564                                    &renderer,
565                                    config.extraction.llm.as_ref(),
566                                    &config.extraction,
567                                    &user_agent,
568                                    default_stealth,
569                                    render_js_default,
570                                    deadline,
571                                )
572                                .await
573                                .ok();
574                                // Mutate the shared status in place — push one document
575                                // and bump the counter without cloning the whole
576                                // accumulated Vec on every completion (avoids O(n^2)
577                                // copying on large batches). A failed scrape still
578                                // advances `completed`.
579                                tx.send_modify(|st| {
580                                    if let Some(d) = scraped {
581                                        st.data.push(d);
582                                    }
583                                    st.completed += 1;
584                                    // Only flip to Completed from InProgress — never
585                                    // overwrite a terminal Cancelled set by DELETE.
586                                    if st.completed >= total && st.status == CrawlStatus::InProgress
587                                    {
588                                        st.status = CrawlStatus::Completed;
589                                    }
590                                });
591                            }
592                        })
593                        .await;
594                })
595                .await;
596        });
597
598        {
599            let mut jobs = self.crawl_jobs.write().await;
600            if let Some(job) = jobs.get_mut(&id) {
601                job.abort_handle = Some(handle.abort_handle());
602            }
603        }
604
605        id
606    }
607
608    /// Start an async extract job. Each entry is scraped with `formats:[json]` +
609    /// the shared template; per-URL `json` objects are both (a) merged into one
610    /// object for the FC-legacy `data` shape and (b) kept as an ordered per-URL
611    /// array for the native `/v1/extract` contract. `entries` is in original
612    /// request order and may include preflight-failed URLs (surfaced as `failed`
613    /// results without being fetched).
614    pub async fn start_extract_job(
615        &self,
616        entries: Vec<PreparedUrl>,
617        template: ScrapeRequest,
618    ) -> Uuid {
619        let id = Uuid::new_v4();
620        {
621            let mut jobs = self.extract_jobs.write().await;
622            jobs.insert(
623                id,
624                ExtractRecord {
625                    status: ExtractStatus::Processing,
626                    data: None,
627                    per_url: Vec::new(),
628                    tokens_used: 0,
629                    credits_used: 0,
630                    error: None,
631                    created_at: Instant::now(),
632                },
633            );
634        }
635
636        let renderer = self.renderer.clone();
637        let config = self.config.clone();
638        let extract_jobs = self.extract_jobs.clone();
639
640        tokio::spawn(async move {
641            // `/v2/extract` is a multi-URL background job — `Batch` traffic, so its
642            // scrapes use the batch lanes and don't consume the interactive reserve.
643            // Scoped inside the spawned task (a handler-level scope is lost across
644            // `tokio::spawn`).
645            crw_core::REQUEST_CLASS
646                .scope(crw_core::ScrapeClass::Batch, async move {
647                    let user_agent = config.crawler.user_agent.clone();
648                    let default_stealth =
649                        config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
650                    let render_js_default = config.renderer.render_js_default;
651                    let deadline_ms =
652                        config.effective_deadline_ms(template.deadline_ms, template.wait_for);
653
654                    let mut merged = serde_json::Map::new();
655                    let mut per_url: Vec<UrlResult> = Vec::with_capacity(entries.len());
656                    let mut tokens = 0u32;
657                    let mut credits = 0u32;
658                    let mut last_err: Option<String> = None;
659                    let mut any_ok = false;
660
661                    for entry in entries {
662                        // Preflight-failed URLs (bad parse / SSRF) surface as
663                        // `failed` without a fetch — never silently dropped.
664                        if let Some(err) = entry.preflight_error {
665                            last_err = Some(err.clone());
666                            per_url.push(UrlResult {
667                                url: entry.url,
668                                status: ExtractStatus::Failed,
669                                data: None,
670                                error: Some(err),
671                                llm_usage: None,
672                                basis: None,
673                                basis_warnings: Vec::new(),
674                                llm_input_hash: None,
675                            });
676                            continue;
677                        }
678
679                        let mut req = template.clone();
680                        req.url = entry.url.clone();
681                        let deadline = Deadline::from_request_ms(deadline_ms);
682                        match scrape_url(
683                            &req,
684                            &renderer,
685                            config.extraction.llm.as_ref(),
686                            &config.extraction,
687                            &user_agent,
688                            default_stealth,
689                            render_js_default,
690                            deadline,
691                        )
692                        .await
693                        {
694                            Ok(d) => {
695                                any_ok = true;
696                                if let Some(serde_json::Value::Object(obj)) = &d.json {
697                                    for (k, v) in obj {
698                                        merged.insert(k.clone(), v.clone());
699                                    }
700                                }
701                                if let Some(usage) = &d.llm_usage {
702                                    tokens += usage.total_tokens;
703                                }
704                                credits += if d.credit_cost == 0 { 1 } else { d.credit_cost };
705                                per_url.push(UrlResult {
706                                    url: entry.url,
707                                    status: ExtractStatus::Completed,
708                                    data: d.json,
709                                    error: None,
710                                    llm_usage: d.llm_usage,
711                                    basis: d.basis,
712                                    basis_warnings: d.basis_warnings,
713                                    llm_input_hash: d.llm_input_hash,
714                                });
715                            }
716                            Err(e) => {
717                                let msg = e.to_string();
718                                last_err = Some(msg.clone());
719                                per_url.push(UrlResult {
720                                    url: entry.url,
721                                    status: ExtractStatus::Failed,
722                                    data: None,
723                                    error: Some(msg),
724                                    llm_usage: None,
725                                    basis: None,
726                                    basis_warnings: Vec::new(),
727                                    llm_input_hash: None,
728                                });
729                            }
730                        }
731                    }
732
733                    let mut jobs = extract_jobs.write().await;
734                    if let Some(rec) = jobs.get_mut(&id) {
735                        if !any_ok && last_err.is_some() {
736                            rec.status = ExtractStatus::Failed;
737                            rec.error = last_err;
738                        } else {
739                            rec.status = ExtractStatus::Completed;
740                            rec.data = Some(serde_json::Value::Object(merged));
741                        }
742                        rec.per_url = per_url;
743                        rec.tokens_used = tokens;
744                        // ponytail: 1-credit floor even on an all-failed job —
745                        // preflight-failed URLs add 0 (they `continue` before the
746                        // tally). SaaS settles the real cost separately.
747                        rec.credits_used = credits.max(1);
748                    }
749                })
750                .await;
751        });
752
753        id
754    }
755}