browsing 0.1.3

Lightweight MCP/API for browser automation: navigate, get content (text), screenshot. Parallelism via RwLock.
Documentation
//! Content action handlers

use super::Handler;
use crate::agent::views::ActionResult;
use crate::error::{BrowsingError, Result};
use crate::tools::views::{ActionContext, ActionParams};
use async_trait::async_trait;
use serde_json::json;
use tracing::info;

/// Handler for content extraction actions
/// Handles extract_links, extract_images, find_text, and other content operations
pub struct ContentHandler;

#[async_trait]
impl Handler for ContentHandler {
    async fn handle(
        &self,
        params: &ActionParams<'_>,
        context: &mut ActionContext<'_>,
    ) -> Result<ActionResult> {
        match params.get_action_type().unwrap_or("unknown") {
            "scroll" => self.scroll(params, context).await,
            "find_text" => self.find_text(params, context).await,
            "dropdown_options" => self.dropdown_options(params, context).await,
            "select_dropdown" => self.select_dropdown(params, context).await,
            _ => Err(BrowsingError::Tool("Unknown content action".into())),
        }
    }
}

impl ContentHandler {
    async fn scroll(
        &self,
        params: &ActionParams<'_>,
        context: &mut ActionContext<'_>,
    ) -> Result<ActionResult> {
        let down = params.get_optional_bool("down");
        let pages = params.get_optional_f64("pages").unwrap_or(1.0);

        let mut page = context.browser.get_page()?;
        let mouse = page.mouse().await;
        let viewport_height = 1000.0;
        let delta_y = if down {
            pages * viewport_height
        } else {
            -pages * viewport_height
        };

        mouse.scroll(0.0, 0.0, None, Some(delta_y)).await?;

        let direction = if down { "down" } else { "up" };
        let memory = format!("Scrolled {} {} pages", direction, pages);
        info!("📜 {}", memory);
        Ok(ActionResult::success_with_memory(memory))
    }

