回到文章列表
技術分享agent360Chrome DevTools ProtocoldevicePixelRatioMCP

Browser MCP 照截圖座標點擊,怎麼是歪的?分享 agent360 PR 修復紀錄

截圖是 device 像素、click_xy 吃 CSS 像素,DPR 1.65 時照圖點會偏 450px 還回報成功。這篇講 PR #55 怎麼用 Page.getLayoutMetrics 和 PNG 檔頭把換算公式附在截圖旁邊,以及維護者補上的捲軸與時間預算修正。

更新於 8 min9 次觀看文章語言

Page.captureScreenshot 給的是 device 像素,click_xy 吃的是 CSS 像素。中間差一個 devicePixelRatio,再加上 client 會偷偷縮圖。這篇記錄我送給 agent360 的 PR #55 怎麼把換算資訊塞回截圖旁邊,以及維護者在上面補的兩刀。

agent360 是什麼、為什麼我用它,上一篇講過了,這裡不重講。

先看症狀

測試頁是 the-internet.herokuapp.com/checkboxes,目標是第一個 checkbox。走的是 agent360 的退路:截圖、從圖上讀位置、用 browser_click_xy 點座標(canvas 畫的介面、怪到找不到元素的頁面,最後都只能走這條)。

click_xy 的工具說明也是這樣教的:「take a screenshot, read the button's position, click its center」。

結果:

  • 從圖上讀到 checkbox 中心在 (1129, 157),丟給 click_xy
  • 回傳 ok: true, landed: true
  • checkbox 沒勾

偏了大約 450px。同一個點換算成 CSS 座標 (685, 95) 再點,就勾到了。

GitHub 上 PR #55 的頁面,紅框 1 標出 Closed 狀態,紅框 2 標出量測表:從 3840 寬的圖讀到 (1129, 157) 點下去沒中但回報 ok:true,換算成 CSS 的 (685, 95) 點到

最煩的是那個 landed: true。它是 agent360 點完之後附上的驗證結果,意思是「這次點擊確實送到了目標元素上」,沒有被別的元素擋掉、也沒有跑進 iframe 之類判斷不了的地方。但 AI 會把它讀成「點到我想點的東西了」,看到成功就繼續往下做,越做越歪。

那 click_xy 說謊嗎? 其實沒有。它不知道你想點什麼,只把座標底下的那個元素(document.elementFromPoint)當成目標,這次是頁面背景。點擊確實送到背景上了,所以是 landed。錯的是餵給它的座標。

為什麼會偏? 三把尺

我的環境:Windows 顯示縮放 150%,Chrome 頁面縮放 110%。

Chrome 的 devicePixelRatio 是兩個相乘:1.5 × 1.1 = 1.65。意思是 1 個 CSS 像素在螢幕上佔 1.65 個實體像素。

然後兩個 API 用的單位不一樣:

  • Page.captureScreenshot(CDP)回的 PNG 是 device 像素。我那次截圖是 3840 × 1907。
  • click_xy 底下是 Input.dispatchMouseEvent,吃 CSS 像素。那時的 CSS 視口寬 2327(高度沒有另外量,用 1907 ÷ 1.65 推回來約 1156)。

3840 ÷ 2327 = 1.65,對得上。

所以從圖上讀到的 x = 1129 直接拿去點,等於在 CSS 座標 1129 的地方點,而 checkbox 其實在 1129 ÷ 1.65 ≈ 684(實際點的是 685)。跟點到的 (685, 95) 比,x 差 444px,y 也差了 62px,合起來就是那 450px。

而且偏移量不是固定的:越靠右下角偏越多(偏移 = 座標 × (1 − 1/1.65),大約是座標的四成)。左上角的按鈕可能剛好點得到,所以這個 bug 不會每次都出現,更難抓。

事情還沒完。client 會縮圖。MCP client 收到一張 3840 寬的圖,不一定原尺寸給模型看,我遇過的一個 client 把它縮到 2000 寬。模型在它看到的那張圖上量出來的座標,是第三套單位。

照截圖讀座標,點下去偏了 450px · 在 Miro 開啟

把三把尺都從左上角 (0, 0) 疊在一起,就看得出偏移為什麼越往右下越大:

