Skip to main content

opendev_tools_impl/
web_screenshot.rs

1//! Web screenshot tool — capture web page screenshots.
2//!
3//! Provides web page capture functionality. Since Rust doesn't have native
4//! Playwright bindings, this implementation uses HTTP + HTML extraction as
5//! a fallback. For full rendering, it can shell out to a headless browser
6//! via the system's `chromium` or `google-chrome` CLI.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::path_utils::{resolve_file_path, validate_path_access};
12
13use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
14
15/// Tool for capturing web page screenshots.
16#[derive(Debug)]
17pub struct WebScreenshotTool;
18
19#[async_trait::async_trait]
20impl BaseTool for WebScreenshotTool {
21    fn name(&self) -> &str {
22        "web_screenshot"
23    }
24
25    fn description(&self) -> &str {
26        "Capture a screenshot of a web page. Saves as PNG using headless Chrome/Chromium, \
27         or falls back to saving page HTML."
28    }
29
30    fn parameter_schema(&self) -> serde_json::Value {
31        serde_json::json!({
32            "type": "object",
33            "properties": {
34                "url": {
35                    "type": "string",
36                    "description": "URL of the web page to capture"
37                },
38                "output_path": {
39                    "type": "string",
40                    "description": "Path to save the screenshot (optional, auto-generated if not provided)"
41                },
42                "viewport_width": {
43                    "type": "integer",
44                    "description": "Browser viewport width in pixels (default: 1920)"
45                },
46                "viewport_height": {
47                    "type": "integer",
48                    "description": "Browser viewport height in pixels (default: 1080)"
49                },
50                "action": {
51                    "type": "string",
52                    "description": "Action: 'capture' (default), 'list', or 'clear'",
53                    "enum": ["capture", "list", "clear"]
54                }
55            },
56            "required": ["url"]
57        })
58    }
59
60    async fn execute(
61        &self,
62        args: HashMap<String, serde_json::Value>,
63        ctx: &ToolContext,
64    ) -> ToolResult {
65        let action = args
66            .get("action")
67            .and_then(|v| v.as_str())
68            .unwrap_or("capture");
69
70        match action {
71            "list" => list_screenshots(),
72            "clear" => clear_screenshots(5),
73            _ => {
74                let url = match args.get("url").and_then(|v| v.as_str()) {
75                    Some(u) if !u.trim().is_empty() => u.trim(),
76                    _ => return ToolResult::fail("url is required for screenshot capture"),
77                };
78
79                let output_path = args.get("output_path").and_then(|v| v.as_str());
80                let viewport_width = args
81                    .get("viewport_width")
82                    .and_then(|v| v.as_u64())
83                    .unwrap_or(1920) as u32;
84                let viewport_height = args
85                    .get("viewport_height")
86                    .and_then(|v| v.as_u64())
87                    .unwrap_or(1080) as u32;
88
89                capture_screenshot(url, output_path, viewport_width, viewport_height, ctx).await
90            }
91        }
92    }
93}
94
95/// Normalize a URL to have proper protocol prefix.
96fn normalize_url(url: &str) -> String {
97    let url = url.trim();
98    if url.starts_with("https://") || url.starts_with("http://") {
99        return url.to_string();
100    }
101    if url.starts_with("https:/") && !url.starts_with("https://") {
102        return url.replacen("https:/", "https://", 1);
103    }
104    if url.starts_with("http:/") && !url.starts_with("http://") {
105        return url.replacen("http:/", "http://", 1);
106    }
107    format!("https://{url}")
108}
109
110/// Get the screenshot storage directory.
111fn screenshot_dir() -> PathBuf {
112    let dir = std::env::temp_dir().join("opendev_web_screenshots");
113    std::fs::create_dir_all(&dir).ok();
114    dir
115}
116
117/// Generate an output path from a URL.
118fn generate_output_path(url: &str) -> PathBuf {
119    // Extract domain for filename
120    let domain = url
121        .trim_start_matches("https://")
122        .trim_start_matches("http://")
123        .split('/')
124        .next()
125        .unwrap_or("page")
126        .replace([':', '/'], "_");
127
128    let timestamp = std::time::SystemTime::now()
129        .duration_since(std::time::UNIX_EPOCH)
130        .unwrap_or_default()
131        .as_millis();
132
133    screenshot_dir().join(format!("{domain}_{timestamp}.png"))
134}
135
136/// Find a headless browser binary on the system.
137fn find_browser() -> Option<String> {
138    let candidates = [
139        "chromium",
140        "chromium-browser",
141        "google-chrome",
142        "google-chrome-stable",
143        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
144        "/Applications/Chromium.app/Contents/MacOS/Chromium",
145    ];
146
147    for candidate in &candidates {
148        if let Ok(output) = std::process::Command::new("which").arg(candidate).output()
149            && output.status.success()
150        {
151            return Some(candidate.to_string());
152        }
153        // Direct path check
154        if Path::new(candidate).exists() {
155            return Some(candidate.to_string());
156        }
157    }
158    None
159}
160
161/// Capture a screenshot using headless Chrome, falling back to HTML save.
162async fn capture_screenshot(
163    url: &str,
164    output_path: Option<&str>,
165    viewport_width: u32,
166    viewport_height: u32,
167    ctx: &ToolContext,
168) -> ToolResult {
169    let url = normalize_url(url);
170
171    // Determine output path
172    let dest = match output_path {
173        Some(p) => {
174            let resolved = resolve_file_path(p, &ctx.working_dir);
175            if let Err(msg) = validate_path_access(&resolved, &ctx.working_dir) {
176                return ToolResult::fail(msg);
177            }
178            resolved
179        }
180        None => generate_output_path(&url),
181    };
182
183    // Ensure parent directory exists
184    if let Some(parent) = dest.parent() {
185        std::fs::create_dir_all(parent).ok();
186    }
187
188    // Try headless Chrome first
189    if let Some(browser) = find_browser() {
190        let window_size = format!("--window-size={viewport_width},{viewport_height}");
191        let screenshot_arg = format!("--screenshot={}", dest.display());
192
193        let result = tokio::process::Command::new(&browser)
194            .args([
195                "--headless",
196                "--disable-gpu",
197                "--no-sandbox",
198                "--disable-software-rasterizer",
199                "--disable-dev-shm-usage",
200                &window_size,
201                &screenshot_arg,
202                &url,
203            ])
204            .stdout(std::process::Stdio::piped())
205            .stderr(std::process::Stdio::piped())
206            .output()
207            .await;
208
209        match result {
210            Ok(output) if output.status.success() && dest.exists() => {
211                let size_kb = std::fs::metadata(&dest)
212                    .map(|m| m.len() as f64 / 1024.0)
213                    .unwrap_or(0.0);
214
215                let mut metadata = HashMap::new();
216                metadata.insert(
217                    "screenshot_path".into(),
218                    serde_json::json!(dest.to_string_lossy()),
219                );
220                metadata.insert("url".into(), serde_json::json!(url));
221                metadata.insert(
222                    "viewport".into(),
223                    serde_json::json!(format!("{viewport_width}x{viewport_height}")),
224                );
225                metadata.insert(
226                    "screenshot_size_kb".into(),
227                    serde_json::json!(format!("{size_kb:.1}")),
228                );
229
230                return ToolResult::ok_with_metadata(
231                    format!(
232                        "Screenshot saved: {}\nURL: {url}\nViewport: {viewport_width}x{viewport_height}\nSize: {size_kb:.1} KB",
233                        dest.display()
234                    ),
235                    metadata,
236                );
237            }
238            Ok(output) => {
239                let stderr = String::from_utf8_lossy(&output.stderr);
240                tracing::warn!(
241                    "Headless Chrome failed (status {}): {stderr}",
242                    output.status
243                );
244                // Fall through to HTTP fallback
245            }
246            Err(e) => {
247                tracing::warn!("Failed to launch headless Chrome: {e}");
248                // Fall through to HTTP fallback
249            }
250        }
251    }
252
253    // Fallback: save page as HTML
254    let client = match reqwest::Client::builder()
255        .timeout(std::time::Duration::from_secs(30))
256        .redirect(reqwest::redirect::Policy::limited(10))
257        .user_agent(
258            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
259             AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
260        )
261        .build()
262    {
263        Ok(c) => c,
264        Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
265    };
266
267    let response = match client.get(&url).send().await {
268        Ok(r) => r,
269        Err(e) => return ToolResult::fail(format!("Failed to fetch page: {e}")),
270    };
271
272    let body = match response.text().await {
273        Ok(t) => t,
274        Err(e) => return ToolResult::fail(format!("Failed to read page: {e}")),
275    };
276
277    // Save as HTML instead of PNG
278    let html_dest = dest.with_extension("html");
279    match std::fs::write(&html_dest, &body) {
280        Ok(_) => {
281            let mut metadata = HashMap::new();
282            metadata.insert(
283                "screenshot_path".into(),
284                serde_json::json!(html_dest.to_string_lossy()),
285            );
286            metadata.insert("url".into(), serde_json::json!(url));
287            metadata.insert("format".into(), serde_json::json!("html"));
288            metadata.insert(
289                "note".into(),
290                serde_json::json!(
291                    "Headless Chrome not available. Saved as HTML. \
292                     Install Chrome/Chromium for PNG screenshots."
293                ),
294            );
295
296            ToolResult::ok_with_metadata(
297                format!(
298                    "Page saved as HTML: {}\nURL: {url}\n\
299                     Note: Install Chrome/Chromium for PNG screenshot support.",
300                    html_dest.display()
301                ),
302                metadata,
303            )
304        }
305        Err(e) => ToolResult::fail(format!("Failed to save page: {e}")),
306    }
307}
308
309/// List recent screenshots.
310fn list_screenshots() -> ToolResult {
311    let dir = screenshot_dir();
312    if !dir.exists() {
313        return ToolResult::ok("No screenshots found.");
314    }
315
316    let mut entries: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();
317
318    if let Ok(read_dir) = std::fs::read_dir(&dir) {
319        for entry in read_dir.flatten() {
320            let path = entry.path();
321            if let Some(ext) = path.extension()
322                && (ext == "png" || ext == "html")
323                && let Ok(meta) = entry.metadata()
324            {
325                entries.push((path, meta));
326            }
327        }
328    }
329
330    // Sort by modification time, newest first
331    entries.sort_by(|a, b| {
332        b.1.modified()
333            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
334            .cmp(&a.1.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH))
335    });
336
337    // Show at most 10
338    entries.truncate(10);
339
340    if entries.is_empty() {
341        return ToolResult::ok("No screenshots found.");
342    }
343
344    let mut output = format!("Screenshots ({}, showing up to 10):\n\n", entries.len());
345    for (path, meta) in &entries {
346        let size_kb = meta.len() as f64 / 1024.0;
347        output.push_str(&format!("  {} ({:.1} KB)\n", path.display(), size_kb));
348    }
349
350    let mut metadata = HashMap::new();
351    metadata.insert("count".into(), serde_json::json!(entries.len()));
352    metadata.insert("directory".into(), serde_json::json!(dir.to_string_lossy()));
353
354    ToolResult::ok_with_metadata(output, metadata)
355}
356
357/// Clear old screenshots, keeping the most recent ones.
358fn clear_screenshots(keep_recent: usize) -> ToolResult {
359    let dir = screenshot_dir();
360    if !dir.exists() {
361        return ToolResult::ok("No screenshots directory found.");
362    }
363
364    let mut entries: Vec<PathBuf> = Vec::new();
365
366    if let Ok(read_dir) = std::fs::read_dir(&dir) {
367        for entry in read_dir.flatten() {
368            let path = entry.path();
369            if let Some(ext) = path.extension()
370                && (ext == "png" || ext == "html")
371            {
372                entries.push(path);
373            }
374        }
375    }
376
377    // Sort by modification time, newest first
378    entries.sort_by(|a, b| {
379        let a_time = a
380            .metadata()
381            .and_then(|m| m.modified())
382            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
383        let b_time = b
384            .metadata()
385            .and_then(|m| m.modified())
386            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
387        b_time.cmp(&a_time)
388    });
389
390    let to_delete = if entries.len() > keep_recent {
391        &entries[keep_recent..]
392    } else {
393        &[]
394    };
395
396    let mut deleted = 0;
397    for path in to_delete {
398        if std::fs::remove_file(path).is_ok() {
399            deleted += 1;
400        }
401    }
402
403    let kept = entries.len().saturating_sub(deleted);
404
405    let mut metadata = HashMap::new();
406    metadata.insert("deleted_count".into(), serde_json::json!(deleted));
407    metadata.insert("kept_count".into(), serde_json::json!(kept));
408
409    ToolResult::ok_with_metadata(
410        format!("Cleared {deleted} screenshots, kept {kept}."),
411        metadata,
412    )
413}
414
415#[cfg(test)]
416#[path = "web_screenshot_tests.rs"]
417mod tests;