Skip to main content

headless_engine/browser/
tab.rs

1use 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        // Check if target is a numerical index
197        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        // Check if target is a numerical index
213        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        // 1. Check if matches href or anchor text
227        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        // 2. Try evaluating JS click
239        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}