同一個位置,三組座標 · 在 Miro 開啟

模型只知道自己那張圖多大;CSS 視口多寬、圖有沒有被縮過,它都不知道。要它自己猜 devicePixelRatio? 不可能。

在 Linux 上也重現得出來

這不是 Windows 才有的事,任何 HiDPI 螢幕或有頁面縮放都會中。用 headless Chromium 加 --force-device-scale-factor=1.65、視口設成 2327 寬(換成 device 像素被取整成 3840,所以 CSS 寬讀回來是 2327.27),就能拿到同一組寬度:截圖 3840 × 1824,cssVisualViewport 2327 寬。

const shot = await cdp.send('Page.captureScreenshot', { format: 'png' });
const png = Buffer.from(shot.data, 'base64');
console.log(png.readUInt32BE(16), png.readUInt32BE(20)); // 3840 1824

const m = await cdp.send('Page.getLayoutMetrics');
console.log(m.cssVisualViewport.clientWidth);             // 2327.27...

Linux 的字型和版面跟 Windows 那次不一樣,checkbox 的位置也不同:這次從圖上讀到的中心是 (1131, 320)。直接拿去點,落點跑到頁尾的連結上,checkbox 沒變;換算成 (685, 194) 再點,checkbox 變成勾選:

headless Chromium 重現:紅框 1 是 checkbox 1 的位置,箭頭指向紅框 2,也就是直接用圖片座標點下去的落點,落在頁尾 Elemental Selenium 連結上

解法:截圖旁邊多附一段換算說明

要換算,模型需要兩樣東西:CSS 視口寬,和「它看到的圖有多寬」。

後者 server 不知道,縮圖是 client 做的。但模型讀座標時,本來就是在它看到的那張圖上量的,那張圖多寬(W),就是它量 x 時用的那把尺。有些 client 還會直接講,例如 Claude Code 用 Read 讀圖片檔時會附一句 original 2560x1800, displayed at 2000x1406(MCP 回傳的截圖有沒有這句,要看 client)。client 沒縮圖的話,W 就等於原圖寬。

所以公式寫成以「圖被顯示的寬度 W」為準,而不是寫死 3840。回答裡附的 image 3840×... 是原圖寬,在會縮圖的 client 上拿它當 W 就錯了,這也是公式特別寫「shown W pixels wide」的原因。(老實說,PR 的實測沒有另外驗證縮圖那條路,公式這樣寫是為了不把 3840 寫死。)

前者交給擴充去量。整條路線長這樣:

截圖回來之前,多量一次 CSS 視口 · 在 Miro 開啟

為什麼用 CDP 量,不跑 window.devicePixelRatio?

最直覺的做法是在頁面裡跑一行 script 讀 window.devicePixelRatio 和 innerWidth。

問題是 agent360 跑頁面 script 是用 chrome.scripting.executeScript 注入 MAIN world,注入後在頁面裡跑的東西會被頁面的 CSP 擋(錯誤是 unsafe-eval)。這不是我猜的:它的程式碼裡有一個 safeExecuteScript,專門接 Content Security Policy/unsafe-eval 的錯誤再改走 debugger,註解寫著 Google、Stripe、Slack 都會遇到;browser_extract_list 被擋時的錯誤訊息甚至直接叫你「use screenshots instead」。被擋的頁面,剛好就是最需要截圖+座標的頁面。

Page.getLayoutMetrics 是 CDP 指令,擴充截圖時本來就掛著 debugger,頁面裡什麼都不用跑,CSP 管不到;而且一個指令就同時給 CSS 和 device 兩組數字。

回傳裡的 cssVisualViewport 是 CSS 像素的視口寬高;同一個回傳裡還有 visualViewport,是同一塊視口的 device 像素版。

為什麼讀 PNG 的 IHDR?

比例要拿「圖本身的寬」去除,這是最直接的事實。PNG 的寬高就寫在檔頭:8 bytes 簽章,接著 IHDR chunk 的長度和型別,再來 4 bytes 寬、4 bytes 高。需要的是前 24 bytes;程式取 base64 的前 44 個字元(解出 33 bytes,夠用),不用解碼整張圖:

