headless_engine/dom/
interactive.rs1use scraper::{Html, Selector};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct InteractiveElement {
6 pub index: usize,
7 pub tag: String,
8 pub role: String,
9 pub text: String,
10 pub name: String,
11 pub input_type: String,
12 pub placeholder: String,
13 pub value: String,
14 pub href: String,
15 pub selector: String,
16 pub is_clickable: bool,
17 pub is_input: bool,
18}
19
20impl InteractiveElement {
21 pub fn to_agent_string(&self) -> String {
22 if self.is_input {
23 let label = if !self.placeholder.is_empty() {
24 format!("placeholder=\"{}\"", self.placeholder)
25 } else if !self.name.is_empty() {
26 format!("name=\"{}\"", self.name)
27 } else {
28 format!("type=\"{}\"", self.input_type)
29 };
30 format!(
31 "[{}] <input {}> (value=\"{}\")",
32 self.index, label, self.value
33 )
34 } else if self.tag == "button" || self.role == "button" {
35 format!("[{}] <button \"{}\">", self.index, self.text)
36 } else if self.tag == "a" {
37 format!("[{}] <a \"{}\"> -> {}", self.index, self.text, self.href)
38 } else {
39 format!("[{}] <{} \"{}\">", self.index, self.tag, self.text)
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PageObservation {
46 pub url: String,
47 pub title: String,
48 pub is_captcha_detected: bool,
49 pub interactive_elements: Vec<InteractiveElement>,
50 pub agent_tree_text: String,
51 pub content_summary_markdown: String,
52}
53
54pub struct InteractiveParser;
55
56impl InteractiveParser {
57 pub fn parse(html_str: &str, base_url: Option<&str>) -> Vec<InteractiveElement> {
58 let document = Html::parse_document(html_str);
59 let mut elements = Vec::new();
60
61 let query = "a[href], button, input, textarea, select, [role='button'], [role='link'], [role='checkbox'], [role='tab'], [onclick]";
62 let selector = match Selector::parse(query) {
63 Ok(s) => s,
64 Err(_) => return elements,
65 };
66
67 let mut index = 1;
68 for el in document.select(&selector) {
69 let tag = el.value().name().to_string();
70 let role = el.value().attr("role").unwrap_or("").to_string();
71 let name = el.value().attr("name").unwrap_or("").to_string();
72 let input_type = el.value().attr("type").unwrap_or("text").to_string();
73 let placeholder = el.value().attr("placeholder").unwrap_or("").to_string();
74 let value = el.value().attr("value").unwrap_or("").to_string();
75 let id = el.value().attr("id").unwrap_or("").to_string();
76 let class = el.value().attr("class").unwrap_or("").to_string();
77
78 if input_type == "hidden"
80 || el.value().attr("disabled").is_some()
81 || el.value().attr("aria-hidden") == Some("true")
82 {
83 continue;
84 }
85
86 let mut text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
87 if text.is_empty() {
88 if let Some(aria_label) = el
89 .value()
90 .attr("aria-label")
91 .or_else(|| el.value().attr("title"))
92 {
93 text = aria_label.trim().to_string();
94 }
95 }
96
97 let raw_href = el.value().attr("href").unwrap_or("");
99 let href = if !raw_href.is_empty() && !raw_href.starts_with("javascript:") {
100 Self::resolve_url(raw_href, base_url)
101 } else {
102 String::new()
103 };
104
105 if text.is_empty()
107 && href.is_empty()
108 && placeholder.is_empty()
109 && name.is_empty()
110 && id.is_empty()
111 {
112 continue;
113 }
114
115 let is_input = tag == "input" || tag == "textarea" || tag == "select";
116 let is_clickable = tag == "a"
117 || tag == "button"
118 || role == "button"
119 || role == "link"
120 || el.value().attr("onclick").is_some();
121
122 let css_selector = if !id.is_empty() {
124 format!("#{}", id)
125 } else if !name.is_empty() {
126 format!("{}[name='{}']", tag, name)
127 } else if !class.is_empty() {
128 let first_class = class.split_whitespace().next().unwrap_or("");
129 format!("{}.{}", tag, first_class)
130 } else {
131 tag.clone()
132 };
133
134 elements.push(InteractiveElement {
135 index,
136 tag,
137 role,
138 text,
139 name,
140 input_type,
141 placeholder,
142 value,
143 href,
144 selector: css_selector,
145 is_clickable,
146 is_input,
147 });
148
149 index += 1;
150 }
151
152 elements
153 }
154
155 fn resolve_url(href: &str, base_url: Option<&str>) -> String {
156 if href.starts_with("http://") || href.starts_with("https://") {
157 return href.to_string();
158 }
159 if let Some(base) = base_url {
160 if href.starts_with("//") {
161 return format!("https:{}", href);
162 }
163 if href.starts_with('/') {
164 if let Some(idx) = base.find("://") {
165 let after = &base[idx + 3..];
166 let host = after.split('/').next().unwrap_or(after);
167 let scheme = &base[..idx + 3];
168 return format!("{}{}{}", scheme, host, href);
169 }
170 }
171 let trimmed_base = base.split('?').next().unwrap_or(base);
172 let parent = if trimmed_base.ends_with('/') {
173 trimmed_base
174 } else if let Some(last_slash) = trimmed_base.rfind('/') {
175 &trimmed_base[..last_slash + 1]
176 } else {
177 trimmed_base
178 };
179 return format!("{}{}", parent, href);
180 }
181 href.to_string()
182 }
183}