1use super::*;
2
3impl Scraper {
4 pub fn new(config: ScraperConfig) -> Result<Self, ScraperError> {
5 cpu::install();
6 let browser_private_network = config.fetcher.ssrf.allow_private_network;
7 let browser_allow = browser_allow_list(&config.fetcher.ssrf)?;
8 let mut egress = Vec::new();
9 for (name, proxy) in &config.egress {
10 let mut fc = config.fetcher.clone();
11 if !proxy.is_empty() {
13 fc.proxy = Some(proxy.clone());
14 }
15 let class = config.egress_classes.get(name).copied().unwrap_or_default();
16 egress.push((
17 name.clone(),
18 class,
19 Arc::new(Fetcher::new_egress(fc).map_err(ScraperError::Fetcher)?),
20 ));
21 }
22 let fetcher = Arc::new(Fetcher::new(config.fetcher).map_err(ScraperError::Fetcher)?);
23 let llm = config
24 .llm
25 .map(pagesift_llm::ChatClient::new)
26 .transpose()
27 .map_err(ScraperError::Llm)?;
28 let rate = &config.policy.rate;
29 let interact_limits = config.interact;
30 let initial_conc = rate
31 .global_ceiling
32 .map_or(rate.global_concurrency, |c| rate.global_concurrency.min(c))
33 .max(1);
34 let crawl_slots = pagesift_policy::AdaptiveConcurrencyGate::new(initial_conc);
35 let chain = pagesift_cdp::engine_chain_with_allow_list(
36 &config.browser,
37 browser_private_network,
38 &browser_allow,
39 );
40 let sessions = Arc::new(super::interact::Sessions::new(
41 interact_limits,
42 super::interact::record_budget(&chain),
43 ));
44 Ok(Self {
45 crawl_slots,
46 policy: Arc::new({
47 let wait_price = fetcher.config().wait_price.clone();
49 let mut p = Policy::new(fetcher, config.policy);
50 for (name, class, f) in egress {
51 p.add_egress_class(name, class, f);
52 }
53 p.limiter().publish_wait_price(wait_price);
54 p
55 }),
56 store: config.store,
57 audit: config.audit,
58 hooks: config.hooks,
59 secrets: config.secrets,
60 llm,
61 search: config.search,
62 ocr: config.ocr,
63 browsers: {
64 log_engine_chain(&chain);
66 chain.into_iter().map(|c| (c, OnceCell::new())).collect()
67 },
68 launch_failed: Vec::new(),
69 launch_notes: Vec::new(),
70 launch_broken: Default::default(),
71 browser_private_network,
72 browser_allow,
73 learner: crate::learn::HostLearner::default(),
74 planner: super::planner::RenderPlanner::default(),
75 render_stats: super::render_stats::RenderStats::new(),
76 pages: std::sync::OnceLock::new(),
77 sessions,
78 })
79 .map(|mut s| {
80 s.launch_failed = (0..s.browsers.len()).map(|_| Default::default()).collect();
81 s.launch_notes = (0..s.browsers.len()).map(|_| Default::default()).collect();
82 if let Some(store) = &s.store {
84 super::planner::load(&s.planner, store);
85 }
86 s
87 })
88 }
89
90 pub(super) async fn browser(&self, i: usize) -> Option<Arc<BrowserPool>> {
92 let (cfg, cell) = self.browsers.get(i)?;
93 cell.get_or_init(|| async {
94 let guard: Arc<dyn RequestGuard> = if self.browser_private_network {
95 Arc::new(SchemeGuard)
96 } else if self.browser_allow.is_empty() {
97 Arc::new(PrivateNetworkGuard)
98 } else {
99 Arc::new(AllowListGuard::new(self.browser_allow.clone()))
100 };
101 match BrowserPool::new(cfg.clone(), guard).await {
102 Ok(pool) => Some(Arc::new(pool)),
103 Err(e) => {
104 let engine = cfg.engine.map_or("browser", |k| k.name());
105 *self.launch_notes[i].lock().unwrap() = Some(format!("{engine}: {e}"));
106 if !matches!(e, pagesift_cdp::Error::NoBrowserFound) {
107 self.launch_broken
108 .store(true, std::sync::atomic::Ordering::Relaxed);
109 }
110 tracing::warn!("{engine} rendering unavailable: {e}");
111 None
112 }
113 }
114 })
115 .await
116 .clone()
117 }
118
119 pub(super) async fn has_browser(&self) -> bool {
120 for i in 0..self.browsers.len() {
121 if self.browser(i).await.is_some() {
122 return true;
123 }
124 }
125 false
126 }
127
128 pub(super) fn browser_launch_failed(&self) -> bool {
130 self.launch_broken
131 .load(std::sync::atomic::Ordering::Relaxed)
132 }
133
134 pub(super) fn browser_unavailable_reason(&self) -> String {
136 let notes: Vec<String> = self
137 .launch_notes
138 .iter()
139 .filter_map(|n| n.lock().unwrap().clone())
140 .collect();
141 if notes.is_empty() {
142 return "no browser engine found (run `pagesift doctor`)".into();
143 }
144 let root = notes.iter().any(|n| n.contains("as root"));
145 let mut reason = format!(
146 "no browser engine could start ({}); run `pagesift doctor` for details",
147 notes.join("; ")
148 );
149 if root && !notes.iter().any(|n| n.contains("unprivileged user")) {
150 reason.push_str(
151 " — pagesift is running as root: the Chrome sandbox does not work as root and pagesift will not disable it; run as an unprivileged user",
152 );
153 }
154 reason
155 }
156
157 pub async fn probe_engines(&self) -> Vec<EngineProbe> {
160 let mut out = Vec::new();
161 for i in 0..self.browsers.len() {
162 let cfg = &self.browsers[i].0;
163 let engine = cfg.engine.map_or("chrome", |k| k.name());
164 let location = match &cfg.source {
165 pagesift_cdp::BrowserSource::Executable(p) => p.display().to_string(),
166 pagesift_cdp::BrowserSource::Remote(u) => u.to_string(),
167 pagesift_cdp::BrowserSource::Auto => "auto".into(),
168 };
169 let result = match self.browser(i).await {
170 Some(pool) => pool.probe().await.map_err(|e| e.to_string()),
171 None => Err(self.launch_notes[i]
172 .lock()
173 .unwrap()
174 .clone()
175 .unwrap_or_else(|| "could not start".into())),
176 };
177 out.push(EngineProbe {
178 engine,
179 location,
180 result,
181 });
182 }
183 out
184 }
185
186 pub async fn probe_browsers(&self) {
188 for i in 0..self.browsers.len() {
189 let Some(pool) = self.browser(i).await else {
190 continue;
191 };
192 let engine = pool.engine().name();
193 match pool.probe().await {
194 Ok(()) => tracing::info!(engine, "browser engine ready"),
195 Err(e) => self.note_launch_failure(i, engine, &e),
196 }
197 }
198 }
199
200 pub(super) fn note_launch_failure(&self, i: usize, engine: &str, e: &pagesift_cdp::Error) {
202 let first = self.launch_failed[i]
203 .lock()
204 .unwrap()
205 .replace(Instant::now())
206 .is_none();
207 if first {
208 let msg = e.to_string();
209 *self.launch_notes[i].lock().unwrap() = Some(format!("{engine}: {msg}"));
210 let hint = if msg.contains("sandbox") {
211 ": the browser sandbox needs unprivileged user namespaces, which this host or container \
212 does not allow (check kernel.apparmor_restrict_unprivileged_userns and the container's \
213 seccomp/AppArmor profile); pagesift does not disable the sandbox"
214 } else {
215 ""
216 };
217 tracing::warn!(
218 engine,
219 retry_after_secs = LAUNCH_RETRY.as_secs(),
220 "{engine} fallback unavailable: {msg}{hint}"
221 );
222 }
223 }
224
225 pub async fn shutdown(&self) {
226 self.sessions.shutdown().await;
227 if let Some(store) = &self.store
228 && let Err(e) = super::planner::save(&self.planner, store)
229 {
230 tracing::warn!("saving the render planner failed: {e}");
231 }
232 for (_, cell) in &self.browsers {
233 if let Some(Some(pool)) = cell.get() {
234 pool.shutdown().await;
235 }
236 }
237 }
238
239 pub async fn scrape(&self, url: &str, opts: &ScrapeOptions) -> Result<Document, ApiError> {
240 let mode = self
241 .policy
242 .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
243 .map_err(policy_error)?;
244 self.scrape_with(url, opts, mode).await
245 }
246
247 pub fn audit_robots_ignored(&self, kind: RequestKind, mode: RobotsMode, url: &str) {
249 if mode == RobotsMode::Ignore
250 && let Some(audit) = &self.audit
251 {
252 let request = match kind {
253 RequestKind::Scrape => "scrape",
254 RequestKind::Crawl => "crawl",
255 RequestKind::Map => "map",
256 };
257 audit.record_robots_ignored(request, url, self.policy.robots_ignore_reason(kind));
258 }
259 }
260
261 pub fn audit(&self) -> Option<&Arc<crate::audit::Audit>> {
263 self.audit.as_ref()
264 }
265
266 pub fn llm_client(&self) -> Option<&pagesift_llm::ChatClient> {
268 self.llm.as_ref()
269 }
270
271 pub fn has_search(&self) -> bool {
273 self.search.is_some()
274 }
275
276 pub fn policy(&self) -> &Policy {
277 &self.policy
278 }
279
280 pub fn route_pages(&self, runner: Arc<dyn crate::PageRunner>) -> bool {
282 self.pages.set(runner).is_ok()
283 }
284
285 pub fn page_runner(&self) -> Option<&Arc<dyn crate::PageRunner>> {
286 self.pages.get()
287 }
288
289 pub fn planner(&self) -> &super::planner::RenderPlanner {
291 &self.planner
292 }
293
294 pub fn learner(&self) -> &crate::learn::HostLearner {
295 &self.learner
296 }
297
298 pub fn engine_count(&self) -> usize {
300 self.browsers.len()
301 }
302
303 pub fn network_stage_snapshots(&self) -> Vec<pagesift_net::StageSnapshot> {
304 self.policy.fetcher().stage_snapshots()
305 }
306
307 pub fn transport_phase_snapshots(&self) -> Vec<pagesift_net::PhaseSnapshot> {
308 self.policy.fetcher().phase_snapshots()
309 }
310
311 pub fn crawl_slots(&self) -> &pagesift_policy::AdaptiveConcurrencyGate {
312 self.sync_crawl_slots();
313 &self.crawl_slots
314 }
315
316 pub fn concurrency(&self) -> usize {
321 if let Some(n) = self.pages.get().and_then(|r| r.capacity()) {
322 return n.max(1);
323 }
324 self.policy.limiter().global_limit()
325 }
326
327 pub fn observe_crawl(&self, _queue_wait: Duration, _failed: bool) {
330 self.sync_crawl_slots();
331 }
332
333 fn sync_crawl_slots(&self) {
334 let target = self.concurrency();
335 if self.crawl_slots.limit() != target {
336 self.crawl_slots.set_limit(target);
337 }
338 }
339
340 pub async fn scrape_with(
345 &self,
346 url: &str,
347 opts: &ScrapeOptions,
348 mode: RobotsMode,
349 ) -> Result<Document, ApiError> {
350 self.scrape_audited("scrape", url, opts, mode).await
351 }
352
353 pub async fn scrape_audited(
355 &self,
356 kind: &str,
357 url: &str,
358 opts: &ScrapeOptions,
359 mode: RobotsMode,
360 ) -> Result<Document, ApiError> {
361 let Some(audit) = &self.audit else {
362 return self.scrape_inner(Source::Url(url, mode), opts).await;
363 };
364 let started = Instant::now();
365 let mut result = self.scrape_inner(Source::Url(url, mode), opts).await;
366 let raw = result.as_ref().ok().and_then(|d| d.raw_html.as_deref());
367 if mode == RobotsMode::Ignore {
368 let request = if kind == "scrape" {
369 RequestKind::Scrape
370 } else {
371 RequestKind::Crawl
372 };
373 let reason = self.policy.robots_ignore_reason(request);
374 audit.record_scrape_ignoring_robots(
375 kind,
376 url,
377 opts,
378 &result,
379 raw,
380 started.elapsed(),
381 reason,
382 );
383 } else {
384 audit.record_scrape(kind, url, opts, &result, raw, started.elapsed());
385 }
386 if !opts.wants("rawHtml")
388 && let Ok(doc) = &mut result
389 {
390 doc.raw_html = None;
391 }
392 result
393 }
394
395 pub(super) async fn scrape_plain(
397 &self,
398 source: Source<'_>,
399 opts: &ScrapeOptions,
400 ) -> Result<Document, ApiError> {
401 tag_filter(opts)?;
403 if opts.export_images {
404 return self.scrape_exporting_images(source, opts).await;
405 }
406 let mut result = self.scrape_document(source, opts).await;
407 if let Ok(doc) = &mut result {
408 if !opts.headers.is_empty()
410 && let Some(meta) = &mut doc.metadata.pagesift
411 && meta.engine.as_deref() == Some("browser")
412 {
413 meta.warnings.push(
414 "headers_not_sent: this page was rendered in a browser, which does not send the request's headers"
415 .into(),
416 );
417 }
418 crate::notice::fill_warning(doc);
419 }
420 result
421 }
422
423 async fn scrape_exporting_images(
426 &self,
427 source: Source<'_>,
428 opts: &ScrapeOptions,
429 ) -> Result<Document, ApiError> {
430 let mode = match &source {
431 Source::Url(_, mode) => *mode,
432 Source::Upload(_) => RobotsMode::Respect,
433 };
434 let mut inner = opts.clone();
435 if !inner.wants("images") {
436 inner
437 .formats
438 .push(pagesift_api::types::Format::new("images"));
439 }
440 inner.max_age = Some(0);
441 inner.store_in_cache = false;
442 let mut result = self.scrape_document(source, &inner).await;
443 if let Ok(doc) = &mut result {
444 self.export_web_images(doc, mode).await;
445 if !opts.wants("images") {
446 doc.images = None;
447 }
448 crate::notice::fill_warning(doc);
449 }
450 result
451 }
452
453 pub(super) async fn scrape_content(
455 &self,
456 source: Source<'_>,
457 opts: &ScrapeOptions,
458 ) -> Result<Document, ApiError> {
459 let wants_llm = opts.wants("summary") || opts.wants("json");
460 let tracking = opts
461 .formats
462 .iter()
463 .find(|f| f.kind == "changeTracking")
464 .map(crate::change::Tracking::parse)
465 .transpose()?;
466 if !wants_llm
467 && tracking.is_none()
468 && opts.query_filter.is_none()
469 && opts.chunking.is_none()
470 {
471 return self.base_document(source, opts).await;
472 }
473 let url = match &source {
474 Source::Url(url, _) => Some(*url),
475 Source::Upload(_) => None,
476 };
477 if tracking.is_some() && url.is_none() {
478 return Err(ApiError::unsupported(
479 "changeTracking needs a URL: it is not available for uploaded files",
480 ));
481 }
482 if (wants_llm || tracking.as_ref().is_some_and(|t| t.json)) && self.llm.is_none() {
483 return Err(ApiError::unsupported(
484 "summary and json formats (and the json mode of changeTracking) need an LLM: set [llm] in pagesift.toml",
485 ));
486 }
487 if tracking.is_some() && self.store.is_none() {
488 return Err(ApiError::unsupported(
489 "changeTracking keeps history in the store: enable [cache] in pagesift.toml",
490 ));
491 }
492 let mut inner = opts.clone();
494 inner
495 .formats
496 .retain(|f| f.kind != "summary" && f.kind != "json");
497 let wants_md = inner.wants("markdown");
498 if !wants_md {
499 inner
500 .formats
501 .push(pagesift_api::types::Format::new("markdown"));
502 }
503 if tracking.is_some() && inner.max_age.is_none() {
505 inner.max_age = Some(0);
506 }
507 let mut doc = self.base_document(source, &inner).await?;
508 if let Some(q) = &opts.query_filter {
509 apply_query_filter(&mut doc, q).await?;
510 }
511 if let Some(c) = &opts.chunking {
512 doc.chunks = Some(chunk_markdown(
513 doc.markdown.as_deref().unwrap_or_default(),
514 c,
515 )?);
516 }
517 let md = doc.markdown.clone().unwrap_or_default();
518 if let Some(llm) = self.llm.as_ref().filter(|_| wants_llm) {
519 if opts.wants("summary") {
520 let summary = pagesift_llm::summarize(llm, &md, &Default::default())
521 .await
522 .map_err(llm_error)?;
523 doc.summary = Some(summary);
524 }
525 if let Some(f) = opts.formats.iter().find(|f| f.kind == "json") {
526 let schema = f
527 .options
528 .get("schema")
529 .cloned()
530 .unwrap_or_else(|| serde_json::json!({ "type": "object" }));
531 let eo = pagesift_llm::ExtractOptions {
532 prompt: f
533 .options
534 .get("prompt")
535 .and_then(|p| p.as_str())
536 .map(String::from),
537 };
538 doc.json = Some(
539 pagesift_llm::extract_json(llm, &md, &schema, &eo)
540 .await
541 .map_err(llm_error)?,
542 );
543 }
544 }
545 if let (Some(t), Some(store), Some(url)) = (&tracking, &self.store, url) {
546 self.track_changes(store, t, url, &mut doc).await?;
547 }
548 if !wants_md {
549 doc.markdown = None;
550 }
551 Ok(doc)
552 }
553
554 async fn base_document(
555 &self,
556 source: Source<'_>,
557 opts: &ScrapeOptions,
558 ) -> Result<Document, ApiError> {
559 match source {
560 Source::Url(url, mode) => self.scrape_cached(url, opts, mode).await,
561 Source::Upload(upload) => self.parse_upload(upload, opts).await,
562 }
563 }
564
565 pub(super) async fn track_changes(
567 &self,
568 store: &pagesift_store::Store,
569 t: &crate::change::Tracking,
570 url: &str,
571 doc: &mut Document,
572 ) -> Result<(), ApiError> {
573 use crate::change::{Current, Previous};
574 let ext = doc.metadata.pagesift.get_or_insert_with(Default::default);
575 let current = if ext.not_modified {
576 Current::NotModified
577 } else if matches!(doc.metadata.status_code, 404 | 410) {
578 Current::Gone
579 } else {
580 Current::Page(doc.markdown.as_deref().unwrap_or_default())
581 };
582 let mut outcome = crate::change::track(store, t, url, current).await?;
583 ext.warnings.append(&mut outcome.warnings);
584 if let (true, "changed", Previous::Page(prev), Some(llm)) = (
585 t.json,
586 outcome.tracking.change_status.as_str(),
587 &outcome.previous,
588 &self.llm,
589 ) {
590 let schema = t
591 .schema
592 .clone()
593 .unwrap_or_else(|| serde_json::json!({ "type": "object" }));
594 let eo = pagesift_llm::ExtractOptions {
595 prompt: t.prompt.clone(),
596 };
597 let cur = doc.markdown.as_deref().unwrap_or_default();
598 let (before, after) = futures_util::future::try_join(
599 pagesift_llm::extract_json(llm, prev, &schema, &eo),
600 pagesift_llm::extract_json(llm, cur, &schema, &eo),
601 )
602 .await
603 .map_err(llm_error)?;
604 outcome.tracking.json = Some(crate::change::json_changes(&before, &after));
605 }
606 doc.change_tracking = Some(outcome.tracking);
607 Ok(())
608 }
609}
610
611fn browser_allow_list(ssrf: &pagesift_net::SsrfPolicy) -> Result<NetworkAllowList, ScraperError> {
613 let mut allow = NetworkAllowList::default();
614 for host in &ssrf.allow_hosts {
615 allow.allow_host(host.clone());
616 }
617 for ip in &ssrf.allow_ips {
618 allow.allow_ip(*ip);
619 }
620 for cidr in &ssrf.allow_cidrs {
621 let (net, prefix) = match cidr {
622 pagesift_net::IpCidr::V4 { net, prefix } => (std::net::IpAddr::V4(*net), *prefix),
623 pagesift_net::IpCidr::V6 { net, prefix } => (std::net::IpAddr::V6(*net), *prefix),
624 };
625 allow
626 .allow_cidr_parts(net, prefix)
627 .map_err(|e| ScraperError::Fetcher(pagesift_net::Error::Config(e)))?;
628 }
629 Ok(allow)
630}