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