1use std::collections::BTreeSet;
11use std::sync::Mutex;
12
13use pagesift_api::types::{
14 InteractRequest, InteractResponse, InteractStopped, SESSION_REPLAYED, session_gone,
15};
16use pagesift_cdp::KeptPage;
17use serde_json::Value;
18
19use super::*;
20
21mod act;
22mod plan;
23mod registry;
24
25pub use registry::InteractLimits;
26pub(crate) use registry::Sessions;
27use registry::{OpenPage, PageState, Record};
28
29tokio::task_local! {
30 pub(super) static KEEP: Arc<KeepSlot>;
34}
35
36#[derive(Default)]
37pub(super) struct KeepSlot(Mutex<Option<(KeptPage, Arc<BrowserPool>)>>);
38
39impl KeepSlot {
40 pub(super) fn put(&self, page: KeptPage, pool: Arc<BrowserPool>) {
42 *self.0.lock().unwrap_or_else(|e| e.into_inner()) = Some((page, pool));
43 }
44
45 fn take(&self) -> Option<(KeptPage, Arc<BrowserPool>)> {
46 self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
47 }
48}
49
50pub(super) fn interact_capable(cfg: &PoolConfig) -> bool {
52 cfg.engine.unwrap_or(pagesift_cdp::EngineKind::Chrome) == pagesift_cdp::EngineKind::Chrome
53}
54
55pub(super) fn record_budget(chain: &[PoolConfig]) -> usize {
57 let mb = chain
58 .iter()
59 .map(|c| {
60 let f = c
61 .engine
62 .unwrap_or(pagesift_cdp::EngineKind::Chrome)
63 .footprint();
64 f.browser_mb + f.tab_mb
65 })
66 .max()
67 .unwrap_or(0) as usize;
68 (mb * 1024 * 1024).min(MAX_RECORD_BYTES)
69}
70
71const MAX_RECORD_BYTES: usize = 16 * 1024 * 1024;
73
74fn redact(text: &str, secrets: &[(&str, &str)]) -> String {
76 let mut out = text.to_string();
77 for (name, value) in secrets {
78 if value.is_empty() {
79 continue;
80 }
81 let holder = format!("{{{{secret:{name}}}}}");
82 let encoded: String = url::form_urlencoded::byte_serialize(value.as_bytes()).collect();
83 let escaped = value
84 .replace('&', "&")
85 .replace('<', "<")
86 .replace('>', ">")
87 .replace('"', """);
88 for form in [value, &encoded.as_str(), &escaped.as_str()] {
89 if out.contains(*form) {
90 out = out.replace(*form, &holder);
91 }
92 }
93 }
94 out
95}
96
97fn redact_json(v: &mut Value, secrets: &[(&str, &str)]) {
98 match v {
99 Value::String(s) => *s = redact(s, secrets),
100 Value::Array(a) => a.iter_mut().for_each(|x| redact_json(x, secrets)),
101 Value::Object(m) => m.values_mut().for_each(|x| redact_json(x, secrets)),
102 _ => {}
103 }
104}
105
106fn has_secret(action: &Value) -> bool {
107 action
108 .get("text")
109 .and_then(Value::as_str)
110 .is_some_and(|t| t.contains("{{secret:"))
111}
112
113fn is_script(action: &Value) -> bool {
114 action.get("type").and_then(Value::as_str) == Some("executeJavascript")
115}
116
117fn secret_names(text: &str) -> impl Iterator<Item = &str> {
119 text.split("{{secret:")
120 .skip(1)
121 .filter_map(|rest| rest.split_once("}}").map(|(name, _)| name))
122}
123
124impl Scraper {
125 pub(super) fn interact_available(&self) -> bool {
127 self.browsers.iter().any(|(c, _)| interact_capable(c))
128 }
129
130 fn interact_unavailable(&self) -> ApiError {
131 ApiError::unsupported(
132 "interact needs Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)",
133 )
134 }
135
136 async fn interact_pool(&self) -> Result<Arc<BrowserPool>, ApiError> {
138 for i in 0..self.browsers.len() {
139 if interact_capable(&self.browsers[i].0)
140 && let Some(pool) = self.browser(i).await
141 {
142 return Ok(pool);
143 }
144 }
145 Err(self.interact_unavailable())
146 }
147
148 async fn admit_page(&self) -> Result<registry::Reserved, ApiError> {
150 let pool = self.interact_pool().await?;
151 let cap = self.sessions.sweep(Some(pool.session_room()), 1).await;
152 self.sessions.reserve(cap).map_err(|open| {
153 ApiError::new(
154 ErrorCode::ConcurrencyLimitExceeded,
155 format!(
156 "cannot keep another browser session: {open} are open and in use, and this machine's memory and browser tabs admit {cap}"
157 ),
158 )
159 })
160 }
161
162 async fn open_page(
164 &self,
165 url: &str,
166 opts: &ScrapeOptions,
167 mode: RobotsMode,
168 ) -> Result<(Document, KeptPage, Arc<BrowserPool>), ApiError> {
169 let mut inner = opts.clone();
170 inner.engine = Some("browser".into());
171 inner.max_age = Some(0);
173 inner.store_in_cache = false;
174 let slot = Arc::new(KeepSlot::default());
175 let doc = KEEP
176 .scope(slot.clone(), self.scrape_with(url, &inner, mode))
177 .await?;
178 let (page, pool) = slot.take().ok_or_else(|| {
179 ApiError::unsupported(
180 "this page was answered without a browser (a rule chose the engine, or the content is not a web page), so there is no session to keep",
181 )
182 })?;
183 Ok((doc, page, pool))
184 }
185
186 fn opened(
187 &self,
188 page: KeptPage,
189 pool: Arc<BrowserPool>,
190 doc: &Document,
191 url: &str,
192 opts: &ScrapeOptions,
193 ) -> Box<OpenPage> {
194 self.sessions.start_reaper();
195 let now = Instant::now();
196 Box::new(OpenPage {
197 page,
198 pool,
199 opened: now,
200 last_used: now,
201 url: doc.metadata.url.clone().unwrap_or_else(|| url.to_string()),
202 secrets: BTreeSet::new(),
203 scripted: opts.actions.iter().any(is_script),
204 })
205 }
206
207 pub async fn scrape_session(
209 &self,
210 url: &str,
211 opts: &ScrapeOptions,
212 keep: bool,
213 owner: Option<&str>,
214 ) -> Result<Document, ApiError> {
215 if !self.interact_available() {
216 if keep {
217 return Err(self.interact_unavailable());
218 }
219 return self.scrape(url, opts).await;
220 }
221 let mode = self
222 .policy
223 .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
224 .map_err(policy_error)?;
225 if !keep {
226 let mut doc = self.scrape_with(url, opts, mode).await?;
227 let id = self
228 .sessions
229 .insert(None, owner, url, opts, mode, PageState::NotOpened);
230 doc.metadata.scrape_id = Some(id);
231 return Ok(doc);
232 }
233 if opts.engine.as_deref() == Some("http") {
234 return Err(ApiError::invalid(
235 "keepSession keeps a browser page and cannot be combined with engine \"http\"",
236 ));
237 }
238 let _reserved = self.admit_page().await?;
239 let (mut doc, page, pool) = self.open_page(url, opts, mode).await?;
240 let open = self.opened(page, pool, &doc, url, opts);
241 let id = self
242 .sessions
243 .insert(None, owner, url, opts, mode, PageState::Open(open));
244 doc.metadata.scrape_id = Some(id);
245 Ok(doc)
246 }
247
248 fn record_for(
250 &self,
251 id: &str,
252 req: &InteractRequest,
253 owner: Option<&str>,
254 ) -> Result<Arc<Record>, ApiError> {
255 match (self.sessions.get(id, owner), req.url.as_deref()) {
256 (Ok(r), _) => Ok(r),
257 (Err(e), Some(url))
258 if matches!(e.code, ErrorCode::SessionGone | ErrorCode::NotFound)
259 && pagesift_api::types::is_scrape_id(id) =>
260 {
261 let opts = ScrapeOptions::default();
262 let mode = self
263 .policy
264 .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
265 .map_err(policy_error)?;
266 self.sessions
267 .insert(Some(id), owner, url, &opts, mode, PageState::NotOpened);
268 self.sessions.get(id, owner)
269 }
270 (Err(e), _) => Err(e),
271 }
272 }
273
274 pub async fn interact(
275 &self,
276 id: &str,
277 req: &InteractRequest,
278 owner: Option<&str>,
279 ) -> Result<InteractResponse, ApiError> {
280 req.check()?;
281 if !self.interact_available() {
282 return Err(self.interact_unavailable());
283 }
284 if req.prompt.is_some() && self.llm.is_none() {
285 return Err(ApiError::unsupported(
286 "prompt needs an LLM: set [llm] in pagesift.toml, or send `actions`",
287 ));
288 }
289 let actions = req.actions.clone().unwrap_or_default();
290 let record = self.record_for(id, req, owner)?;
291 let mut resp = InteractResponse::default();
292 let limit = req.time_limit();
293 let deadline = tokio::time::Instant::now() + limit;
294 let timed_out = |resp: &mut InteractResponse| {
295 resp.killed = true;
296 resp.fail(format!(
297 "the call did not finish within {} s; the session is still open",
298 limit.as_secs_f64()
299 ));
300 };
301 let Ok(mut state) = tokio::time::timeout_at(deadline, record.state.lock()).await else {
303 timed_out(&mut resp);
304 return Ok(resp);
305 };
306 if let PageState::Gone(why) = &*state {
307 return Err(session_gone(format!(
308 "the browser session of scrape {id} has ended: {why}"
309 )));
310 }
311 if matches!(&*state, PageState::NotOpened) {
312 let opts = replay_options(&record.opts, limit);
313 let opening = async {
314 let reserved = self.admit_page().await?;
315 let opened = self.open_page(&record.url, &opts, record.mode).await?;
316 Ok::<_, ApiError>((reserved, opened))
317 };
318 let (_reserved, (doc, page, pool)) =
319 match tokio::time::timeout_at(deadline, opening).await {
320 Ok(r) => r?,
321 Err(_) => {
322 timed_out(&mut resp);
323 return Ok(resp);
324 }
325 };
326 *state = PageState::Open(self.opened(page, pool, &doc, &record.url, &opts));
327 self.sessions.mark_open(id, &record);
328 resp.warnings.push(format!(
329 "{SESSION_REPLAYED}: the scrape did not keep its page (keepSession), so {} was opened again; the page may differ from the scrape result",
330 record.url
331 ));
332 }
333 let PageState::Open(open) = &mut *state else {
334 return Err(session_gone(format!(
335 "the browser session of scrape {id} has ended"
336 )));
337 };
338 let run = async {
339 match &req.prompt {
340 Some(prompt) => self.run_prompt(&record, open, prompt, &mut resp).await,
341 None => self.run_actions(&record, open, actions, &mut resp).await,
342 }
343 };
344 let outcome = tokio::time::timeout_at(deadline, run).await;
345 open.last_used = Instant::now();
346 let alive = open.page.is_alive();
347 let redacting: Vec<(String, String)> = open
348 .secrets
349 .iter()
350 .filter_map(|n| self.secrets.get(n).map(|v| (n.clone(), v.value.clone())))
351 .collect();
352 let outcome = outcome.map(|r| {
354 r.map_err(|mut e| {
355 let pairs: Vec<(&str, &str)> = redacting
356 .iter()
357 .map(|(n, v)| (n.as_str(), v.as_str()))
358 .collect();
359 e.message = redact(&e.message, &pairs);
360 e
361 })
362 });
363 match outcome {
364 Err(_) => timed_out(&mut resp),
365 Ok(Ok(())) => {}
366 Ok(Err(e)) => {
367 let closes = e.code == ErrorCode::RobotsDisallowed || !alive;
368 if closes {
369 let why = if alive {
370 "an action navigated to a page robots.txt disallows".to_string()
371 } else {
372 "the browser exited".to_string()
373 };
374 let old = std::mem::replace(&mut *state, PageState::Gone(why.clone()));
375 drop(state);
376 if let PageState::Open(o) = old {
377 o.page.close().await;
378 }
379 self.sessions.mark_closed(id);
380 return Err(if alive {
381 e
382 } else {
383 session_gone(format!(
384 "the browser session of scrape {id} has ended: {why}"
385 ))
386 });
387 }
388 return Err(e);
389 }
390 }
391 drop(state);
392 finish_response(resp, &redacting)
393 }
394
395 pub async fn stop_interact(
396 &self,
397 id: &str,
398 owner: Option<&str>,
399 ) -> Result<InteractStopped, ApiError> {
400 let not_found = |detail: &str| {
401 ApiError::new(
402 ErrorCode::NotFound,
403 format!("no browser session for scrape {id}: {detail}"),
404 )
405 };
406 let record = self.sessions.get(id, owner).map_err(|e| match e.code {
407 ErrorCode::SessionGone => ApiError::new(ErrorCode::NotFound, e.message),
409 _ => e,
410 })?;
411 let mut state = record.state.lock().await;
412 match &*state {
413 PageState::NotOpened => return Err(not_found("no session was started")),
414 PageState::Gone(why) => return Err(not_found(&format!("it has ended: {why}"))),
415 PageState::Open(_) => {}
416 }
417 let old = std::mem::replace(&mut *state, PageState::Gone("it was stopped".into()));
418 drop(state);
419 let PageState::Open(open) = old else {
420 return Err(not_found("no session was started"));
421 };
422 let held = open.opened.elapsed();
423 open.page.close().await;
424 self.sessions.mark_closed(id);
425 Ok(InteractStopped {
426 success: true,
427 session_duration_ms: held.as_millis() as u64,
428 credits_billed: 0,
429 })
430 }
431
432 async fn polite_permit(
434 &self,
435 record: &Record,
436 current: &str,
437 ) -> Result<pagesift_policy::Permit, ApiError> {
438 let target = Url::parse(current).map_err(|e| ApiError::invalid(e.to_string()))?;
439 let delay = match record.mode {
440 RobotsMode::Respect => self
441 .policy
442 .check_robots(&target)
443 .await
444 .map_err(policy_error)?,
445 RobotsMode::Ignore => None,
446 };
447 let host = target.host_str().unwrap_or_default().to_string();
448 Ok(self.policy.limiter().acquire(&host, delay).await)
449 }
450
451 async fn run_actions(
452 &self,
453 record: &Record,
454 open: &mut OpenPage,
455 mut actions: Vec<Value>,
456 resp: &mut InteractResponse,
457 ) -> Result<(), ApiError> {
458 pagesift_cdp::parse_actions(&Value::Array(actions.clone()))
460 .map_err(|e| ApiError::invalid(e.to_string()))?;
461 let filling = actions.iter().any(has_secret);
462 let scripting = actions.iter().any(is_script);
463 if scripting && (filling || !open.secrets.is_empty()) || filling && open.scripted {
465 return Err(ApiError::invalid(
466 "a session cannot both run executeJavascript and use {{secret:...}}",
467 ));
468 }
469 open.scripted |= scripting;
470 for a in &actions {
471 if let Some(text) = a.get("text").and_then(Value::as_str) {
472 open.secrets.extend(secret_names(text).map(str::to_string));
473 }
474 }
475 let permit = self.polite_permit(record, &open.url.clone()).await?;
476 let started = Instant::now();
477 let mut out = pagesift_cdp::ActionOutputs::default();
478 let mut failed = None;
479 let mut rest = actions.as_mut_slice();
480 while !rest.is_empty() && failed.is_none() {
481 let n = if has_secret(&rest[0]) {
483 1
484 } else {
485 rest.iter().position(has_secret).unwrap_or(rest.len())
486 };
487 let (chunk, tail) = std::mem::take(&mut rest).split_at_mut(n);
488 rest = tail;
489 if has_secret(&chunk[0]) {
490 match open.page.capture().await {
491 Ok(page) => {
492 super::autoschema::fill_secrets(chunk, &self.secrets, &page.final_url)?
493 }
494 Err(e) => {
495 failed = Some(e);
496 break;
497 }
498 }
499 }
500 let parsed = pagesift_cdp::parse_actions(&Value::Array(chunk.to_vec()))
501 .map_err(|e| ApiError::invalid(e.to_string()))?;
502 match open.page.run_actions(&parsed).await {
503 Ok(o) => {
504 out.screenshots.extend(o.screenshots);
505 out.scrapes.extend(o.scrapes);
506 out.javascript_returns.extend(o.javascript_returns);
507 out.pdfs.extend(o.pdfs);
508 }
509 Err(e) => failed = Some(e),
510 }
511 }
512 match failed {
513 None => {
514 resp.result = out.javascript_returns.last().map(|j| j.value.to_string());
515 resp.actions = Some(out.to_firecrawl_json());
516 }
517 Some(e) => action_failure(e, resp)?,
518 }
519 drop(permit);
520 self.fill_page(record, open, resp, started).await
521 }
522
523 async fn fill_page(
525 &self,
526 record: &Record,
527 open: &mut OpenPage,
528 resp: &mut InteractResponse,
529 started: Instant,
530 ) -> Result<(), ApiError> {
531 let mut page = match open.page.capture().await {
532 Ok(p) => p,
533 Err(e) => {
534 if open.page.is_alive() {
536 if resp.error.is_none() {
537 resp.fail(format!("could not read the page after the actions: {e}"));
538 }
539 return Ok(());
540 }
541 return Err(ApiError::new(render_error_code(&e), e.to_string()));
542 }
543 };
544 if page.final_url != open.url {
545 if record.mode == RobotsMode::Respect
546 && let Ok(u) = Url::parse(&page.final_url)
547 {
548 self.policy.check_robots(&u).await.map_err(policy_error)?;
549 }
550 open.url = page.final_url.clone();
551 }
552 let secrets = self.named_secrets(&open.secrets);
553 if !secrets.is_empty() {
554 page.html = redact(&page.html, &secrets);
556 }
557 let opts = page_options(&record.opts);
558 let doc = self
559 .live_document(page, &record.url, &opts, Vec::new(), started)
560 .await?;
561 resp.data = Some(doc);
562 Ok(())
563 }
564
565 fn named_secrets<'a>(&'a self, names: &'a BTreeSet<String>) -> Vec<(&'a str, &'a str)> {
566 names
567 .iter()
568 .filter_map(|n| self.secrets.get(n).map(|v| (n.as_str(), v.value.as_str())))
569 .collect()
570 }
571}
572
573fn action_failure(e: pagesift_cdp::Error, resp: &mut InteractResponse) -> Result<(), ApiError> {
575 use pagesift_cdp::Error as E;
576 match e {
577 E::Action { .. } | E::Protocol { .. } => {
578 resp.fail(e.to_string());
579 Ok(())
580 }
581 E::InvalidOptions(m) => Err(ApiError::invalid(m)),
582 E::ExecuteJavascriptDisabled => Err(ApiError::unsupported(e.to_string())),
583 other => Err(ApiError::new(render_error_code(&other), other.to_string())),
584 }
585}
586
587fn replay_options(opts: &ScrapeOptions, limit: Duration) -> ScrapeOptions {
589 let mut o = opts.clone();
590 let ms = limit.as_millis() as u64;
591 o.timeout = Some(o.timeout.map_or(ms, |t| t.min(ms)));
592 o
593}
594
595fn page_options(opts: &ScrapeOptions) -> ScrapeOptions {
597 let mut o = opts.clone();
598 o.formats.retain(|f| {
599 matches!(
600 f.kind.as_str(),
601 "markdown" | "html" | "rawHtml" | "links" | "images"
602 )
603 });
604 if o.formats.is_empty() {
605 o.formats.push(pagesift_api::types::Format::new("markdown"));
606 }
607 o
608}
609
610fn finish_response(
612 resp: InteractResponse,
613 secrets: &[(String, String)],
614) -> Result<InteractResponse, ApiError> {
615 if secrets.is_empty() {
616 return Ok(resp);
617 }
618 let pairs: Vec<(&str, &str)> = secrets
619 .iter()
620 .map(|(n, v)| (n.as_str(), v.as_str()))
621 .collect();
622 let mut v = serde_json::to_value(&resp)
623 .map_err(|e| ApiError::new(ErrorCode::Internal, e.to_string()))?;
624 redact_json(&mut v, &pairs);
625 serde_json::from_value(v).map_err(|e| ApiError::new(ErrorCode::Internal, e.to_string()))
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn secrets_are_named_and_redacted() {
634 let names: Vec<&str> = secret_names("log in as {{secret:USER}} / {{secret:PW}}").collect();
635 assert_eq!(names, ["USER", "PW"]);
636 assert_eq!(secret_names("nothing here").count(), 0);
637 let pairs = [("PW", "hunter2"), ("EMPTY", "")];
638 assert_eq!(redact("value=hunter2;", &pairs), "value={{secret:PW}};");
639 let mut v = serde_json::json!({"a": ["hunter2"], "b": {"c": "x hunter2"}, "n": 1});
640 redact_json(&mut v, &pairs);
641 assert_eq!(
642 v,
643 serde_json::json!({"a": ["{{secret:PW}}"], "b": {"c": "x {{secret:PW}}"}, "n": 1})
644 );
645 }
646
647 #[test]
648 fn page_options_drop_llm_formats() {
649 let o: ScrapeOptions =
650 serde_json::from_value(serde_json::json!({"formats": ["summary", "links"]})).unwrap();
651 let kinds: Vec<_> = page_options(&o)
652 .formats
653 .iter()
654 .map(|f| f.kind.clone())
655 .collect();
656 assert_eq!(kinds, ["links"]);
657 let o: ScrapeOptions =
658 serde_json::from_value(serde_json::json!({"formats": ["summary"]})).unwrap();
659 assert!(page_options(&o).wants("markdown"));
660 }
661}