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}