├── .amazonq
└── rules
│ └── cargo
│ └── cargo.md
├── .gitignore
├── AmazonQ.md
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE.md
├── README.md
├── src
├── capture.rs
├── lib.rs
├── main.rs
└── mcp
│ ├── actions.rs
│ ├── client.rs
│ ├── mock_sdk.rs
│ ├── mod.rs
│ └── server.rs
└── tests
├── mcp
├── mod.rs
├── test_client.rs
├── test_integration.rs
└── test_server.rs
└── mod.rs
/.amazonq/rules/cargo/cargo.md:
--------------------------------------------------------------------------------
1 | I don't want you to update Cargo.toml directly. Instead, I want you to use the cargo command line to add dependancies to the project. This will ensure they are added in a maintainable way
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | **/*.rs.bk
3 | Cargo.lock
4 | *.png
5 | *.gif
6 | .DS_Store
7 |
--------------------------------------------------------------------------------
/AmazonQ.md:
--------------------------------------------------------------------------------
1 |
2 | # WebLook - Web Page Screenshot/Recording Tool
3 |
4 | ## Requirements
5 |
6 | 1. **Core Functionality**:
7 | - Open a given URL in a headless browser
8 | - Wait a specified amount of time before capturing
9 | - Take either a static screenshot or create a short animated GIF recording
10 | - Run as efficiently as possible in headless mode
11 |
12 | 2. **Default Parameters**:
13 | - Default URL: 127.0.0.1:8080
14 | - Default wait time: 10 seconds
15 | - Default recording length: 10 seconds
16 | - Default output: weblook.png (for screenshot) or weblook.gif (for recording)
17 |
18 | 3. **Input/Output Options**:
19 | - Accept URL as a command-line parameter
20 | - Accept URL as piped input
21 | - Support saving output to a specified file
22 | - Support piping output to stdout for use in pipelines
23 |
24 | 4. **Configuration Options**:
25 | - Configurable window size for the browser viewport
26 | - Configurable wait time before capture
27 | - Configurable recording length for GIFs
28 |
29 | 5. **Experimental Features**:
30 | - MCP (Model Context Protocol) integration as an optional feature
31 | - Enabled via the `mcp_experimental` feature flag
32 | - Support for both server and client modes
33 |
34 | ## Implementation Approaches
35 |
36 | ### Approach 1: Headless Chrome with WebDriver
37 |
38 | **Description**: Use WebDriver protocol with a headless Chrome/Chromium browser.
39 |
40 | **Components**:
41 | - Rust WebDriver client library (e.g., `thirtyfour`, `fantoccini`)
42 | - Chrome/Chromium browser (installed on the system)
43 | - Image processing library for screenshots (e.g., `image`)
44 | - GIF creation library (e.g., `gif`)
45 |
46 | **Pros**:
47 | - Full browser rendering ensures accurate representation of modern web pages
48 | - WebDriver is a standardized protocol with good support
49 | - Can execute JavaScript and interact with the page if needed
50 |
51 | **Cons**:
52 | - Requires Chrome/Chromium to be installed
53 | - Potentially higher resource usage
54 | - More complex setup and dependencies
55 |
56 | ### Approach 2: Lightweight Headless Browser Library
57 |
58 | **Description**: Use a pure Rust headless browser library without external dependencies.
59 |
60 | **Components**:
61 | - Headless browser library (e.g., `headless_chrome`, `rust-headless-chrome`)
62 | - Image processing libraries for capturing and creating GIFs
63 |
64 | **Pros**:
65 | - Potentially lighter weight than full WebDriver approach
66 | - Fewer external dependencies
67 | - Possibly faster startup time
68 |
69 | **Cons**:
70 | - May still require Chrome binaries
71 | - Potentially less standardized than WebDriver
72 | - May have fewer features or less compatibility with complex web pages
73 |
74 | ### Approach 3: HTTP Client with HTML Renderer
75 |
76 | **Description**: Use an HTTP client to fetch content and render it with a HTML/CSS renderer.
77 |
78 | **Components**:
79 | - HTTP client (e.g., `reqwest`)
80 | - HTML/CSS renderer (e.g., `lol_html`, custom renderer)
81 | - WebKit or similar rendering engine wrapper
82 | - Image processing libraries
83 |
84 | **Pros**:
85 | - Potentially the lightest weight solution
86 | - No browser dependency
87 | - Fastest execution time
88 |
89 | **Cons**:
90 | - Limited JavaScript support
91 | - May not render complex modern websites correctly
92 | - Less accurate representation of how pages appear to users
93 |
94 | Each approach offers different trade-offs between accuracy, resource usage, dependencies, and complexity. The best choice depends on the specific requirements for accuracy of rendering, resource constraints, and deployment environment.
95 |
96 | ## Feature Flags
97 |
98 | 1. **mcp_experimental**:
99 | - Enables the experimental MCP (Model Context Protocol) integration
100 | - When enabled, adds MCP server and client functionality
101 | - Disabled by default to keep the core tool lightweight
102 | - Can be enabled with `cargo build --features mcp_experimental`
103 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [Unreleased]
4 |
5 | ### Added
6 | - Experimental MCP (Model Context Protocol) integration
7 | - New feature flag `mcp_experimental` to enable/disable MCP functionality at compile time
8 | - Documentation for MCP experimental features in README.md
9 | - MCP server and client functionality (when compiled with the feature flag)
10 |
11 | ### Changed
12 | - MCP-related command line options are now marked as experimental
13 | - MCP-related code is now conditionally compiled only when the feature flag is enabled
14 | - Updated documentation to reflect the experimental status of MCP features
15 |
16 | ### Developer Notes
17 | - MCP tests are now conditionally compiled with the feature flag
18 | - Added feature flag documentation in AmazonQ.md
19 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "weblook"
3 | version = "0.1.0"
4 | edition = "2024"
5 | authors = ["Kelsea Blackwell"]
6 | description = "A command-line tool for capturing screenshots and recordings of web pages"
7 | license = "GPL-3.0"
8 | repository = "https://github.com/TrippingKelsea/weblookrs"
9 | readme = "README.md"
10 | keywords = ["screenshot", "web", "capture", "gif", "recording"]
11 | categories = ["command-line-utilities", "multimedia"]
12 |
13 | [lib]
14 | name = "weblook"
15 | path = "src/lib.rs"
16 |
17 | [[bin]]
18 | name = "weblook"
19 | path = "src/main.rs"
20 |
21 | [features]
22 | default = []
23 | # Experimental MCP (Model Context Protocol) support
24 | mcp_experimental = []
25 |
26 | [dependencies]
27 | anyhow = "1.0.98"
28 | atty = "0.2.14"
29 | base64 = "0.21.7"
30 | chrono = "0.4.40"
31 | clap = { version = "4.5.36", features = ["derive"] }
32 | colored = "3.0.0"
33 | futures = "0.3.31"
34 | gif = "0.13.1"
35 | image = "0.25.6"
36 | indicatif = "0.17.11"
37 | # mcp-sdk = { git = "https://github.com/modelcontextprotocol/rust-sdk" }
38 | rand = "0.9.0"
39 | serde = { version = "1.0.197", features = ["derive"] }
40 | serde_json = "1.0.114"
41 | tempfile = "3.19.1"
42 | thirtyfour = "0.35.0"
43 | tokio = { version = "1.44.2", features = ["full"] }
44 | url = "2.5.4"
45 | webp = "0.3.0"
46 |
47 | [dev-dependencies]
48 | mockito = "1.4.0"
49 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # GNU GENERAL PUBLIC LICENSE
2 |
3 | Version 3, 29 June 2007
4 |
5 | Copyright (C) 2007 Free Software Foundation, Inc.
6 |
7 | Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
8 |
9 | ## Preamble
10 |
11 | The GNU General Public License is a free, copyleft license for software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
14 |
15 | When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
16 |
17 | To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
18 |
19 | For the complete license text, please see the accompanying LICENSE file or visit https://www.gnu.org/licenses/gpl-3.0.html
20 |
21 | ## Copyright
22 |
23 | Copyright (C) 2025 Kelsea Blackwell
24 |
25 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
26 |
27 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
28 |
29 | You should have received a copy of the GNU General Public License along with this program. If not, see .
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WebLook
2 |
3 | A command-line tool for capturing screenshots and recordings of web pages.
4 |
5 | ## Why This Tool Exists
6 |
7 | I created WebLook to support my local development workflow, particularly when testing Amazon Q CLI and MCP (Model Context Protocol) applications. I needed a lightweight, read-only web tool with sane defaults that could quickly capture visual states of locally-running web applications without complex configuration. WebLook is designed to be simple, efficient, and integrate seamlessly into development pipelines with minimal overhead.
8 |
9 | ## Features
10 |
11 | - Take screenshots of web pages
12 | - Create animated GIF recordings of web pages
13 | - Configurable wait time before capture
14 | - Configurable window size
15 | - Configurable recording length
16 | - Support for input/output piping
17 | - Headless operation
18 | - Execute custom JavaScript before capture
19 | - Capture browser console logs
20 | - Automatic user-agent rotation (Windows/Mac Chrome)
21 | - Automatic ChromeDriver management
22 | - Colorful progress indicators with countdown timers
23 | - **[EXPERIMENTAL] MCP (Model Context Protocol) integration** for AI model interaction
24 |
25 | ## Usage
26 |
27 | ```
28 | weblook [OPTIONS] [URL]
29 | ```
30 |
31 | ### Options
32 |
33 | - `--output, -o `: Specify output file (default: weblook.png or weblook.gif)
34 | - `--wait, -w `: Wait time before capture (default: 10 seconds)
35 | - `--record, -r [SECONDS]`: Create a recording instead of screenshot (default length: 10 seconds)
36 | - `--size, -s `: Set viewport size (default: 1280x720)
37 | - `--js, -j `: Execute JavaScript code before capture
38 | - `--console-log `: Capture browser console logs and save to specified file
39 | - `--debug, -d`: Enable debug output (shows ChromeDriver messages)
40 | - `--mcp-server `: [EXPERIMENTAL] Start as MCP server on specified address
41 | - `--mcp-client `: [EXPERIMENTAL] Connect to MCP server at specified URL
42 | - `--help, -h`: Show help information
43 |
44 | ### Examples
45 |
46 | ```bash
47 | # Take a screenshot of the default URL (127.0.0.1:8080)
48 | weblook
49 |
50 | # Take a screenshot of a specific URL
51 | weblook https://example.com
52 |
53 | # Take a screenshot after waiting 5 seconds
54 | weblook --wait 5 https://example.com
55 |
56 | # Create a 5-second recording
57 | weblook --record 5 https://example.com
58 |
59 | # Set viewport size to 1920x1080
60 | weblook --size 1920x1080 https://example.com
61 |
62 | # Execute JavaScript before capture
63 | weblook --js "document.body.style.backgroundColor = 'red';" https://example.com
64 |
65 | # Capture console logs to a file
66 | weblook --console-log console.log https://example.com
67 |
68 | # Pipe URL input and output to another command
69 | echo "https://example.com" | weblook --output - | other-command
70 |
71 | # Save output to a specific file
72 | weblook https://example.com --output screenshot.png
73 |
74 | # Show debug output
75 | weblook --debug https://example.com
76 |
77 | # [EXPERIMENTAL] Start as an MCP server
78 | weblook --mcp-server 127.0.0.1:8000
79 |
80 | # [EXPERIMENTAL] Use as an MCP client
81 | weblook --mcp-client http://localhost:8000 https://example.com
82 | ```
83 |
84 | ## Installation
85 |
86 | WebLook is currently not available on crates.io. To install:
87 |
88 | ```bash
89 | # Clone the repository
90 | git clone https://github.com/username/weblook.git
91 | cd weblook
92 |
93 | # Build and install without experimental MCP support
94 | cargo build --release
95 |
96 | # Build with experimental MCP support
97 | cargo build --release --features mcp_experimental
98 |
99 | # The binary will be available at target/release/weblook
100 | # You can copy it to a directory in your PATH for easier access
101 | cp target/release/weblook ~/.local/bin/ # or another directory in your PATH
102 | ```
103 |
104 | ## Requirements
105 |
106 | - ChromeDriver must be installed
107 | - Install ChromeDriver: `sudo apt install chromium-chromedriver` (Ubuntu/Debian)
108 | - The application will automatically start and stop ChromeDriver as needed
109 |
110 | ## Experimental Features
111 |
112 | ### MCP (Model Context Protocol) Integration
113 |
114 | The MCP integration is currently experimental and requires compiling with the `mcp_experimental` feature flag. This feature allows WebLook to:
115 |
116 | 1. Act as an MCP server that other applications can connect to
117 | 2. Act as an MCP client that can connect to other MCP servers
118 |
119 | To enable MCP support:
120 |
121 | ```bash
122 | cargo build --features mcp_experimental
123 | ```
124 |
125 | ## License
126 |
127 | GPL-3.0
128 |
129 | Copyright (C) 2025 Kelsea Blackwell
130 |
--------------------------------------------------------------------------------
/src/capture.rs:
--------------------------------------------------------------------------------
1 | use anyhow::{Context, Result};
2 | use colored::*;
3 | use indicatif::{ProgressBar, ProgressStyle};
4 | use std::io::{self, Write};
5 | use std::path::PathBuf;
6 | use std::process::{Child, Command, Stdio};
7 | use std::time::Duration;
8 | use thirtyfour::{ChromeCapabilities, WebDriver, ChromiumLikeCapabilities};
9 | use tokio::time::sleep;
10 | use url::Url;
11 | use std::net::TcpStream;
12 | use std::fs;
13 |
14 | /// Options for capturing web content
15 | pub struct CaptureOptions {
16 | pub url: String,
17 | pub output_path: PathBuf,
18 | pub wait: u64,
19 | pub size: String,
20 | pub js: Option,
21 | pub debug: bool,
22 | pub is_recording: bool,
23 | pub recording_length: Option,
24 | pub console_log: Option,
25 | }
26 |
27 | /// Viewport size representation
28 | pub struct ViewportSize {
29 | pub width: u32,
30 | pub height: u32,
31 | }
32 |
33 | impl std::str::FromStr for ViewportSize {
34 | type Err = anyhow::Error;
35 |
36 | fn from_str(s: &str) -> Result {
37 | let parts: Vec<&str> = s.split('x').collect();
38 | if parts.len() != 2 {
39 | return Err(anyhow::anyhow!("Invalid viewport size format. Expected WIDTHxHEIGHT"));
40 | }
41 |
42 | let width = parts[0].parse::()
43 | .context("Failed to parse viewport width")?;
44 | let height = parts[1].parse::()
45 | .context("Failed to parse viewport height")?;
46 |
47 | Ok(ViewportSize { width, height })
48 | }
49 | }
50 |
51 | // User agent strings for rotation
52 | const USER_AGENTS: [&str; 2] = [
53 | // Chrome on Windows
54 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
55 | // Chrome on Mac
56 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
57 | ];
58 |
59 | // ChromeDriver management
60 | pub struct ChromeDriverManager {
61 | process: Option,
62 | port: u16,
63 | debug: bool,
64 | }
65 |
66 | impl ChromeDriverManager {
67 | pub fn new(port: u16, debug: bool) -> Self {
68 | ChromeDriverManager {
69 | process: None,
70 | port,
71 | debug,
72 | }
73 | }
74 |
75 | pub fn is_running(&self) -> bool {
76 | TcpStream::connect(format!("127.0.0.1:{}", self.port)).is_ok()
77 | }
78 |
79 | pub fn start(&mut self) -> Result<()> {
80 | if self.is_running() {
81 | if self.debug {
82 | println!("ChromeDriver is already running on port {}", self.port);
83 | }
84 | return Ok(());
85 | }
86 |
87 | if self.debug {
88 | println!("Starting ChromeDriver on port {}...", self.port);
89 | }
90 |
91 | let process = if self.debug {
92 | Command::new("chromedriver")
93 | .arg(format!("--port={}", self.port))
94 | .spawn()
95 | .context("Failed to start ChromeDriver. Make sure it's installed.")?
96 | } else {
97 | Command::new("chromedriver")
98 | .arg(format!("--port={}", self.port))
99 | .stdout(Stdio::null())
100 | .stderr(Stdio::null())
101 | .spawn()
102 | .context("Failed to start ChromeDriver. Make sure it's installed.")?
103 | };
104 |
105 | self.process = Some(process);
106 |
107 | // Wait for ChromeDriver to start
108 | let start_time = std::time::Instant::now();
109 | while !self.is_running() {
110 | if start_time.elapsed() > Duration::from_secs(5) {
111 | return Err(anyhow::anyhow!("Timed out waiting for ChromeDriver to start"));
112 | }
113 | std::thread::sleep(Duration::from_millis(100));
114 | }
115 |
116 | if self.debug {
117 | println!("ChromeDriver started successfully");
118 | }
119 | Ok(())
120 | }
121 | }
122 |
123 | impl Drop for ChromeDriverManager {
124 | fn drop(&mut self) {
125 | if let Some(mut process) = self.process.take() {
126 | if self.debug {
127 | println!("Stopping ChromeDriver...");
128 | }
129 | let _ = process.kill();
130 | let _ = process.wait();
131 | if self.debug {
132 | println!("ChromeDriver stopped");
133 | }
134 | }
135 | }
136 | }
137 |
138 | /// Main capture function that handles both screenshots and recordings
139 | pub async fn perform_capture(options: CaptureOptions) -> Result<()> {
140 | // Determine if we're outputting to stdout
141 | let is_piped = options.output_path.to_str() == Some("-");
142 |
143 | // Start ChromeDriver if not already running
144 | let chromedriver_port = 9515;
145 | let mut chromedriver = ChromeDriverManager::new(chromedriver_port, options.debug);
146 | chromedriver.start()?;
147 |
148 | // Parse URL
149 | let url = Url::parse(&options.url).context("Failed to parse URL")?;
150 |
151 | // Parse viewport size
152 | let viewport = options.size.parse::()?;
153 |
154 | // Determine recording length if recording
155 | let recording_length = if options.is_recording {
156 | options.recording_length.unwrap_or(10)
157 | } else {
158 | 0
159 | };
160 |
161 | if !is_piped && !options.debug {
162 | eprintln!("{}", "Starting WebLook...".bright_cyan());
163 | if options.is_recording {
164 | eprintln!("{} {}", "•".yellow(), format!("Recording {} for {} seconds", url, recording_length).yellow());
165 | } else {
166 | eprintln!("{} {}", "•".yellow(), format!("Taking screenshot of {}", url).yellow());
167 | }
168 | std::io::stderr().flush().ok();
169 | }
170 |
171 | // Set up WebDriver
172 | let driver = setup_webdriver(viewport, chromedriver_port).await?;
173 |
174 | // Navigate to URL and wait
175 | navigate_and_wait(&driver, url, Duration::from_secs(options.wait), is_piped, options.debug).await?;
176 |
177 | // Execute JavaScript if provided
178 | if let Some(js_code) = &options.js {
179 | execute_javascript(&driver, js_code).await?;
180 | }
181 |
182 | // Capture console logs if requested
183 | if let Some(log_path) = &options.console_log {
184 | capture_console_logs(&driver, log_path, is_piped, options.debug).await?;
185 | }
186 |
187 | // Capture screenshot or recording
188 | if options.is_recording {
189 | create_recording(&driver, recording_length, &options.output_path, is_piped, options.debug).await?;
190 | } else {
191 | take_screenshot(&driver, &options.output_path, is_piped, options.debug).await?;
192 | }
193 |
194 | // Clean up
195 | driver.quit().await?;
196 |
197 | // ChromeDriver will be automatically stopped by the Drop implementation
198 |
199 | Ok(())
200 | }
201 |
202 | async fn setup_webdriver(viewport: ViewportSize, port: u16) -> Result {
203 | let mut caps = ChromeCapabilities::new();
204 |
205 | // Select a random user agent
206 | let user_agent_idx = 0; // Just use the first one for testing
207 | let user_agent = USER_AGENTS[user_agent_idx];
208 |
209 | // Configure headless mode and user agent
210 | caps.add_arg("--headless=new")?;
211 | caps.add_arg("--disable-gpu")?;
212 | caps.add_arg(&format!("--window-size={},{}", viewport.width, viewport.height))?;
213 | caps.add_arg(&format!("--user-agent={}", user_agent))?;
214 |
215 | // Enable browser logging - we'll handle this differently
216 | // by using the Chrome DevTools Protocol directly
217 |
218 | // Connect to WebDriver
219 | let driver = WebDriver::new(&format!("http://localhost:{}", port), caps).await?;
220 |
221 | // Set viewport size
222 | driver.set_window_rect(0, 0, viewport.width, viewport.height).await?;
223 |
224 | Ok(driver)
225 | }
226 |
227 | async fn navigate_and_wait(driver: &WebDriver, url: Url, wait_time: Duration, is_piped: bool, debug: bool) -> Result<()> {
228 | // Navigate to the URL
229 | driver.goto(url.as_str()).await?;
230 |
231 | // Wait for the specified time with a nice countdown
232 | if !is_piped {
233 | // Force flush stdout to ensure messages appear
234 | eprintln!("Page loaded. Waiting for {} seconds...", wait_time.as_secs());
235 | std::io::stderr().flush().ok();
236 |
237 | display_countdown(wait_time, "Loading page", debug).await;
238 | } else {
239 | sleep(wait_time).await;
240 | }
241 |
242 | Ok(())
243 | }
244 |
245 | // Display a colorful countdown timer
246 | async fn display_countdown(duration: Duration, message: &str, debug: bool) {
247 | if !debug {
248 | eprintln!("Starting countdown: {} for {} seconds", message, duration.as_secs());
249 | std::io::stderr().flush().ok();
250 |
251 | let pb = ProgressBar::new(duration.as_secs());
252 | pb.set_style(ProgressStyle::default_bar()
253 | .template("{spinner:.green} {msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}s")
254 | .unwrap()
255 | .progress_chars("#>-"));
256 |
257 | pb.set_message(message.bright_green().to_string());
258 |
259 | for i in 1..=duration.as_secs() {
260 | // Rainbow text for the countdown
261 | match i % 6 {
262 | 0 => pb.set_message(message.red().to_string()),
263 | 1 => pb.set_message(message.yellow().to_string()),
264 | 2 => pb.set_message(message.green().to_string()),
265 | 3 => pb.set_message(message.cyan().to_string()),
266 | 4 => pb.set_message(message.blue().to_string()),
267 | _ => pb.set_message(message.magenta().to_string()),
268 | }
269 |
270 | pb.set_position(i);
271 | sleep(Duration::from_secs(1)).await;
272 | }
273 |
274 | pb.finish_with_message(format!("{} complete!", message).green().to_string());
275 | } else {
276 | eprintln!("Waiting for {} seconds...", duration.as_secs());
277 | sleep(duration).await;
278 | }
279 | }
280 |
281 | async fn execute_javascript(driver: &WebDriver, js_code: &str) -> Result<()> {
282 | // Execute the JavaScript code
283 | driver.execute(js_code, vec![]).await?;
284 |
285 | // Give a short time for any JS effects to complete
286 | sleep(Duration::from_millis(500)).await;
287 |
288 | Ok(())
289 | }
290 |
291 | async fn take_screenshot(driver: &WebDriver, output_path: &PathBuf, is_piped: bool, debug: bool) -> Result<()> {
292 | // Take screenshot
293 | if !is_piped && !debug {
294 | eprintln!("{}", "Taking screenshot...".bright_cyan());
295 | std::io::stderr().flush().ok();
296 | }
297 |
298 | let screenshot = driver.screenshot_as_png().await?;
299 |
300 | // Handle output
301 | if output_path.to_str() == Some("-") {
302 | // Write to stdout
303 | io::stdout().write_all(&screenshot)?;
304 | } else {
305 | // Write to file
306 | std::fs::write(output_path, screenshot)?;
307 |
308 | if !is_piped && !debug {
309 | eprintln!("{} {}", "✓".green(), format!("Screenshot saved to {}", output_path.display()).bright_green());
310 | std::io::stderr().flush().ok();
311 | } else if !is_piped && debug {
312 | eprintln!("Screenshot saved to {}", output_path.display());
313 | }
314 | }
315 |
316 | Ok(())
317 | }
318 |
319 | async fn create_recording(driver: &WebDriver, duration_secs: u64, output_path: &PathBuf, is_piped: bool, debug: bool) -> Result<()> {
320 | // Create a temporary directory for frames
321 | let temp_dir = tempfile::tempdir()?;
322 | let frames_per_second = 10;
323 | let total_frames = duration_secs * frames_per_second;
324 | let frame_delay = Duration::from_millis(1000 / frames_per_second);
325 |
326 | // Capture frames
327 | let mut frames = Vec::new();
328 |
329 | if !is_piped {
330 | if !debug {
331 | eprintln!("Starting recording for {} seconds...", duration_secs);
332 | std::io::stderr().flush().ok();
333 |
334 | let pb = ProgressBar::new(duration_secs);
335 | pb.set_style(ProgressStyle::default_bar()
336 | .template("{spinner:.green} {msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}s")
337 | .unwrap()
338 | .progress_chars("#>-"));
339 |
340 | pb.set_message("Recording".bright_green().to_string());
341 |
342 | for i in 0..total_frames {
343 | // Take screenshot
344 | let screenshot_data = driver.screenshot_as_png().await?;
345 | let frame_path = temp_dir.path().join(format!("frame_{:04}.png", i));
346 | std::fs::write(&frame_path, screenshot_data)?;
347 | frames.push(frame_path);
348 |
349 | // Update progress bar with rainbow colors every second
350 | if i % frames_per_second == 0 {
351 | let current_second = i / frames_per_second;
352 | pb.set_position(current_second + 1);
353 |
354 | match current_second % 6 {
355 | 0 => pb.set_message("Recording".red().to_string()),
356 | 1 => pb.set_message("Recording".yellow().to_string()),
357 | 2 => pb.set_message("Recording".green().to_string()),
358 | 3 => pb.set_message("Recording".cyan().to_string()),
359 | 4 => pb.set_message("Recording".blue().to_string()),
360 | _ => pb.set_message("Recording".magenta().to_string()),
361 | }
362 | }
363 |
364 | // Wait for next frame
365 | sleep(frame_delay).await;
366 | }
367 |
368 | pb.finish_with_message("Recording complete!".green().to_string());
369 | eprintln!("{}", "Creating GIF...".bright_cyan());
370 | std::io::stderr().flush().ok();
371 | } else {
372 | eprintln!("Recording for {} seconds...", duration_secs);
373 | for i in 0..total_frames {
374 | // Take screenshot
375 | let screenshot_data = driver.screenshot_as_png().await?;
376 | let frame_path = temp_dir.path().join(format!("frame_{:04}.png", i));
377 | std::fs::write(&frame_path, screenshot_data)?;
378 | frames.push(frame_path);
379 |
380 | // Wait for next frame
381 | sleep(frame_delay).await;
382 | }
383 | eprintln!("Recording complete. Creating GIF...");
384 | }
385 | } else {
386 | for i in 0..total_frames {
387 | // Take screenshot
388 | let screenshot_data = driver.screenshot_as_png().await?;
389 | let frame_path = temp_dir.path().join(format!("frame_{:04}.png", i));
390 | std::fs::write(&frame_path, screenshot_data)?;
391 | frames.push(frame_path);
392 |
393 | // Wait for next frame
394 | sleep(frame_delay).await;
395 | }
396 | }
397 |
398 | // Create GIF from frames
399 | create_gif_from_frames(&frames, output_path, is_piped, debug)?;
400 |
401 | if !is_piped && !debug {
402 | eprintln!("{} {}", "✓".green(), format!("GIF saved to {}", output_path.display()).bright_green());
403 | std::io::stderr().flush().ok();
404 | } else if !is_piped && debug {
405 | eprintln!("GIF saved to {}", output_path.display());
406 | }
407 |
408 | Ok(())
409 | }
410 |
411 | fn create_gif_from_frames(frame_paths: &[PathBuf], output_path: &PathBuf, is_piped: bool, debug: bool) -> Result<()> {
412 | // Load all frames
413 | let mut frames = Vec::new();
414 |
415 | if !is_piped && !debug {
416 | let pb = ProgressBar::new(frame_paths.len() as u64);
417 | pb.set_style(ProgressStyle::default_bar()
418 | .template("{spinner:.green} {msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}")
419 | .unwrap()
420 | .progress_chars("#>-"));
421 |
422 | pb.set_message("Processing frames".bright_blue().to_string());
423 |
424 | for (i, path) in frame_paths.iter().enumerate() {
425 | let img = image::open(path)?;
426 | let frame = img.to_rgba8();
427 | frames.push(frame);
428 |
429 | // Update progress bar with rainbow colors
430 | match i % 6 {
431 | 0 => pb.set_message("Processing frames".red().to_string()),
432 | 1 => pb.set_message("Processing frames".yellow().to_string()),
433 | 2 => pb.set_message("Processing frames".green().to_string()),
434 | 3 => pb.set_message("Processing frames".cyan().to_string()),
435 | 4 => pb.set_message("Processing frames".blue().to_string()),
436 | _ => pb.set_message("Processing frames".magenta().to_string()),
437 | }
438 |
439 | pb.inc(1);
440 | }
441 |
442 | pb.finish_with_message("Frames processed!".green().to_string());
443 | } else if !is_piped && debug {
444 | eprintln!("Processing {} frames...", frame_paths.len());
445 | for path in frame_paths {
446 | let img = image::open(path)?;
447 | let frame = img.to_rgba8();
448 | frames.push(frame);
449 | }
450 | eprintln!("Frames processed. Creating GIF...");
451 | } else {
452 | for path in frame_paths {
453 | let img = image::open(path)?;
454 | let frame = img.to_rgba8();
455 | frames.push(frame);
456 | }
457 | }
458 |
459 | // Create GIF
460 | if output_path.to_str() == Some("-") {
461 | // Write to stdout
462 | let mut buffer = Vec::new();
463 | write_gif_to_buffer(&frames, &mut buffer)?;
464 | io::stdout().write_all(&buffer)?;
465 | } else {
466 | // Write to file
467 | let mut file = std::fs::File::create(output_path)?;
468 | write_gif_to_buffer(&frames, &mut file)?;
469 | }
470 |
471 | Ok(())
472 | }
473 |
474 | fn write_gif_to_buffer(frames: &[image::RgbaImage], buffer: &mut W) -> Result<()> {
475 | let (width, height) = (frames[0].width(), frames[0].height());
476 |
477 | let mut encoder = gif::Encoder::new(buffer, width as u16, height as u16, &[])?;
478 | encoder.set_repeat(gif::Repeat::Infinite)?;
479 |
480 | for frame in frames {
481 | let mut frame_data = Vec::new();
482 | for pixel in frame.pixels() {
483 | frame_data.push(pixel[0]);
484 | frame_data.push(pixel[1]);
485 | frame_data.push(pixel[2]);
486 | }
487 |
488 | let mut frame = gif::Frame::from_rgb(width as u16, height as u16, &frame_data);
489 | frame.delay = 10; // 1/10th of a second
490 | encoder.write_frame(&frame)?;
491 | }
492 |
493 | Ok(())
494 | }
495 | /// Capture browser console logs and save to file
496 | async fn capture_console_logs(driver: &WebDriver, log_path: &str, is_piped: bool, debug: bool) -> Result<()> {
497 | if !is_piped && !debug {
498 | eprintln!("{}", "Capturing console logs...".bright_cyan());
499 | std::io::stderr().flush().ok();
500 | } else if !is_piped && debug {
501 | eprintln!("Capturing console logs...");
502 | }
503 |
504 | // Execute JavaScript to retrieve console logs
505 | // We'll use a custom approach since thirtyfour doesn't directly expose the logs API
506 | let script = r#"
507 | return (function() {
508 | if (!window.console_logs) {
509 | window.console_logs = [];
510 |
511 | // Store original console methods
512 | const originalLog = console.log;
513 | const originalInfo = console.info;
514 | const originalWarn = console.warn;
515 | const originalError = console.error;
516 | const originalDebug = console.debug;
517 |
518 | // Override console methods to capture logs
519 | console.log = function() {
520 | window.console_logs.push({
521 | level: 'INFO',
522 | message: Array.from(arguments).map(arg => String(arg)).join(' '),
523 | timestamp: new Date().toISOString()
524 | });
525 | originalLog.apply(console, arguments);
526 | };
527 |
528 | console.info = function() {
529 | window.console_logs.push({
530 | level: 'INFO',
531 | message: Array.from(arguments).map(arg => String(arg)).join(' '),
532 | timestamp: new Date().toISOString()
533 | });
534 | originalInfo.apply(console, arguments);
535 | };
536 |
537 | console.warn = function() {
538 | window.console_logs.push({
539 | level: 'WARNING',
540 | message: Array.from(arguments).map(arg => String(arg)).join(' '),
541 | timestamp: new Date().toISOString()
542 | });
543 | originalWarn.apply(console, arguments);
544 | };
545 |
546 | console.error = function() {
547 | window.console_logs.push({
548 | level: 'ERROR',
549 | message: Array.from(arguments).map(arg => String(arg)).join(' '),
550 | timestamp: new Date().toISOString()
551 | });
552 | originalError.apply(console, arguments);
553 | };
554 |
555 | console.debug = function() {
556 | window.console_logs.push({
557 | level: 'DEBUG',
558 | message: Array.from(arguments).map(arg => String(arg)).join(' '),
559 | timestamp: new Date().toISOString()
560 | });
561 | originalDebug.apply(console, arguments);
562 | };
563 | }
564 |
565 | return JSON.stringify(window.console_logs);
566 | })();
567 | "#;
568 |
569 | let logs_json = driver.execute(script, vec![]).await?;
570 |
571 | // Convert the JSON string to a proper Value
572 | let logs_value: serde_json::Value = match logs_json.json().as_str() {
573 | Some(json_str) => serde_json::from_str(json_str)?,
574 | None => {
575 | // If we didn't get a string, create an empty array
576 | serde_json::json!([])
577 | }
578 | };
579 |
580 | let mut log_content = String::new();
581 |
582 | if let Some(logs_array) = logs_value.as_array() {
583 | for log in logs_array {
584 | let timestamp = log["timestamp"].as_str().unwrap_or("unknown");
585 | let level = log["level"].as_str().unwrap_or("INFO");
586 | let message = log["message"].as_str().unwrap_or("");
587 |
588 | log_content.push_str(&format!("[{}] [{}] {}\n", timestamp, level, message));
589 | }
590 | }
591 |
592 | // If no logs were captured, add a message
593 | if log_content.is_empty() {
594 | log_content = "No console logs were captured during this session.\n".to_string();
595 | }
596 |
597 | // Write logs to file
598 | fs::write(log_path, log_content)?;
599 |
600 | if !is_piped && !debug {
601 | eprintln!("{} {}", "✓".green(), format!("Console logs saved to {}", log_path).bright_green());
602 | std::io::stderr().flush().ok();
603 | } else if !is_piped && debug {
604 | eprintln!("Console logs saved to {}", log_path);
605 | }
606 |
607 | Ok(())
608 | }
609 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod capture;
2 |
3 | // MCP module is only available when the mcp_experimental feature is enabled
4 | #[cfg(feature = "mcp_experimental")]
5 | pub mod mcp;
6 |
7 | // Re-export main components for easier use in tests
8 | pub use capture::CaptureOptions;
9 |
10 | // Re-export MCP components only when the feature is enabled
11 | #[cfg(feature = "mcp_experimental")]
12 | pub use mcp::{MCPServer, MCPClient};
13 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use anyhow::{Context, Result};
2 | use base64::Engine;
3 | use clap::Parser;
4 | use std::io::{self, Read, Write};
5 | use std::net::SocketAddr;
6 | use std::path::PathBuf;
7 | use tokio::signal;
8 | use url::Url;
9 |
10 | mod capture;
11 | #[cfg(feature = "mcp_experimental")]
12 | mod mcp;
13 |
14 | use capture::CaptureOptions;
15 |
16 | #[derive(Parser, Debug)]
17 | #[command(author, version, about = "Capture screenshots and recordings of web pages")]
18 | struct Args {
19 | /// URL to capture (default: http://127.0.0.1:8080)
20 | #[arg(index = 1)]
21 | url: Option,
22 |
23 | /// Output file path (default: weblook.png or weblook.gif)
24 | #[arg(short, long)]
25 | output: Option,
26 |
27 | /// Wait time before capture in seconds (default: 10)
28 | #[arg(short, long, default_value = "10")]
29 | wait: u64,
30 |
31 | /// Create a recording instead of screenshot (value is length in seconds)
32 | #[arg(short, long)]
33 | record: Option