    async fn find_text(
        &self,
        params: &ActionParams<'_>,
        context: &mut ActionContext<'_>,
    ) -> Result<ActionResult> {
        let text = params.get_required_str("text")?;
        let page = context.browser.get_page()?;

        let script = format!(
            r#"(function() {{
                const searchText = {};
                const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
                let node;
                while (node = walker.nextNode()) {{
                    if (node.textContent && node.textContent.includes(searchText)) {{
                        const range = document.createRange();
                        range.selectNodeContents(node);
                        const rect = range.getBoundingClientRect();
                        window.scrollTo({{ top: window.scrollY + rect.top - window.innerHeight / 2, behavior: 'smooth' }});
                        return true;
                    }}
                }}
                return false;
            }})()"#,
            json!(text)
        );

        let result = page.evaluate(&script).await?;
        let found = result.trim() == "true";

        if found {
            let memory = format!("Scrolled to text: {}", text);
            info!("🔍 {}", memory);
            Ok(ActionResult::success_with_memory(memory))
        } else {
            let msg = format!("Text '{}' not found or not visible on page", text);
            info!("⚠️ {}", msg);
            Ok(ActionResult {
                extracted_content: Some(msg),
                long_term_memory: Some(format!(
                    "Tried scrolling to text '{}' but it was not found",
                    text
                )),
                ..Default::default()
            })
        }
    }

    async fn dropdown_options(
        &self,
        params: &ActionParams<'_>,
        context: &mut ActionContext<'_>,
    ) -> Result<ActionResult> {
        let index = params.get_required_u32("index")?;
        let element = context
            .selector_map
            .and_then(|map| map.get(&index))
            .ok_or_else(|| BrowsingError::Tool(format!("Element index {} not found", index)))?;

        let page = context.browser.get_page()?;
        let backend_node_id = element.backend_node_id.ok_or_else(|| {
            BrowsingError::Tool(format!("Element index {} has no backend_node_id", index))
        })?;

        let script = format!(
            r#"(function() {{
                const nodeId = {};
                const node = document.querySelector(`[data-backend-node-id="${{nodeId}}"]`) ||
                             Array.from(document.querySelectorAll('select')).find(el => {{
                                 const rect = el.getBoundingClientRect();
                                 return rect.width > 0 && rect.height > 0;
                             }}) || document.querySelector('select');
                if (!node && document.querySelector('select')) {{
                    const select = document.querySelector('select');
                    const options = Array.from(select.options).map(opt => ({{ value: opt.value, text: opt.text, selected: opt.selected }}));
                    return JSON.stringify(options);
                }}
                if (node && node.tagName === 'SELECT') {{
                    const options = Array.from(node.options).map(opt => ({{ value: opt.value, text: opt.text, selected: opt.selected }}));
                    return JSON.stringify(options);
                }}
                return JSON.stringify([]);
            }})()"#,
            backend_node_id
        );

        let result = page.evaluate(&script).await?;
        let options: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap_or_default();

        let options_text = options
            .iter()
            .enumerate()
            .map(|(i, opt)| {
                let value = opt.get("value").and_then(|v| v.as_str()).unwrap_or("");
                let text = opt.get("text").and_then(|v| v.as_str()).unwrap_or("");
                format!("{}. {} (value: {})", i + 1, text, value)
            })
            .collect::<Vec<_>>()
            .join("\n");

        let memory = format!("Dropdown options for index {}:\n{}", index, options_text);
        info!("📋 {}", memory);
        Ok(ActionResult {
            extracted_content: Some(options_text),
            long_term_memory: Some(memory),
            ..Default::default()
        })
    }

    async fn select_dropdown(
        &self,
        params: &ActionParams<'_>,
        context: &mut ActionContext<'_>,
    ) -> Result<ActionResult> {
        let index = params.get_required_u32("index")?;
        let text = params.get_required_str("text")?;

        let element = context
            .selector_map
            .and_then(|map| map.get(&index))
            .ok_or_else(|| BrowsingError::Tool(format!("Element index {} not found", index)))?;

        let page = context.browser.get_page()?;
        let backend_node_id = element.backend_node_id.ok_or_else(|| {
            BrowsingError::Tool(format!("Element index {} has no backend_node_id", index))
        })?;

        let script = format!(
            r#"(function() {{
                const nodeId = {};
                const searchText = {};
                const node = document.querySelector(`[data-backend-node-id="${{nodeId}}"]`) ||
                             Array.from(document.querySelectorAll('select')).find(el => {{
                                 const rect = el.getBoundingClientRect();
                                 return rect.width > 0 && rect.height > 0;
                             }}) || document.querySelector('select');
                if (!node || node.tagName !== 'SELECT') {{
                    return {{ success: false, error: 'Element is not a select dropdown' }};
                }}
                const options = Array.from(node.options);
                const option = options.find(opt => opt.text.trim() === searchText || opt.value === searchText || opt.text.includes(searchText));
                if (!option) {{
                    return {{ success: false, error: `Option "${{searchText}}" not found` }};
                }}
                node.value = option.value;
                node.dispatchEvent(new Event('change', {{ bubbles: true }}));
                node.dispatchEvent(new Event('input', {{ bubbles: true }}));
                return {{ success: true, message: `Selected option: ${{option.text}} (value: ${{option.value}})` }};
            }})()"#,
            backend_node_id,
            json!(text)
        );

        let result = page.evaluate(&script).await?;
        let result_obj: serde_json::Value =
            serde_json::from_str(&result).unwrap_or(serde_json::json!({}));

        if result_obj
            .get("success")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            let message = result_obj
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("Selected option");
            let memory = format!("Selected dropdown option '{}' at index {}", text, index);
            info!("✅ {}", memory);
            Ok(ActionResult {
                extracted_content: Some(message.to_string()),
                long_term_memory: Some(memory),
                ..Default::default()
            })
        } else {
            let error = result_obj
                .get("error")
                .and_then(|v| v.as_str())
                .unwrap_or("Failed to select dropdown option");
            Err(BrowsingError::Tool(error.to_string()))
        }
    }
}