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