Skip to main content

pagesift_api/types/
interact.rs

1//! `POST /v2/scrape/{id}/interact` 和 `DELETE /v2/scrape/{id}/interact` 的请求和响应,字段照 Firecrawl v2。
2//!
3//! 和 Firecrawl 的差异:`code` / `language`(Playwright 代码)不支持,改用 `actions`(和 scrape 的动作列表相同);
4//! `cdpUrl`、`liveViewUrl`、`interactiveLiveViewUrl` 永远是 `null`,`warnings` 里写明。
5
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::Document;
12use crate::error::{ApiError, ErrorCode};
13
14/// [协议] Firecrawl API reference:`prompt` 1–10,000 字符
15pub const MAX_PROMPT_CHARS: usize = 10_000;
16/// [协议] Firecrawl API reference:`timeout` 默认 30 秒
17pub const DEFAULT_INTERACT_TIMEOUT_SECS: f64 = 30.0;
18/// [协议] Firecrawl API reference:`timeout` 上限 300 秒
19pub const MAX_INTERACT_TIMEOUT_SECS: f64 = 300.0;
20/// 警告代码:没有实时画面和 CDP 地址。
21pub const LIVE_VIEW_UNAVAILABLE: &str = "live_view_unavailable";
22/// 警告代码:页面没有保留到现在,这次重新打开了网址。
23pub const SESSION_REPLAYED: &str = "session_replayed";
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct InteractRequest {
28    /// 自然语言任务(需要 `[llm]`)。和 `actions` 二选一。
29    #[serde(default)]
30    pub prompt: Option<String>,
31    /// PageSift extension:和 scrape 相同的动作列表。和 `prompt` 二选一。
32    #[serde(default)]
33    pub actions: Option<Vec<Value>>,
34    /// Firecrawl 的代码执行:不支持,收到时明确报错。
35    #[serde(default)]
36    pub code: Option<String>,
37    #[serde(default)]
38    pub language: Option<String>,
39    /// 时限(秒),1–300,默认 30。
40    #[serde(default)]
41    pub timeout: Option<f64>,
42    /// 原来的抓取已经没有记录时,在这个网址上开新会话。
43    #[serde(default)]
44    pub url: Option<String>,
45}
46
47impl InteractRequest {
48    /// 请求本身的校验(和会话状态无关)。
49    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/// `success: false` 是这次动作没有做完(选择器找不到、超时),会话还在,可以继续调用;
90/// 会话本身不可用(已关闭、已回收、id 不对)走 HTTP 错误。
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct InteractResponse {
94    pub success: bool,
95    /// 永远是 `null`:不开放浏览器的调试端口。
96    pub cdp_url: Option<String>,
97    /// 永远是 `null`:没有实时画面。
98    pub live_view_url: Option<String>,
99    /// 永远是 `null`:没有实时画面。
100    pub interactive_live_view_url: Option<String>,
101    /// 模型对 `prompt` 的回答。
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub output: Option<String>,
104    /// 没有代码执行,永远为空。
105    pub stdout: String,
106    /// 没有代码执行,永远为空。
107    pub stderr: String,
108    /// `prompt`:模型最后看到的页面快照;`actions`:最后一个 `executeJavascript` 的返回值。
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub result: Option<String>,
111    pub exit_code: i32,
112    /// 到时限被中止。
113    pub killed: bool,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub error: Option<String>,
116    /// PageSift extension:动作之后的页面,格式取自原来的抓取。
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub data: Option<Document>,
119    /// PageSift extension:`actions` 的产出(截图、`scrape`、`executeJavascript`、PDF)。
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub actions: Option<Value>,
122    /// PageSift extension:`prompt` 实际执行的步骤(动作、元素的角色和名称;不含输入的文字)。
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub steps: Vec<Value>,
125    /// PageSift extension:`prompt` 调了几次模型、多少 token(`{calls, tokens}`)。
126    #[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    /// 这次调用没有做完:记下原因,会话留着。
159    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    /// pagesift 没有计费,永远是 0。
172    pub credits_billed: u64,
173}
174
175/// 这个进程的标记:scrapeId 的第一段。重启后的旧 id 第一段对不上,能明确告诉调用方原因。
176pub fn instance_tag() -> String {
177    // [协议] UUID 文本形式第一段 8 个十六进制字符(RFC 9562)
178    uuid::Uuid::new_v4().to_string()[..8].to_string()
179}
180
181/// 带进程标记的 scrapeId,仍是合法的 UUID 文本。
182pub fn scrape_id(tag: &str) -> String {
183    // [协议] 同上:第一段 8 个字符
184    format!("{tag}{}", &uuid::Uuid::new_v4().to_string()[8..])
185}
186
187/// 形状是 UUID(scrapeId 都是)。
188pub fn is_scrape_id(id: &str) -> bool {
189    uuid::Uuid::try_parse(id).is_ok()
190}
191
192/// 形状像 UUID,但第一段不是这个进程的标记。
193pub fn issued_elsewhere(id: &str, tag: &str) -> bool {
194    is_scrape_id(id) && !id.starts_with(tag)
195}
196
197/// 会话已经不在了(HTTP 410)。
198pub 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}