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 && !parsed_frames.is_empty()
182 {
183 frames = parsed_frames;
184 }
185 focused_ref = v
186 .get("focused_ref")
187 .and_then(|v| v.as_str())
188 .map(str::to_string);
189 if let Ok(parsed_truncation) = serde_json::from_value::<ObservationTruncation>(
190 v.get("truncated").cloned().unwrap_or_default(),
191 ) {
192 truncated = Some(parsed_truncation);
193 }
194 }
195
196 Ok(Observation {
197 schema_version: 1,
198 url: url.to_string(),
199 title,
200 viewport,
201 frames,
202 nodes,
203 forms,
204 focused_ref,
205 truncated,
206 })
207}
208
209const OBSERVATION_JS: &str = include_str!("../../../../assets/observation/snapshot.js");
210
211pub async fn write(paths: &ArtifactPaths, obs: &Observation) -> Result<PathBuf, Error> {
212 let target = paths.file_for(Artifact::Observation);
213 let bytes = serde_json::to_vec_pretty(obs).map_err(|e| {
214 Error::new(
215 ErrorCode::InternalError,
216 format!("serialize observation: {e}"),
217 )
218 })?;
219 writer::write_bytes(&target, &bytes).await?;
220 Ok(target)
221}
222
223pub const DISALLOWED_LABELS: &[&str] =
227 &["login", "captcha", "paywall", "important", "best", "likely"];
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn schema_round_trips() {
235 let obs = Observation {
236 schema_version: 1,
237 url: "https://example.com/".into(),
238 title: "Example".into(),
239 viewport: Viewport {
240 width: 1280,
241 height: 720,
242 device_scale_factor: 1.0,
243 },
244 frames: vec![Frame {
245 frame_id: "main".into(),
246 url: "https://example.com/".into(),
247 }],
248 nodes: vec![Node {
249 r#ref: "obs-1".into(),
250 frame_id: "main".into(),
251 role: "button".into(),
252 name: Some("Submit".into()),
253 text: Some("Submit".into()),
254 visible: true,
255 enabled: true,
256 bbox: Some(BBox {
257 x: 0.0,
258 y: 0.0,
259 width: 80.0,
260 height: 32.0,
261 }),
262 actions: vec!["click".into()],
263 href: None,
264 src: None,
265 frame_ref: None,
266 input_type: None,
267 checked: None,
268 selected: None,
269 focused: None,
270 value_redacted: None,
271 selector_hint: None,
272 selector_hint_unique: None,
273 }],
274 forms: vec![],
275 focused_ref: None,
276 truncated: None,
277 };
278 let json = serde_json::to_string(&obs).unwrap_or_default();
279 let parsed: Observation = serde_json::from_str(&json).unwrap();
280 assert_eq!(parsed.nodes.len(), 1);
281 assert_eq!(parsed.nodes[0].role, "button");
282 }
283
284 #[test]
285 fn disallowed_labels_present_in_constant() {
286 for label in DISALLOWED_LABELS {
287 assert!(!label.is_empty());
288 }
289 }
290}