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}