1use crate::dom::interactive::InteractiveElement;
2use crate::dom::SearchResults;
3use anyhow::Result;
4use base64::Engine;
5use scraper::{Html, Selector};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ScreenshotResult {
13 pub width: u32,
14 pub height: u32,
15 pub svg: String,
16 pub layout_wireframe: String,
17 pub element_count: usize,
18 #[serde(skip_serializing_if = "Vec::is_empty")]
19 pub png_bytes: Vec<u8>,
20 pub png_base64: String,
21}
22
23pub struct RealBrowserScreenshot;
24
25struct TempFileCleanup(PathBuf);
26impl Drop for TempFileCleanup {
27 fn drop(&mut self) {
28 let _ = std::fs::remove_file(&self.0);
29 }
30}
31
32impl RealBrowserScreenshot {
33 pub fn find_browser_binary() -> Option<PathBuf> {
34 #[cfg(target_os = "windows")]
35 {
36 let candidates = [
37 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
38 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
39 r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
40 r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
41 ];
42 for path_str in &candidates {
43 let p = std::path::Path::new(path_str);
44 if p.exists() {
45 return Some(p.to_path_buf());
46 }
47 }
48 if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
49 let chrome_local = std::path::Path::new(&local_app_data)
50 .join(r"Google\Chrome\Application\chrome.exe");
51 if chrome_local.exists() {
52 return Some(chrome_local);
53 }
54 let edge_local = std::path::Path::new(&local_app_data)
55 .join(r"Microsoft\Edge\Application\msedge.exe");
56 if edge_local.exists() {
57 return Some(edge_local);
58 }
59 }
60 if let Ok(output) = std::process::Command::new("where").arg("chrome").output() {
62 if output.status.success() {
63 let path_str = String::from_utf8_lossy(&output.stdout)
64 .lines()
65 .next()
66 .unwrap_or("")
67 .trim()
68 .to_string();
69 if !path_str.is_empty() {
70 return Some(PathBuf::from(path_str));
71 }
72 }
73 }
74 if let Ok(output) = std::process::Command::new("where").arg("msedge").output() {
75 if output.status.success() {
76 let path_str = String::from_utf8_lossy(&output.stdout)
77 .lines()
78 .next()
79 .unwrap_or("")
80 .trim()
81 .to_string();
82 if !path_str.is_empty() {
83 return Some(PathBuf::from(path_str));
84 }
85 }
86 }
87 }
88
89 #[cfg(target_os = "macos")]
90 {
91 let candidates = [
92 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
93 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
94 "/Applications/Chromium.app/Contents/MacOS/Chromium",
95 ];
96 for path_str in &candidates {
97 let p = std::path::Path::new(path_str);
98 if p.exists() {
99 return Some(p.to_path_buf());
100 }
101 }
102 if let Ok(output) = std::process::Command::new("which")
104 .arg("google-chrome")
105 .output()
106 {
107 if output.status.success() {
108 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
109 if !path_str.is_empty() {
110 return Some(PathBuf::from(path_str));
111 }
112 }
113 }
114 }
115
116 #[cfg(target_os = "linux")]
117 {
118 let candidates = [
119 "google-chrome",
120 "google-chrome-stable",
121 "chromium",
122 "chromium-browser",
123 ];
124 for bin in &candidates {
125 if let Ok(output) = std::process::Command::new("which").arg(bin).output() {
126 if output.status.success() {
127 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
128 if !path_str.is_empty() {
129 return Some(PathBuf::from(path_str));
130 }
131 }
132 }
133 }
134 }
135
136 None
137 }
138
139 fn generate_temp_png_path() -> PathBuf {
140 let temp_dir = std::env::temp_dir();
141 let pid = std::process::id();
142 let time = std::time::SystemTime::now()
143 .duration_since(std::time::UNIX_EPOCH)
144 .map(|d| d.as_millis())
145 .unwrap_or(0);
146 static COUNTER: AtomicUsize = AtomicUsize::new(0);
147 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
148 temp_dir.join(format!("headless_shot_{}_{}_{}.png", pid, time, count))
149 }
150
151 fn is_valid_url(url: &str) -> bool {
152 url.starts_with("http://") || url.starts_with("https://") || url.starts_with("file://")
153 }
154
155 pub async fn capture_real_screenshot_async(
156 url: &str,
157 html_str: &str,
158 width: u32,
159 height: u32,
160 ) -> Option<ScreenshotResult> {
161 if !Self::is_valid_url(url) {
162 return None;
163 }
164
165 let browser_bin = Self::find_browser_binary()?;
166
167 let temp_png = Self::generate_temp_png_path();
168 let mut temp_html = temp_png.clone();
169 temp_html.set_extension("html");
170
171 let mut injected_html = html_str.to_string();
172 let base_tag = format!("<base href=\"{}\">", url);
173 if let Some(idx) = injected_html.find("<head>") {
174 injected_html.insert_str(idx + 6, &base_tag);
175 } else if let Some(idx) = injected_html.find("<head ") {
176 if let Some(close_idx) = injected_html[idx..].find('>') {
177 injected_html.insert_str(idx + close_idx + 1, &base_tag);
178 }
179 } else {
180 injected_html.insert_str(0, &base_tag);
181 }
182
183 let _ = std::fs::write(&temp_html, injected_html);
184
185 let _cleanup_png = TempFileCleanup(temp_png.clone());
186 let _cleanup_html = TempFileCleanup(temp_html.clone());
187
188 let temp_png_str = temp_png.to_string_lossy().to_string();
189 let temp_html_str = format!(
190 "file:///{}",
191 temp_html.to_string_lossy().to_string().replace('\\', "/")
192 );
193
194 let screenshot_arg = format!("--screenshot={}", temp_png_str);
195 let window_size_arg = format!("--window-size={},{}", width, height);
196
197 let target_arg = if url.starts_with("http://") || url.starts_with("https://") {
198 url.to_string()
199 } else {
200 temp_html_str
201 };
202
203 let profile_dir = std::env::temp_dir().join("headless_engine_stealth_profile");
204 let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
205
206 let mut cmd = tokio::process::Command::new(&browser_bin);
207 cmd.arg("--headless=new")
208 .arg("--disable-gpu")
209 .arg("--no-sandbox")
210 .arg(&profile_arg)
211 .arg("--hide-scrollbars")
212 .arg("--disable-blink-features=AutomationControlled")
213 .arg("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36")
214 .arg("--lang=en-US,en")
215 .arg("--virtual-time-budget=8000")
216 .arg(&window_size_arg)
217 .arg(&screenshot_arg)
218 .arg(&target_arg);
219
220 #[cfg(target_os = "windows")]
221 cmd.creation_flags(0x08000000); if let Ok(child) = cmd.spawn() {
224 let _ =
225 tokio::time::timeout(std::time::Duration::from_secs(12), child.wait_with_output())
226 .await;
227 }
228
229 if temp_png.exists() {
230 if let Ok(png_bytes) = std::fs::read(&temp_png) {
231 let png_base64 = format!(
232 "data:image/png;base64,{}",
233 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
234 );
235 return Some(ScreenshotResult {
236 width,
237 height,
238 svg: String::new(),
239 layout_wireframe: String::new(),
240 element_count: 1,
241 png_bytes,
242 png_base64,
243 });
244 }
245 }
246 None
247 }
248
249 pub fn capture_real_screenshot_sync(
250 url: &str,
251 html_str: &str,
252 width: u32,
253 height: u32,
254 ) -> Option<ScreenshotResult> {
255 if !Self::is_valid_url(url) {
256 return None;
257 }
258
259 let browser_bin = Self::find_browser_binary()?;
260
261 let temp_png = Self::generate_temp_png_path();
262 let mut temp_html = temp_png.clone();
263 temp_html.set_extension("html");
264
265 let mut injected_html = html_str.to_string();
266 let base_tag = format!("<base href=\"{}\">", url);
267 if let Some(idx) = injected_html.find("<head>") {
268 injected_html.insert_str(idx + 6, &base_tag);
269 } else if let Some(idx) = injected_html.find("<head ") {
270 if let Some(close_idx) = injected_html[idx..].find('>') {
271 injected_html.insert_str(idx + close_idx + 1, &base_tag);
272 }
273 } else {
274 injected_html.insert_str(0, &base_tag);
275 }
276
277 let _ = std::fs::write(&temp_html, injected_html);
278
279 let _cleanup_png = TempFileCleanup(temp_png.clone());
280 let _cleanup_html = TempFileCleanup(temp_html.clone());
281
282 let temp_png_str = temp_png.to_string_lossy().to_string();
283 let temp_html_str = format!(
284 "file:///{}",
285 temp_html.to_string_lossy().to_string().replace('\\', "/")
286 );
287
288 let screenshot_arg = format!("--screenshot={}", temp_png_str);
289 let window_size_arg = format!("--window-size={},{}", width, height);
290
291 let target_arg = if url.starts_with("http://") || url.starts_with("https://") {
292 url.to_string()
293 } else {
294 temp_html_str
295 };
296
297 let profile_dir = std::env::temp_dir().join("headless_engine_stealth_profile");
298 let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
299
300 let mut cmd = std::process::Command::new(&browser_bin);
301 cmd.arg("--headless=new")
302 .arg("--disable-gpu")
303 .arg("--no-sandbox")
304 .arg(&profile_arg)
305 .arg("--hide-scrollbars")
306 .arg("--disable-blink-features=AutomationControlled")
307 .arg("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36")
308 .arg("--lang=en-US,en")
309 .arg("--virtual-time-budget=8000")
310 .arg(&window_size_arg)
311 .arg(&screenshot_arg)
312 .arg(&target_arg);
313
314 #[cfg(target_os = "windows")]
315 {
316 use std::os::windows::process::CommandExt;
317 cmd.creation_flags(0x08000000); }
319
320 if let Ok(mut child) = cmd.spawn() {
321 let start = std::time::Instant::now();
322 let timeout = std::time::Duration::from_secs(12);
323 loop {
324 if let Ok(Some(_)) = child.try_wait() {
325 break;
326 }
327 if start.elapsed() > timeout {
328 let _ = child.kill();
329 let _ = child.wait();
330 break;
331 }
332 std::thread::sleep(std::time::Duration::from_millis(50));
333 }
334 }
335
336 if temp_png.exists() {
337 if let Ok(png_bytes) = std::fs::read(&temp_png) {
338 let png_base64 = format!(
339 "data:image/png;base64,{}",
340 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
341 );
342 return Some(ScreenshotResult {
343 width,
344 height,
345 svg: String::new(),
346 layout_wireframe: String::new(),
347 element_count: 1,
348 png_bytes,
349 png_base64,
350 });
351 }
352 }
353 None
354 }
355}
356
357pub struct PageRenderer;
358
359impl PageRenderer {
360 pub async fn render_async(
361 url: &str,
362 title: &str,
363 html_str: &str,
364 interactive: &[InteractiveElement],
365 _search_results: Option<&SearchResults>,
366 ) -> ScreenshotResult {
367 let width = 1280;
368 let height = 720;
369
370 if let Some(real_shot) =
372 RealBrowserScreenshot::capture_real_screenshot_async(url, html_str, width, height).await
373 {
374 return real_shot;
375 }
376
377 crate::render::HtmlRenderer::render_html_to_screenshot(
379 url,
380 title,
381 html_str,
382 interactive,
383 width,
384 height,
385 )
386 .await
387 .unwrap_or_else(|_| {
388 Self::render_general_page(url, title, html_str, interactive, width, height)
389 })
390 }
391
392 pub fn render(
393 url: &str,
394 title: &str,
395 html_str: &str,
396 interactive: &[InteractiveElement],
397 _search_results: Option<&SearchResults>,
398 ) -> ScreenshotResult {
399 let width = 1280;
400 let height = 720;
401
402 if let Some(real_shot) =
403 RealBrowserScreenshot::capture_real_screenshot_sync(url, html_str, width, height)
404 {
405 return real_shot;
406 }
407
408 Self::render_general_page(url, title, html_str, interactive, width, height)
409 }
410
411 fn render_general_page(
412 url: &str,
413 title: &str,
414 html_str: &str,
415 interactive: &[InteractiveElement],
416 width: u32,
417 height: u32,
418 ) -> ScreenshotResult {
419 let mut y_offset = 120;
420 let document = Html::parse_document(html_str);
421
422 let mut visual_blocks = Vec::new();
423 if let Ok(sel) = Selector::parse("h1, h2, h3, p, button, a[href], input, li") {
424 for el in document.select(&sel) {
425 let tag = el.value().name();
426 let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
427 if text.is_empty() || text.len() < 2 {
428 continue;
429 }
430
431 let linked_index = interactive.iter().find(|i| i.text == text).map(|i| i.index);
432 visual_blocks.push((tag.to_string(), text, linked_index));
433 if visual_blocks.len() >= 50 {
434 break;
435 }
436 }
437 }
438
439 let title_escaped = Self::xml_escape(&title.chars().take(30).collect::<String>());
440 let url_escaped = Self::xml_escape(url);
441
442 let mut svg = format!(
443 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" width=\"{width}\" height=\"{height}\" style=\"background:#0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\">\n\
444 <rect width=\"{width}\" height=\"80\" fill=\"#1e293b\" />\n\
445 <circle cx=\"25\" cy=\"40\" r=\"7\" fill=\"#ef4444\" />\n\
446 <circle cx=\"45\" cy=\"40\" r=\"7\" fill=\"#f59e0b\" />\n\
447 <circle cx=\"65\" cy=\"40\" r=\"7\" fill=\"#10b981\" />\n\
448 <rect x=\"90\" y=\"24\" width=\"900\" height=\"32\" rx=\"6\" fill=\"#0f172a\" stroke=\"#334155\" stroke-width=\"1\" />\n\
449 <text x=\"105\" y=\"45\" fill=\"#94a3b8\" font-size=\"13\">🔒 {url_escaped}</text>\n\
450 <text x=\"1010\" y=\"45\" fill=\"#e2e8f0\" font-size=\"13\" font-weight=\"600\">{title_escaped}</text>\n\
451 <g transform=\"translate(60, 100)\">\n"
452 );
453
454 let mut wireframe_lines = Vec::new();
455 wireframe_lines.push(
456 "╔══════════════════════════════════════════════════════════════════════════════╗"
457 .to_string(),
458 );
459 wireframe_lines.push(format!("║ URL: {:<72} ║", url));
460 wireframe_lines.push(format!("║ TITLE: {:<70} ║", title));
461 wireframe_lines.push(
462 "╠══════════════════════════════════════════════════════════════════════════════╣"
463 .to_string(),
464 );
465
466 for (tag, text, index_opt) in &visual_blocks {
467 let truncated_text: String = text.chars().take(80).collect();
468 let escaped_text = Self::xml_escape(&truncated_text);
469
470 match tag.as_str() {
471 "h1" => {
472 svg.push_str(&format!(
473 " <text x=\"0\" y=\"{}\" fill=\"#f8fafc\" font-size=\"24\" font-weight=\"bold\">{}</text>\n",
474 y_offset, escaped_text
475 ));
476 wireframe_lines.push(format!("║ # {:<74} ║", truncated_text));
477 y_offset += 40;
478 }
479 "h2" => {
480 svg.push_str(&format!(
481 " <text x=\"0\" y=\"{}\" fill=\"#38bdf8\" font-size=\"18\" font-weight=\"bold\">{}</text>\n",
482 y_offset, escaped_text
483 ));
484 wireframe_lines.push(format!("║ ## {:<73} ║", truncated_text));
485 y_offset += 32;
486 }
487 _ => {
488 let badge = index_opt.map(|i| format!("[{}] ", i)).unwrap_or_default();
489 let color = if index_opt.is_some() {
490 "#60a5fa"
491 } else {
492 "#cbd5e1"
493 };
494 svg.push_str(&format!(
495 " <text x=\"0\" y=\"{}\" fill=\"{}\" font-size=\"13\">{}{}</text>\n",
496 y_offset, color, badge, escaped_text
497 ));
498 if index_opt.is_some() {
499 wireframe_lines.push(format!("║ [LINK] {}{:<67} ║", badge, truncated_text));
500 } else {
501 wireframe_lines.push(format!("║ {:<76} ║", truncated_text));
502 }
503 y_offset += 22;
504 }
505 }
506
507 if y_offset > 1100 {
508 break;
509 }
510 }
511
512 svg.push_str(" </g>\n</svg>");
513 wireframe_lines.push(
514 "╚══════════════════════════════════════════════════════════════════════════════╝"
515 .to_string(),
516 );
517
518 let png_bytes = Self::render_png(&svg, width, height).unwrap_or_default();
519 let png_base64 = if !png_bytes.is_empty() {
520 format!(
521 "data:image/png;base64,{}",
522 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
523 )
524 } else {
525 String::new()
526 };
527
528 ScreenshotResult {
529 width,
530 height,
531 svg,
532 layout_wireframe: wireframe_lines.join("\n"),
533 element_count: visual_blocks.len(),
534 png_bytes,
535 png_base64,
536 }
537 }
538
539 pub fn render_png(svg_str: &str, width: u32, height: u32) -> Result<Vec<u8>> {
540 let opt = resvg::usvg::Options {
541 font_family: "sans-serif".to_string(),
542 ..Default::default()
543 };
544 let tree = resvg::usvg::Tree::from_str(svg_str, &opt)?;
545 let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
546 .ok_or_else(|| anyhow::anyhow!("Failed to allocate raster pixmap"))?;
547
548 resvg::render(
549 &tree,
550 resvg::tiny_skia::Transform::default(),
551 &mut pixmap.as_mut(),
552 );
553 let png_data = pixmap.encode_png()?;
554 Ok(png_data)
555 }
556
557 fn xml_escape(s: &str) -> String {
558 s.replace('&', "&")
559 .replace('<', "<")
560 .replace('>', ">")
561 .replace('"', """)
562 .replace('\'', "'")
563 }
564}