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