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