function pngSize(dataUrl) {
  try {
    const b64 = String(dataUrl || '').replace(/^data:image\/png;base64,/, '');
    const bin = atob(b64.slice(0, 44));   // signature (8) + IHDR length/type (8) + width/height (8)
    if (bin.slice(12, 16) !== 'IHDR') return null;
    const u32 = (i) => ((bin.charCodeAt(i) << 24) | (bin.charCodeAt(i + 1) << 16) |
                        (bin.charCodeAt(i + 2) << 8) | bin.charCodeAt(i + 3)) >>> 0;
    return { width: u32(16), height: u32(20) };
  } catch (e) {
    return null;
  }
}

讀不到 IHDR 就退回用 visualViewport 的 device 寬度(擴充截圖固定用 PNG,正常走不到這條)。pngSize 我拿量測表那次的原始截圖驗過,那張檔案是 3840 × 1907,pngSize 回 { width: 3840, height: 1907 },跟檔案一致。(那張的高度跟後面重測時的 1105 × 1.65 對不上,應該是兩次的視窗高度不同;寬度兩次都是 3840/2327。)

量不到怎麼辦? 不要讓截圖跟著死

這段量測是附加的。截圖本身才是使用者要的東西,不能因為多量一次就失敗。

所以 Page.getLayoutMetrics 包在 Promise.race 裡,1.5 秒沒回就放棄;任何錯誤都吞掉,回傳 undefined,截圖的回答跟改之前一模一樣:

async function screenshotViewport(tabId, image) {
  try {
    let timer;
    const m = await Promise.race([
      cdpSend(tabId, 'Page.getLayoutMetrics', {}),
      new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 1500); }),
    ]).finally(() => clearTimeout(timer));
    const css = m?.cssVisualViewport;
    if (!css?.clientWidth || !css?.clientHeight) return undefined;
    const png = pngSize(image);
    const deviceWidth = png?.width || m?.visualViewport?.clientWidth;
    if (!deviceWidth) return undefined;
    return {
      css_width: Math.round(css.clientWidth),
      css_height: Math.round(css.clientHeight),
      device_pixel_ratio: Math.round((deviceWidth / css.clientWidth) * 1000) / 1000,
      ...(png ? { image_width: png.width, image_height: png.height } : {}),
    };
  } catch (e) {
    return undefined;
  }
}

(這是我 PR 的版本,後面會講維護者改了哪裡。)

server 那段字怎麼寫

擴充把 viewport 附在回答裡,MCP server 在圖片後面多放一個 text block:

`Viewport: ${vp.css_width}×${vp.css_height} CSS pixels, devicePixelRatio ${vp.device_pixel_ratio}` +
(vp.image_width ? `, image ${vp.image_width}×${vp.image_height} pixels` : '') + '. ' +
`browser_click_xy takes CSS pixels: for a point at (x, y) in an image shown W pixels wide, ` +
`click (x / W × ${vp.css_width}, y / W × ${vp.css_width}).`

注意 y 也是乘 css_width / W,不是 css_height / 圖高。縮放是等比的,寬的比例就是高的比例,寫成同一個係數,模型少算一個數字、也少一個算錯的機會。

browser_screenshot 和 browser_click_xy 的工具說明也一起改,明講兩邊座標單位不同。不然模型照舊的說明走,旁邊那段字它不一定會看。

測試:先紅再綠

test/screenshot-viewport.test.mjs 四個測試,兩層各兩個:

  • 擴充:假的 chrome.debugger,Page.getLayoutMetrics 回 cssVisualViewport 2327.27 寬、visualViewport 3840 寬,斷言回答裡有 viewport,css_width 是 2327、device_pixel_ratio 是 1.65。
  • 擴充:Page.getLayoutMetrics 丟錯,斷言圖還在、viewport 是 undefined。
  • server:真的把 mcp-server/index.js 跑起來,用 WebSocket 假扮擴充回一張圖加 viewport,斷言 text 裡有 2327×1105、1.65、browser_click_xy。
  • server:沒有 viewport 時,回答只有圖,跟以前一樣。

在沒改的程式碼上跑,「no viewport in the answer」和「CSS viewport not stated」兩個紅,兩個「維持原樣」的綠;改完 4/4 綠。

先紅很重要。測試在舊程式碼上也是綠的,就代表它沒測到你修的東西。

