1use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::Document;
12use crate::error::{ApiError, ErrorCode};
13
14pub const MAX_PROMPT_CHARS: usize = 10_000;
16pub const DEFAULT_INTERACT_TIMEOUT_SECS: f64 = 30.0;
18pub const MAX_INTERACT_TIMEOUT_SECS: f64 = 300.0;
20pub const LIVE_VIEW_UNAVAILABLE: &str = "live_view_unavailable";
22pub const SESSION_REPLAYED: &str = "session_replayed";
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct InteractRequest {
28 #[serde(default)]
30 pub prompt: Option<String>,
31 #[serde(default)]
33 pub actions: Option<Vec<Value>>,
34 #[serde(default)]
36 pub code: Option<String>,
37 #[serde(default)]
38 pub language: Option<String>,
39 #[serde(default)]
41 pub timeout: Option<f64>,
42 #[serde(default)]
44 pub url: Option<String>,
45}
46
47impl InteractRequest {
48 pub fn check(&self) -> Result<(), ApiError> {
50 if self.code.is_some() {
51 return Err(ApiError::unsupported(
52 "code execution is not supported: pagesift runs no Playwright sandbox; send `actions` or `prompt`",
53 ));
54 }
55 match (&self.prompt, &self.actions) {
56 (Some(_), Some(_)) => {
57 return Err(ApiError::invalid(
58 "send either `prompt` or `actions`, not both",
59 ));
60 }
61 (None, None) => {
62 return Err(ApiError::invalid("send `prompt` or `actions`"));
63 }
64 (Some(p), None) => {
65 let n = p.chars().count();
66 if n == 0 || n > MAX_PROMPT_CHARS {
67 return Err(ApiError::invalid(format!(
68 "prompt must be 1 to {MAX_PROMPT_CHARS} characters, got {n}"
69 )));
70 }
71 }
72 (None, Some(_)) => {}
73 }
74 if let Some(t) = self.timeout
75 && !(1.0..=MAX_INTERACT_TIMEOUT_SECS).contains(&t)
76 {
77 return Err(ApiError::invalid(format!(
78 "timeout must be 1 to {MAX_INTERACT_TIMEOUT_SECS} seconds, got {t}"
79 )));
80 }
81 Ok(())
82 }
83
84 pub fn time_limit(&self) -> Duration {
85 Duration::from_secs_f64(self.timeout.unwrap_or(DEFAULT_INTERACT_TIMEOUT_SECS))
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct InteractResponse {
94 pub success: bool,
95 pub cdp_url: Option<String>,
97 pub live_view_url: Option<String>,
99 pub interactive_live_view_url: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub output: Option<String>,
104 pub stdout: String,
106 pub stderr: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub result: Option<String>,
111 pub exit_code: i32,
112 pub killed: bool,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub error: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub data: Option<Document>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub actions: Option<Value>,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub steps: Vec<Value>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub llm_usage: Option<Value>,
128 #[serde(default)]
129 pub warnings: Vec<String>,
130}
131
132impl Default for InteractResponse {
133 fn default() -> Self {
134 Self {
135 success: true,
136 cdp_url: None,
137 live_view_url: None,
138 interactive_live_view_url: None,
139 output: None,
140 stdout: String::new(),
141 stderr: String::new(),
142 result: None,
143 exit_code: 0,
144 killed: false,
145 error: None,
146 data: None,
147 actions: None,
148 steps: Vec::new(),
149 llm_usage: None,
150 warnings: vec![format!(
151 "{LIVE_VIEW_UNAVAILABLE}: pagesift offers no live view and does not expose the browser's CDP endpoint; cdpUrl, liveViewUrl and interactiveLiveViewUrl are always null"
152 )],
153 }
154 }
155}
156
157impl InteractResponse {
158 pub fn fail(&mut self, error: impl Into<String>) {
160 self.success = false;
161 self.exit_code = 1;
162 self.error = Some(error.into());
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct InteractStopped {
169 pub success: bool,
170 pub session_duration_ms: u64,
171 pub credits_billed: u64,
173}
174
175pub fn instance_tag() -> String {
177 uuid::Uuid::new_v4().to_string()[..8].to_string()
179}
180
181pub fn scrape_id(tag: &str) -> String {
183 format!("{tag}{}", &uuid::Uuid::new_v4().to_string()[8..])
185}
186
187pub fn is_scrape_id(id: &str) -> bool {
189 uuid::Uuid::try_parse(id).is_ok()
190}
191
192pub fn issued_elsewhere(id: &str, tag: &str) -> bool {
194 is_scrape_id(id) && !id.starts_with(tag)
195}
196
197pub fn session_gone(message: impl Into<String>) -> ApiError {
199 ApiError::new(ErrorCode::SessionGone, message)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use serde_json::json;
206
207 fn req(v: Value) -> InteractRequest {
208 serde_json::from_value(v).unwrap()
209 }
210
211 #[test]
212 fn request_rules_follow_firecrawl() {
213 assert!(req(json!({"prompt": "x"})).check().is_ok());
214 assert!(req(json!({"actions": []})).check().is_ok());
215 assert_eq!(
216 req(json!({"code": "1"})).check().unwrap_err().code,
217 ErrorCode::UnsupportedOption
218 );
219 for bad in [
220 json!({}),
221 json!({"prompt": "x", "actions": []}),
222 json!({"prompt": ""}),
223 json!({"prompt": "x", "timeout": 0.5}),
224 json!({"prompt": "x", "timeout": 301}),
225 ] {
226 assert_eq!(
227 req(bad.clone()).check().unwrap_err().code,
228 ErrorCode::InvalidRequest,
229 "{bad}"
230 );
231 }
232 assert_eq!(req(json!({"prompt": "x"})).time_limit().as_secs(), 30);
233 }
234
235 #[test]
236 fn ids_are_uuids_and_tell_a_previous_process_apart() {
237 let tag = instance_tag();
238 let id = scrape_id(&tag);
239 assert!(uuid::Uuid::try_parse(&id).is_ok(), "{id}");
240 assert!(!issued_elsewhere(&id, &tag));
241 assert!(issued_elsewhere(&scrape_id(&instance_tag()), &tag));
242 assert!(!issued_elsewhere("not-a-uuid", &tag));
243 }
244
245 #[test]
246 fn the_response_says_there_is_no_live_view() {
247 let v = serde_json::to_value(InteractResponse::default()).unwrap();
248 assert_eq!(v["liveViewUrl"], Value::Null);
249 assert_eq!(v["interactiveLiveViewUrl"], Value::Null);
250 assert_eq!(v["cdpUrl"], Value::Null);
251 assert!(
252 v["warnings"][0]
253 .as_str()
254 .unwrap()
255 .starts_with("live_view_unavailable: ")
256 );
257 }
258}