use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::Document;
use crate::error::{ApiError, ErrorCode};
pub const MAX_PROMPT_CHARS: usize = 10_000;
pub const DEFAULT_INTERACT_TIMEOUT_SECS: f64 = 30.0;
pub const MAX_INTERACT_TIMEOUT_SECS: f64 = 300.0;
pub const LIVE_VIEW_UNAVAILABLE: &str = "live_view_unavailable";
pub const SESSION_REPLAYED: &str = "session_replayed";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InteractRequest {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub actions: Option<Vec<Value>>,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub timeout: Option<f64>,
#[serde(default)]
pub url: Option<String>,
}
impl InteractRequest {
pub fn check(&self) -> Result<(), ApiError> {
if self.code.is_some() {
return Err(ApiError::unsupported(
"code execution is not supported: pagesift runs no Playwright sandbox; send `actions` or `prompt`",
));
}
match (&self.prompt, &self.actions) {
(Some(_), Some(_)) => {
return Err(ApiError::invalid(
"send either `prompt` or `actions`, not both",
));
}
(None, None) => {
return Err(ApiError::invalid("send `prompt` or `actions`"));
}
(Some(p), None) => {
let n = p.chars().count();
if n == 0 || n > MAX_PROMPT_CHARS {
return Err(ApiError::invalid(format!(
"prompt must be 1 to {MAX_PROMPT_CHARS} characters, got {n}"
)));
}
}
(None, Some(_)) => {}
}
if let Some(t) = self.timeout
&& !(1.0..=MAX_INTERACT_TIMEOUT_SECS).contains(&t)
{
return Err(ApiError::invalid(format!(
"timeout must be 1 to {MAX_INTERACT_TIMEOUT_SECS} seconds, got {t}"
)));
}
Ok(())
}
pub fn time_limit(&self) -> Duration {
Duration::from_secs_f64(self.timeout.unwrap_or(DEFAULT_INTERACT_TIMEOUT_SECS))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InteractResponse {
pub success: bool,
pub cdp_url: Option<String>,
pub live_view_url: Option<String>,
pub interactive_live_view_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
pub stdout: String,
pub stderr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
pub exit_code: i32,
pub killed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Document>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actions: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub llm_usage: Option<Value>,
#[serde(default)]
pub warnings: Vec<String>,
}
impl Default for InteractResponse {
fn default() -> Self {
Self {
success: true,
cdp_url: None,
live_view_url: None,
interactive_live_view_url: None,
output: None,
stdout: String::new(),
stderr: String::new(),
result: None,
exit_code: 0,
killed: false,
error: None,
data: None,
actions: None,
steps: Vec::new(),
llm_usage: None,
warnings: vec![format!(
"{LIVE_VIEW_UNAVAILABLE}: pagesift offers no live view and does not expose the browser's CDP endpoint; cdpUrl, liveViewUrl and interactiveLiveViewUrl are always null"
)],
}
}
}
impl InteractResponse {
pub fn fail(&mut self, error: impl Into<String>) {
self.success = false;
self.exit_code = 1;
self.error = Some(error.into());
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InteractStopped {
pub success: bool,
pub session_duration_ms: u64,
pub credits_billed: u64,
}
pub fn instance_tag() -> String {
uuid::Uuid::new_v4().to_string()[..8].to_string()
}
pub fn scrape_id(tag: &str) -> String {
format!("{tag}{}", &uuid::Uuid::new_v4().to_string()[8..])
}
pub fn is_scrape_id(id: &str) -> bool {
uuid::Uuid::try_parse(id).is_ok()
}
pub fn issued_elsewhere(id: &str, tag: &str) -> bool {
is_scrape_id(id) && !id.starts_with(tag)
}
pub fn session_gone(message: impl Into<String>) -> ApiError {
ApiError::new(ErrorCode::SessionGone, message)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn req(v: Value) -> InteractRequest {
serde_json::from_value(v).unwrap()
}
#[test]
fn request_rules_follow_firecrawl() {
assert!(req(json!({"prompt": "x"})).check().is_ok());
assert!(req(json!({"actions": []})).check().is_ok());
assert_eq!(
req(json!({"code": "1"})).check().unwrap_err().code,
ErrorCode::UnsupportedOption
);
for bad in [
json!({}),
json!({"prompt": "x", "actions": []}),
json!({"prompt": ""}),
json!({"prompt": "x", "timeout": 0.5}),
json!({"prompt": "x", "timeout": 301}),
] {
assert_eq!(
req(bad.clone()).check().unwrap_err().code,
ErrorCode::InvalidRequest,
"{bad}"
);
}
assert_eq!(req(json!({"prompt": "x"})).time_limit().as_secs(), 30);
}
#[test]
fn ids_are_uuids_and_tell_a_previous_process_apart() {
let tag = instance_tag();
let id = scrape_id(&tag);
assert!(uuid::Uuid::try_parse(&id).is_ok(), "{id}");
assert!(!issued_elsewhere(&id, &tag));
assert!(issued_elsewhere(&scrape_id(&instance_tag()), &tag));
assert!(!issued_elsewhere("not-a-uuid", &tag));
}
#[test]
fn the_response_says_there_is_no_live_view() {
let v = serde_json::to_value(InteractResponse::default()).unwrap();
assert_eq!(v["liveViewUrl"], Value::Null);
assert_eq!(v["interactiveLiveViewUrl"], Value::Null);
assert_eq!(v["cdpUrl"], Value::Null);
assert!(
v["warnings"][0]
.as_str()
.unwrap()
.starts_with("live_view_unavailable: ")
);
}
}