From e29800df72ac46b8a4103e7197629247b0777bf9 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 16 Jun 2026 14:26:40 +0900 Subject: [PATCH] fix(tab-list): strip zero-width unicode from titles (#33); feat(screenshot): --clip pixel region + documented element capture (#34) #33: some sites prepend runs of ZWJ/word-joiner/invisible-times/BOM to document.title (badging/anti-scrape); left in, they polluted 'tab list', broke text matching, and wrecked column alignment. sanitize_title() now strips zero-width/bidi-format chars at every title ingestion point + get_title(). #34: 'screenshot ' (element capture) already worked but was undocumented; added 'screenshot --clip x,y,w,h' for an explicit pixel region (CDP captureScreenshot clip), documented both in --help. Verified live. --- cli/src/commands.rs | 58 +++++++++++++++++++++++++++++------- cli/src/native/actions.rs | 9 ++++++ cli/src/native/browser.rs | 52 +++++++++++++++++++++++++++----- cli/src/native/screenshot.rs | 15 +++++++++- cli/src/output.rs | 4 +++ 5 files changed, 119 insertions(+), 19 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index f57eab4..b67fbc0 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -907,17 +907,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result = rest - .iter() - .filter(|arg| match **arg { - "--full" | "-f" => { - full_page = true; - false + let mut clip: Option = None; + let mut positional: Vec<&str> = Vec::new(); + let mut i = 0; + while i < rest.len() { + match rest[i] { + "--full" | "-f" => full_page = true, + // `--clip x,y,w,h` captures a pixel region (issue #34). + "--clip" => { + let raw = rest.get(i + 1).ok_or_else(|| ParseError::MissingArguments { + context: "screenshot --clip".to_string(), + usage: "screenshot --clip [path]", + })?; + let nums: Vec = raw + .split(',') + .filter_map(|n| n.trim().parse::().ok()) + .collect(); + if nums.len() != 4 { + return Err(ParseError::InvalidValue { + message: format!("--clip expects 'x,y,w,h' (4 numbers), got '{raw}'"), + usage: "screenshot --clip [path]", + }); + } + clip = Some(json!({ + "x": nums[0], "y": nums[1], "width": nums[2], "height": nums[3] + })); + i += 1; } - _ => true, - }) - .copied() - .collect(); + other => positional.push(other), + } + i += 1; + } let (selector, path) = match (positional.first(), positional.get(1)) { (Some(first), Some(second)) => { // Two args: first is selector, second is path @@ -948,6 +968,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result Result Result String { fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool { if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) { page.url = target.url.clone(); - page.title = target.title.clone(); + page.title = sanitize_title(&target.title); page.target_type = target.target_type.clone(); return true; } @@ -166,6 +166,29 @@ fn resolve_active_index( active_page_index } +/// Strip zero-width / invisible / bidi-format Unicode from a page title before +/// we store it. Some sites prepend runs of ZWJ / word-joiner / invisible-times / +/// BOM to `document.title` (badging, watermarking, anti-scrape); left in, they +/// pollute `tab list`, break text matching, and wreck column alignment (#33). +fn sanitize_title(s: &str) -> String { + s.chars() + .filter(|&c| { + !matches!(c as u32, + 0x00AD // soft hyphen + | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM + | 0x2028 | 0x2029 // line / paragraph separators + | 0x202A..=0x202E // bidi embedding/override + | 0x2060..=0x2064 // word joiner, invisible operators + | 0x2066..=0x2069 // bidi isolates + | 0x180E // Mongolian vowel separator + | 0xFEFF // BOM / ZW no-break space + ) + }) + .collect::() + .trim() + .to_string() +} + /// Best-effort MIME type from a filename extension, for the relay file-upload /// fallback (the page-constructed `File` needs a sensible `type`). Covers the /// common upload kinds; anything unknown falls back to a generic binary type. @@ -773,7 +796,7 @@ impl BrowserManager { target_id: target.target_id.clone(), session_id: attach_result.session_id.clone(), url: target.url.clone(), - title: target.title.clone(), + title: sanitize_title(&target.title), target_type: target.target_type.clone(), }); } @@ -999,7 +1022,7 @@ impl BrowserManager { self.active_page_index = self.resolved_active_index(); if let Some(page) = self.pages.get_mut(self.active_page_index) { page.url = page_url.clone(); - page.title = title.clone(); + page.title = sanitize_title(&title); } self.pin_active_target(); @@ -1061,7 +1084,7 @@ impl BrowserManager { pub async fn get_title(&self) -> Result { let result = self.evaluate_simple("document.title").await?; - Ok(result.as_str().unwrap_or("").to_string()) + Ok(sanitize_title(result.as_str().unwrap_or(""))) } pub async fn get_content(&self) -> Result { @@ -1404,7 +1427,7 @@ impl BrowserManager { target_id: target.target_id.clone(), session_id: attach.session_id.clone(), url: target.url.clone(), - title: target.title.clone(), + title: sanitize_title(&target.title), target_type: target.target_type.clone(), }; self.add_background_page(page.clone()); @@ -1465,7 +1488,7 @@ impl BrowserManager { target_id: target.target_id.clone(), session_id: attach_result.session_id.clone(), url: target.url.clone(), - title: target.title.clone(), + title: sanitize_title(&target.title), target_type: target.target_type.clone(), }); let _ = self.enable_domains(&attach_result.session_id).await; @@ -1508,7 +1531,7 @@ impl BrowserManager { } } if let Some(t) = ti.get("title").and_then(|v| v.as_str()) { - page.title = t.to_string(); + page.title = sanitize_title(t); } } } @@ -1696,7 +1719,7 @@ impl BrowserManager { if let Some(page) = self.pages.get_mut(index) { page.url = url.clone(); - page.title = title.clone(); + page.title = sanitize_title(&title); } let page = &self.pages[index]; @@ -2767,6 +2790,19 @@ mod tests { assert!(!active_index_is_owned(&[], None, 0, &created)); } + #[test] + fn test_sanitize_title() { + // The exact pollution from #33: ZWJ / word-joiner / invisible-times / BOM + // prepended to "GitHub". + let dirty = "\u{200d}\u{2061}\u{200d}\u{2063}\u{200b}\u{2062}\u{feff}GitHub"; + assert_eq!(sanitize_title(dirty), "GitHub"); + // Clean titles (incl. CJK + normal punctuation) pass through untouched. + assert_eq!(sanitize_title("購入手続きへ - メルカリ"), "購入手続きへ - メルカリ"); + assert_eq!(sanitize_title(" Hello World "), "Hello World"); + // Emoji and real content survive; only the invisibles are dropped. + assert_eq!(sanitize_title("✓ Done\u{200b}"), "✓ Done"); + } + #[test] fn test_mime_for_path() { assert_eq!(mime_for_path("a.png"), "image/png"); diff --git a/cli/src/native/screenshot.rs b/cli/src/native/screenshot.rs index f1b2854..b28b2e8 100644 --- a/cli/src/native/screenshot.rs +++ b/cli/src/native/screenshot.rs @@ -60,6 +60,9 @@ pub struct ScreenshotOptions { pub quality: Option, pub annotate: bool, pub output_dir: Option, + /// Explicit pixel region (x, y, width, height) — `--clip` (issue #34). Takes + /// precedence over selector/full_page. + pub clip: Option<(f64, f64, f64, f64)>, } impl Default for ScreenshotOptions { @@ -72,6 +75,7 @@ impl Default for ScreenshotOptions { quality: None, annotate: false, output_dir: None, + clip: None, } } } @@ -187,7 +191,16 @@ async fn capture_screenshot_base64( capture_beyond_viewport: if options.full_page { Some(true) } else { None }, }; - if options.full_page { + if let Some((x, y, width, height)) = options.clip { + // Explicit pixel region wins over selector/full_page (issue #34). + params.clip = Some(Viewport { + x, + y, + width, + height, + scale: 1.0, + }); + } else if options.full_page { let metrics: Value = client .send_command_no_params("Page.getLayoutMetrics", Some(session_id)) .await?; diff --git a/cli/src/output.rs b/cli/src/output.rs index 416beb3..1767c52 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1832,6 +1832,8 @@ Pass --hide-scrollbars false when launching to keep native scrollbars visible. Options: --full, -f Capture full page (not just viewport) + [selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png` + --clip Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png` --annotate Overlay numbered labels on interactive elements. Each label [N] corresponds to ref @eN from snapshot. Prints a legend mapping labels to element roles/names. @@ -1852,6 +1854,8 @@ Examples: chrome-use screenshot chrome-use screenshot ./screenshot.png chrome-use screenshot --full ./full-page.png + chrome-use screenshot ".header .indicator" corner.png # just one element + chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region chrome-use screenshot --annotate # Labeled screenshot + legend chrome-use screenshot --annotate ./page.png # Save annotated screenshot chrome-use screenshot --annotate --json # JSON output with annotations