全套測試在我這台 Windows 上有 10 個失敗,但 main 上也是同樣那 10 個,跟這個改動無關(PR 裡有寫清楚,免得維護者以為是我弄壞的)。最後在真的 Chrome 上重測:截圖回答寫 2327×1105 CSS pixels、devicePixelRatio 1.65,照那段字換算後的座標點下去,checkbox 從 false 變 true。

維護者補的兩刀

PR 是 10/4 送的。10/7 維護者說 main 為了發版凍結中,先不 merge。10/12 回來看,說這修到一個真問題(照圖點會點到頁面背景,click_xy 還回 ok),然後在上面加了兩個自己的 commit。

一、有傳統捲軸時,比例會歪

我的比例是「PNG 寬 ÷ cssVisualViewport 寬」。問題是截圖含捲軸,cssVisualViewport 不含。

維護者在一台有 16 CSS px 傳統捲軸(會佔掉版面寬度的那種,不是浮在內容上的 overlay 捲軸)的 Chrome 上量,真實比例是 2,我的算法算出 2.033,點靠右邊緣的東西最多偏 15px。

有傳統捲軸時,比例多算了 0.033 · 在 Miro 開啟

我在 PR 註解裡寫「圖本身的寬才是真相」,聽起來很對,但分子分母量的不是同一塊東西。這種錯每一步都合理,合起來就歪了.....

改法是比例改用 Chrome 對同一塊的兩個量測:visualViewport(device 像素)÷ cssVisualViewport(CSS 像素),兩個都不含捲軸。圖的 CSS 尺寸則是圖的尺寸除以這個比例,捲軸那條也一起換算進去:

const png = pngSize(image);
const dpr = m?.visualViewport?.clientWidth ? m.visualViewport.clientWidth / css.clientWidth
  : (png ? png.width / css.clientWidth : 0);
if (!dpr) return undefined;
return {
  css_width: Math.round(png ? png.width / dpr : css.clientWidth),
  css_height: Math.round(png ? png.height / dpr : css.clientHeight),
  device_pixel_ratio: Math.round(dpr * 1000) / 1000,
  ...(png ? { image_width: png.width, image_height: png.height } : {}),
};

css_width 現在是「整張圖換成 CSS 是多寬」,跟 server 那段公式 x / W × css_width 剛好接得起來。那行字開頭還叫 Viewport:,嚴格說數字已經是圖的 CSS 尺寸、不是視口了,名字沒改、意思變了,反正模型只需要那個數字。

png 讀不到時 css_width 還是退回 cssVisualViewport(不含捲軸),等於捲軸問題還在;不過前面講過,擴充截圖固定是 PNG,這條退路正常走不到。

二、量測只能用截圖剩下的時間

server 等擴充回答有 30 秒上限。截圖本身如果拖到 29 秒才回來,我那 1.5 秒的量測接在後面,整個回答就超時了。我的「best-effort」只保證量測失敗不影響截圖,沒保證量測不拖垮截圖。

現在 screenshotViewport 多一個 maxMs,呼叫時傳「截圖預算還剩多少」,取 min(1500, maxMs);剩不到就直接不量。

維護者的第二點裡還帶了一個小改動:我在 click_xy 工具說明裡寫的公式是 x_in_image / image_width * css_width,跟 server 那段用 W 的寫法不一致。維護者也統一成用顯示寬度 W。這個是我自己前後矛盾,被抓到很合理。

結果

PR #55 本身被 close,但 commit 透過 PR #60 合進 main,作者還是我,跟著 1.30.2 發佈。CHANGELOG 多了一行:

a viewport note next to the image | browser_screenshot | The CSS viewport and devicePixelRatio for browser_click_xy, when Chrome reports them in time (#55)

「when Chrome reports them in time」這句,就是上面那兩個 best-effort 的設計留下的痕跡。

如果你也在做給 AI 用的瀏覽器工具

模型不會自己懷疑座標單位,它只會相信 ok: true。

想自己確認? 開 Windows 顯示縮放 150%,叫 Claude 用 agent360 截一張圖,看回答裡有沒有那行 Viewport: ...。

留言(0)

還沒有人留言,來當第一個吧!