headless_engine/browser/
tab.rs1use crate::browser::builder::BrowserBuilder;
2use crate::dom::{DomTree, FormInfo, InteractiveElement, LinkInfo, PageObservation, SearchResults};
3use crate::js::context::JsRuntime;
4use crate::network::client::{FetchResult, NetworkClient};
5use crate::network::fingerprint::DeviceProfile;
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Serialize, Deserialize)]
10pub struct NavigationReport {
11 pub status: u16,
12 pub requested_url: String,
13 pub final_url: String,
14 pub page_title: String,
15 pub is_captcha_detected: bool,
16 pub html_bytes: usize,
17}
18
19pub struct BrowserTab {
20 network: NetworkClient,
21 dom: Option<DomTree>,
22 js: JsRuntime,
23 pub current_url: Option<String>,
24}
25
26impl BrowserTab {
27 pub fn new() -> Result<Self> {
28 Self::with_profile(DeviceProfile::ChromeWindows)
29 }
30
31 pub fn builder() -> BrowserBuilder {
32 BrowserBuilder::new()
33 }
34
35 pub fn with_profile(profile: DeviceProfile) -> Result<Self> {
36 let network = NetworkClient::with_profile(profile)?;
37 Self::from_network(network)
38 }
39
40 pub fn from_network(network: NetworkClient) -> Result<Self> {
41 let js = JsRuntime::with_fingerprint(&network.fingerprint)?;
42 Ok(Self {
43 network,
44 dom: None,
45 js,
46 current_url: None,
47 })
48 }
49
50 pub fn profile(&self) -> DeviceProfile {
51 self.network.profile
52 }
53
54 pub fn set_profile(&mut self, profile: DeviceProfile) -> Result<()> {
55 self.network.set_profile(profile)?;
56 self.js = JsRuntime::with_fingerprint(&self.network.fingerprint)?;
57 Ok(())
58 }
59
60 pub async fn navigate(&mut self, url: &str) -> Result<NavigationReport> {
61 let fetch_result: FetchResult = self.network.fetch(url).await?;
62 let dom = DomTree::parse(&fetch_result.html)?;
63
64 let search_results = dom.parse_google_search_results();
65 let page_title = search_results.page_title.clone();
66 let html_bytes = fetch_result.html.len();
67
68 let _ = self
69 .js
70 .update_page_state(&fetch_result.final_url, &page_title);
71
72 self.dom = Some(dom);
73 self.current_url = Some(fetch_result.final_url.clone());
74
75 Ok(NavigationReport {
76 status: fetch_result.status,
77 requested_url: url.to_string(),
78 final_url: fetch_result.final_url,
79 page_title,
80 is_captcha_detected: fetch_result.is_captcha_detected,
81 html_bytes,
82 })
83 }
84
85 pub fn set_content(&mut self, html: &str, url: Option<&str>) -> Result<NavigationReport> {
86 let dom = DomTree::parse(html)?;
87 let search_results = dom.parse_google_search_results();
88 let page_title = search_results.page_title.clone();
89 let final_url = url.unwrap_or("about:blank").to_string();
90 let html_bytes = html.len();
91
92 let _ = self.js.update_page_state(&final_url, &page_title);
93
94 self.dom = Some(dom);
95 self.current_url = Some(final_url.clone());
96
97 Ok(NavigationReport {
98 status: 200,
99 requested_url: final_url.clone(),
100 final_url,
101 page_title,
102 is_captcha_detected: search_results.is_captcha_detected,
103 html_bytes,
104 })
105 }
106
107 pub fn observe(&self) -> Option<PageObservation> {
108 let dom = self.dom.as_ref()?;
109 let url = self.current_url.clone().unwrap_or_default();
110 let results = dom.parse_google_search_results();
111 let elements = dom.extract_interactive_elements(Some(&url));
112
113 let mut tree_lines = Vec::new();
114 for el in &elements {
115 tree_lines.push(el.to_agent_string());
116 }
117
118 let agent_tree_text = tree_lines.join("\n");
119 let content_summary_markdown = dom.extract_markdown(None, Some(&url));
120
121 Some(PageObservation {
122 url,
123 title: results.page_title,
124 is_captcha_detected: results.is_captcha_detected,
125 interactive_elements: elements,
126 agent_tree_text,
127 content_summary_markdown,
128 })
129 }
130
131 pub fn evaluate_js(&mut self, code: &str) -> Result<String> {
132 self.js.evaluate(code)
133 }
134
135 pub fn extract_dom(&self, selector: Option<&str>) -> Option<String> {
136 self.dom.as_ref().and_then(|d| d.extract(selector))
137 }
138
139 pub fn extract_markdown(&self, selector: Option<&str>) -> Option<String> {
140 self.dom
141 .as_ref()
142 .map(|d| d.extract_markdown(selector, self.current_url.as_deref()))
143 }
144
145 pub fn extract_interactive_elements(&self) -> Vec<InteractiveElement> {
146 self.dom
147 .as_ref()
148 .map(|d| d.extract_interactive_elements(self.current_url.as_deref()))
149 .unwrap_or_default()
150 }
151
152 pub fn extract_links(&self) -> Vec<LinkInfo> {
153 self.dom
154 .as_ref()
155 .map(|d| d.extract_links(self.current_url.as_deref()))
156 .unwrap_or_default()
157 }
158
159 pub fn extract_forms(&self) -> Vec<FormInfo> {
160 self.dom
161 .as_ref()
162 .map(|d| d.extract_forms())
163 .unwrap_or_default()
164 }
165
166 pub fn extract_search_results(&self) -> Option<SearchResults> {
167 self.dom.as_ref().map(|d| d.parse_google_search_results())
168 }
169
170 pub async fn screenshot_async(&self) -> Option<crate::dom::ScreenshotResult> {
171 let dom = self.dom.as_ref()?;
172 let url = self.current_url.as_deref().unwrap_or("about:blank");
173 let results = dom.parse_google_search_results();
174 Some(
175 dom.screenshot_async(url, &results.page_title, self.current_url.as_deref())
176 .await,
177 )
178 }
179
180 pub fn screenshot(&self) -> Option<crate::dom::ScreenshotResult> {
181 let dom = self.dom.as_ref()?;
182 let url = self.current_url.as_deref().unwrap_or("about:blank");
183 let results = dom.parse_google_search_results();
184 Some(dom.screenshot(url, &results.page_title, self.current_url.as_deref()))
185 }
186
187 pub fn screenshot_svg(&self) -> Option<String> {
188 self.screenshot().map(|s| s.svg)
189 }
190
191 pub fn screenshot_layout(&self) -> Option<String> {
192 self.screenshot().map(|s| s.layout_wireframe)
193 }
194
195 pub async fn act_click(&mut self, target: &str) -> Result<Option<NavigationReport>> {
196 if let Ok(idx) = target.parse::<usize>() {
198 let elements = self.extract_interactive_elements();
199 if let Some(el) = elements.iter().find(|e| e.index == idx) {
200 if !el.href.is_empty() {
201 let report = self.navigate(&el.href).await?;
202 return Ok(Some(report));
203 }
204 return self.click(&el.selector).await;
205 }
206 }
207
208 self.click(target).await
209 }
210
211 pub async fn act_type(&mut self, target: &str, text: &str) -> Result<String> {
212 if let Ok(idx) = target.parse::<usize>() {
214 let elements = self.extract_interactive_elements();
215 if let Some(el) = elements.iter().find(|e| e.index == idx) {
216 return self.type_text(&el.selector, text);
217 }
218 }
219
220 self.type_text(target, text)
221 }
222
223 pub async fn click(&mut self, selector_or_text: &str) -> Result<Option<NavigationReport>> {
224 let links = self.extract_links();
225
226 if let Some(link) = links.iter().find(|l| {
228 l.text.eq_ignore_ascii_case(selector_or_text)
229 || l.href.contains(selector_or_text)
230 || l.text
231 .to_lowercase()
232 .contains(&selector_or_text.to_lowercase())
233 }) {
234 let report = self.navigate(&link.href).await?;
235 return Ok(Some(report));
236 }
237
238 let js_code = format!(
240 "var el = document.querySelector('{}'); if (el) {{ el.click(); true; }} else {{ false; }}",
241 selector_or_text
242 );
243 let _ = self.evaluate_js(&js_code);
244
245 Ok(None)
246 }
247
248 pub fn type_text(&mut self, selector: &str, text: &str) -> Result<String> {
249 let js_code = format!(
250 "var el = document.querySelector('{}'); if (el) {{ el.value = '{}'; 'updated'; }} else {{ 'not_found'; }}",
251 selector, text.replace('\'', "\\'")
252 );
253 self.evaluate_js(&js_code)
254 .context("Failed to evaluate type action")
255 }
256}