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