1use super::config::BrowseConfig;
7use super::engine::{BrowserEngine, BrowserError, BrowserTab};
8use super::helpers;
9use super::tab_guard::TabGuard;
10use crate::tools::typed::TypedTool;
11use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
12use async_trait::async_trait;
13use parking_lot::Mutex;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::sync::Arc;
18use tokio::sync::oneshot;
19#[derive(Deserialize, JsonSchema)]
21pub struct BrowseExtractArgs {
22 url: String,
23 selector: String,
24 #[serde(default = "default_extract")]
25 extract: String,
26 #[serde(default = "default_true")]
27 all: bool,
28 #[serde(default = "default_timeout")]
29 timeout: u64,
30}
31
32fn default_extract() -> String {
33 "text".to_string()
34}
35fn default_true() -> bool {
36 true
37}
38fn default_timeout() -> u64 {
39 30
40}
41
42pub struct BrowseExtractTool {
47 engine: Arc<dyn BrowserEngine>,
48 #[allow(dead_code)]
49 config: BrowseConfig,
50 callbacks: super::callback_mixin::BrowseCallbacks,
52 tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
54}
55
56impl BrowseExtractTool {
57 pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
59 Self {
60 engine,
61 config: BrowseConfig::default(),
62 callbacks: super::callback_mixin::BrowseCallbacks::new(),
63 tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
64 }
65 }
66
67 pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
69 Self {
70 engine,
71 config,
72 callbacks: super::callback_mixin::BrowseCallbacks::new(),
73 tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
74 }
75 }
76}
77
78#[async_trait]
79impl AgentTool for BrowseExtractTool {
80 fn name(&self) -> &str {
81 "browse_extract"
82 }
83
84 fn label(&self) -> &str {
85 "Extract Page Data"
86 }
87
88 fn description(&self) -> &str {
89 "Extract structured data from a web page: links, text content, or elements matching \
90 a CSS selector. Use when you need specific data from a page rather than the full content. \
91 Supports extracting all matching elements or just the first match."
92 }
93
94 fn on_progress(&self, callback: crate::tools::ProgressCallback) {
95 self.callbacks.store_progress(callback);
96 }
97
98 fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
99 self.callbacks.store_browse(callback);
100 }
101
102 fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
103 *self.tab_id_slot.lock() = slot;
104 }
105
106 fn current_tab_id(&self) -> Option<uuid::Uuid> {
107 *self.tab_id_slot.lock().lock()
108 }
109
110 fn parameters_schema(&self) -> Value {
111 json!({
112 "type": "object",
113 "properties": {
114 "url": {
115 "type": "string",
116 "description": "URL of the page to extract from"
117 },
118 "selector": {
119 "type": "string",
120 "description": "CSS selector to match elements"
121 },
122 "extract": {
123 "type": "string",
124 "enum": ["links", "text", "elements", "markdown"],
125 "default": "text",
126 "description": "What to extract: 'links' (href + text), 'text' (textContent), 'elements' (tag + text + attrs), 'markdown' (innerHTML as markdown)"
127 },
128 "all": {
129 "type": "boolean",
130 "default": true,
131 "description": "Return all matches (true) or just the first (false)"
132 },
133 "timeout": {
134 "type": "integer",
135 "default": 30,
136 "description": "Maximum time in seconds"
137 }
138 },
139 "required": ["url", "selector"]
140 })
141 }
142
143 async fn execute(
144 &self,
145 _tool_call_id: &str,
146 params: Value,
147 _signal: Option<oneshot::Receiver<()>>,
148 _ctx: &ToolContext,
149 ) -> Result<AgentToolResult, ToolError> {
150 let args: BrowseExtractArgs =
151 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
152 self.execute_typed(_tool_call_id, args, _signal, _ctx).await
153 }
154}
155
156#[async_trait]
157impl TypedTool for BrowseExtractTool {
158 type Args = BrowseExtractArgs;
159
160 async fn execute_typed(
161 &self,
162 _tool_call_id: &str,
163 args: Self::Args,
164 _signal: Option<oneshot::Receiver<()>>,
165 _ctx: &ToolContext,
166 ) -> Result<AgentToolResult, ToolError> {
167 let url = &args.url;
168 let selector = &args.selector;
169 let extract = &args.extract;
170 let all = args.all;
171 let timeout_secs = args.timeout;
172
173 tracing::info!(url = %url, selector = %selector, extract = %extract, "extracting page data");
174
175 let output = tokio::time::timeout(
176 std::time::Duration::from_secs(timeout_secs),
177 self.extract_from_new_tab(url, selector, extract, all),
178 )
179 .await
180 .map_err(|_| format!("Extract timed out after {}s", timeout_secs))??;
181
182 Ok(output)
183 }
184}
185
186impl BrowseExtractTool {
187 async fn extract_from_new_tab(
189 &self,
190 url: &str,
191 selector: &str,
192 extract: &str,
193 all: bool,
194 ) -> Result<AgentToolResult, ToolError> {
195 let raw_tab = self
196 .engine
197 .new_tab()
198 .await
199 .map_err(|e| format!("Failed to open browser tab: {}", e))?;
200
201 let tab_id = raw_tab.tab_id();
204 *self.tab_id_slot.lock().lock() = Some(tab_id);
205
206 self.callbacks
208 .register_on_registry(tab_id, self.engine.callback_registry().as_ref());
209
210 let guard = TabGuard::new(raw_tab);
211
212 let page = guard
213 .tab()
214 .goto(url)
215 .await
216 .map_err(|e| format!("Navigation failed: {}", e))?;
217
218 let output = extract_from_tab(guard.tab(), selector, extract, all)
219 .await
220 .map_err(|e: BrowserError| e.to_string())?;
221
222 let metadata_url = page.url.clone();
223 let metadata_title = page.title.clone();
224 let result_count = count_extracted_items(&output, extract);
225
226 guard.close().await;
227 *self.tab_id_slot.lock().lock() = None;
228
229 Ok(AgentToolResult::success(output).with_metadata(json!({
230 "url": metadata_url,
231 "title": metadata_title,
232 "selector": selector,
233 "extract": extract,
234 "result_count": result_count,
235 })))
236 }
237}
238
239fn count_extracted_items(output: &str, extract: &str) -> usize {
243 match extract {
244 "links" | "elements" => {
245 serde_json::from_str::<Vec<serde_json::Value>>(output)
247 .map(|v| v.len())
248 .unwrap_or(0)
249 }
250 _ => {
251 output.lines().filter(|l| !l.trim().is_empty()).count()
253 }
254 }
255}
256
257async fn extract_from_tab(
258 tab: &dyn BrowserTab,
259 selector: &str,
260 extract: &str,
261 all: bool,
262) -> Result<String, BrowserError> {
263 match extract {
264 "links" => {
265 let js = helpers::js_links_within(selector);
266 let value = tab.evaluate(&js).await?;
267 let links = helpers::parse_link_values(value);
268 let links = if all {
269 links
270 } else {
271 links.into_iter().take(1).collect()
272 };
273 let json_links: Vec<Value> = links
274 .iter()
275 .map(|(t, h)| json!({ "text": t, "href": h }))
276 .collect();
277 Ok(serde_json::to_string_pretty(&json_links).unwrap_or_default())
278 }
279 "elements" => {
280 let js = helpers::js_query_elements(selector);
281 let value = tab.evaluate(&js).await?;
282 let elements = helpers::parse_element_values(value);
283 let elements = if all {
284 elements
285 } else {
286 elements.into_iter().take(1).collect()
287 };
288 let json_elems: Vec<Value> = elements
289 .iter()
290 .map(|(tag, text, attrs)| json!({ "tag": tag, "text": text, "attributes": attrs }))
291 .collect();
292 Ok(serde_json::to_string_pretty(&json_elems).unwrap_or_default())
293 }
294 "markdown" => {
295 let texts = tab.query_all(selector).await?;
296 let texts = if all {
297 texts
298 } else {
299 texts.into_iter().take(1).collect()
300 };
301 Ok(texts.join("\n\n"))
302 }
303 _ => {
304 let texts = tab.query_all(selector).await?;
306 let texts = if all {
307 texts
308 } else {
309 texts.into_iter().take(1).collect()
310 };
311 Ok(texts.join("\n"))
312 }
313 }
314}