agent_first_http/sdk/fetch/artifacts/
observation.rs1use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::sdk::fetch::writer;
13use crate::shared::artifacts::{Artifact, ArtifactPaths};
14use crate::shared::error::{Error, ErrorCode};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Observation {
18 pub schema_version: u32,
19 pub url: String,
20 pub title: String,
21 pub viewport: Viewport,
22 pub frames: Vec<Frame>,
23 pub nodes: Vec<Node>,
24 #[serde(default)]
25 pub forms: Vec<Form>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub focused_ref: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub truncated: Option<ObservationTruncation>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Viewport {
34 pub width: u32,
35 pub height: u32,
36 pub device_scale_factor: f32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Frame {
41 pub frame_id: String,
42 pub url: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Node {
47 pub r#ref: String,
49 pub frame_id: String,
50 pub role: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub name: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub text: Option<String>,
55 pub visible: bool,
56 pub enabled: bool,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub bbox: Option<BBox>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub actions: Vec<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub href: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub src: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub frame_ref: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub input_type: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub checked: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub selected: Option<bool>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub focused: Option<bool>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub value_redacted: Option<bool>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub selector_hint: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub selector_hint_unique: Option<bool>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct BBox {
85 pub x: f32,
86 pub y: f32,
87 pub width: f32,
88 pub height: f32,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Form {
93 pub r#ref: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub action: Option<String>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub field_refs: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ObservationTruncation {
102 pub reason: String,
103 pub node_limit: usize,
104 pub scan_limit: usize,
105 pub scanned: usize,
106 pub emitted_nodes: usize,
107}
108
109pub async fn capture(
113 conn: &crate::sdk::cdp::ws_client::Connection,
114 session_id: &str,
115 url: &str,
116) -> Result<Observation, Error> {
117 let meta = conn
119 .send(
120 "Runtime.evaluate",
121 &serde_json::json!({
122 "expression": "JSON.stringify({title: document.title, w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio})",
123 "returnByValue": true,
124 }),
125 Some(session_id),
126 )
127 .await?;
128 let title;
129 let viewport;
130 if let Some(s) = meta["result"]["value"].as_str() {
131 let v: serde_json::Value = serde_json::from_str(s).unwrap_or(serde_json::Value::Null);
132 title = v["title"].as_str().unwrap_or("").to_string();
133 viewport = Viewport {
134 width: v["w"].as_u64().unwrap_or(0) as u32,
135 height: v["h"].as_u64().unwrap_or(0) as u32,
136 device_scale_factor: v["dpr"].as_f64().unwrap_or(1.0) as f32,
137 };
138 } else {
139 title = String::new();
140 viewport = Viewport {
141 width: 0,
142 height: 0,
143 device_scale_factor: 1.0,
144 };
145 }
146
147 let dom = conn
150 .send(
151 "Runtime.evaluate",
152 &serde_json::json!({
153 "expression": OBSERVATION_JS,
154 "returnByValue": true,
155 }),
156 Some(session_id),
157 )
158 .await?;
159 let mut nodes: Vec<Node> = Vec::new();
160 let mut forms: Vec<Form> = Vec::new();
161 let mut frames: Vec<Frame> = vec![Frame {
162 frame_id: "main".into(),
163 url: url.to_string(),
164 }];
165 let mut focused_ref: Option<String> = None;
166 let mut truncated: Option<ObservationTruncation> = None;
167 if let Some(s) = dom["result"]["value"].as_str() {
168 let v: serde_json::Value = serde_json::from_str(s).unwrap_or(serde_json::Value::Null);
169 if let Ok(parsed_nodes) =
170 serde_json::from_value::<Vec<Node>>(v.get("nodes").cloned().unwrap_or_default())
171 {
172 nodes = parsed_nodes;
173 }
174 if let Ok(parsed_forms) =
175 serde_json::from_value::<Vec<Form>>(v.get("forms").cloned().unwrap_or_default())
176 {
177 forms = parsed_forms;
178 }
179 if let Ok(parsed_frames) =
180 serde_json::from_value::<Vec<Frame>>(v.get("frames").cloned().unwrap_or_default())
181 {
182 if !parsed_frames.is_empty() {
183 frames = parsed_frames;
184 }
185 }
186 focused_ref = v
187 .get("focused_ref")
188 .and_then(|v| v.as_str())
189 .map(str::to_string);
190 if let Ok(parsed_truncation) = serde_json::from_value::<ObservationTruncation>(
191 v.get("truncated").cloned().unwrap_or_default(),
192 ) {
193 truncated = Some(parsed_truncation);
194 }
195 }
196
197 Ok(Observation {
198 schema_version: 1,
199 url: url.to_string(),
200 title,
201 viewport,
202 frames,
203 nodes,
204 forms,
205 focused_ref,
206 truncated,
207 })
208}
209
210const OBSERVATION_JS: &str = include_str!("../../../../assets/observation/snapshot.js");
211
212pub async fn write(paths: &ArtifactPaths, obs: &Observation) -> Result<PathBuf, Error> {
213 let target = paths.file_for(Artifact::Observation);
214 let bytes = serde_json::to_vec_pretty(obs).map_err(|e| {
215 Error::new(
216 ErrorCode::InternalError,
217 format!("serialize observation: {e}"),
218 )
219 })?;
220 writer::write_bytes(&target, &bytes).await?;
221 Ok(target)
222}
223
224pub const DISALLOWED_LABELS: &[&str] =
228 &["login", "captcha", "paywall", "important", "best", "likely"];
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn schema_round_trips() {
236 let obs = Observation {
237 schema_version: 1,
238 url: "https://example.com/".into(),
239 title: "Example".into(),
240 viewport: Viewport {
241 width: 1280,
242 height: 720,
243 device_scale_factor: 1.0,
244 },
245 frames: vec![Frame {
246 frame_id: "main".into(),
247 url: "https://example.com/".into(),
248 }],
249 nodes: vec![Node {
250 r#ref: "obs-1".into(),
251 frame_id: "main".into(),
252 role: "button".into(),
253 name: Some("Submit".into()),
254 text: Some("Submit".into()),
255 visible: true,
256 enabled: true,
257 bbox: Some(BBox {
258 x: 0.0,
259 y: 0.0,
260 width: 80.0,
261 height: 32.0,
262 }),
263 actions: vec!["click".into()],
264 href: None,
265 src: None,
266 frame_ref: None,
267 input_type: None,
268 checked: None,
269 selected: None,
270 focused: None,
271 value_redacted: None,
272 selector_hint: None,
273 selector_hint_unique: None,
274 }],
275 forms: vec![],
276 focused_ref: None,
277 truncated: None,
278 };
279 let json = serde_json::to_string(&obs).unwrap_or_default();
280 let parsed: Observation = serde_json::from_str(&json).unwrap();
281 assert_eq!(parsed.nodes.len(), 1);
282 assert_eq!(parsed.nodes[0].role, "button");
283 }
284
285 #[test]
286 fn disallowed_labels_present_in_constant() {
287 for label in DISALLOWED_LABELS {
288 assert!(!label.is_empty());
289 }
290 }
291}