<!-- Source: https://deepseek-browser-use.io.nexus/06 -->

# 窗口尺寸与页面视口

本文说明浏览器窗口开多大、页面按多大排版、截出来的图是什么尺寸。「窗口尺寸」和「页面视口」在早期实现里是同一个值，现在分开管理 —— 混在一起会得到一个页面比窗口还大的怪状态。

[[toc]]

## 一、两件事必须分开看

| | 由什么决定 | 默认行为 |
| --- | --- | --- |
| **窗口尺寸** | 屏幕的可用工作区 | 宽取工作区的 28/32、高取满 |
| **页面视口** | 配置项 `browser.viewport` | 跟随真实窗口（有头时） |

把它们当成一件事时会出现这种结果：页面按钉死的尺寸排版，窗口按自己的尺寸显示，于是「页面上的视口高度」和「窗口里能看见的高度」对不上。

## 二、窗口尺寸按「可用工作区」算，不是整块屏幕

窗口外框尺寸 = **可用工作区**宽度的 28/32、高度取满。

关键是「可用工作区」而不是「整块屏幕」：任务栏不算进可用高度。用整块屏幕的高度开窗口，窗口底边会被任务栏压住，而压住的那几十像素正好是页面底部。

实测（逻辑分辨率 1707×1067、任务栏占 48 像素的机器）：

| 量 | 值 |
| --- | --- |
| 屏幕分辨率 | 1707 × 1067 |
| 可用工作区 | 1707 × **1019** |
| 开出来的窗口外框 | 1484 × **1019** |

宽度取 28/32 是留出右侧空间，方便人工在同一块屏幕上看到浏览器的实时操作。Chromium 与 Firefox 共用同一套算法，免得两个引擎的窗口大小不一致、排查时看到的页面布局也不一样。

> 量不到屏幕的机器（容器里没有显示设备）会退回一个保守的默认尺寸，而不是让 `start` 失败在这一步。

## 三、`browser.viewport`：页面视口三档取值

| 取值 | 行为 | 什么时候用 |
| --- | --- | --- |
| `window`（默认） | 视口交给真实窗口：页面按窗口的实际内容区排版、拖动窗口页面跟着回流，截图尺寸等于窗口里真实可见的页面区 | 默认。**所见即所得** |
| `fixed` | 一直用按屏幕算出来的固定尺寸 | 需要固定尺寸截图做前后对比，或者站点只认固定视口 |
| `宽x高`（如 `1440x900`） | 钉死这个尺寸 | 想让所有截图尺寸完全一致 |

分隔符 `x` / `*` / `×` 都认，大小写不限，前后空格会忽略。写了不认识的值会直接失败并列出可选值。

**无头模式没有真实窗口可跟随**，所以配 `window` 时会自动退回固定视口，并把原因写进 `start` 回执的 `data.browser.viewport.note` —— 不会静默失效。

`start` 回执里的 `data.browser.viewport` 说明这次用的是哪种：

```json
{"data":{"browser":{"viewport":{"mode":"window","size":"window","note":null}}}}
```

`fixed` 或显式尺寸时是 `{"mode":"fixed","size":"fixed 1484x1019","note":null}`。

> 只影响托管 profile 那条路（Playwright 的持久化上下文）。CDP 那条路（用户自己的 Chrome profile、Edge）本来就不套视口模拟，页面尺寸一直跟着窗口走（见 [05](<https://deepseek-browser-use.io.nexus/05>)）。

## 四、为什么默认从 `fixed` 改成 `window`

视口钉死时，页面按钉死的尺寸排版，而窗口按自己的尺寸显示。实测（旧默认，屏幕 1707×1067）：

| 量 | 实测值 |
| --- | --- |
| 页面自称视口（`window.innerHeight`） | **1067** |
| 窗口外框 | 1019 |
| 真实可见的页面区 | 约 931（1019 减标题栏、标签栏、地址栏约 88 像素） |
| `screenshot` 的像素 | 1484 × **1067** |

`innerHeight > outerHeight` 对真实浏览器窗口是**物理上不可能**的 —— 这就是「视口被模拟、跟窗口脱钩」的铁证。后果有两条：

1. **每页底部约 13% 渲染在窗口外面**，人看不见；
2. 而截图把这部分也拍了进去，于是**喂给视觉模型的画面和用户眼睛看到的不是一回事**；`get_browser_state` 的 `viewport_height` 以及 `pixels_above` / `pixels_below` 也按这个数算，元素「在不在视口里」的判断同样受影响。

改成 `window` 之后同一台机器上的实测：

| 量 | `window`（默认） | `fixed` |
| --- | --- | --- |
| 页面视口（CSS 像素） | 1470 × 925 | 1484 × 1019 |
| 窗口外框 | 1484 × 1020 | 1484 × 1019 |
| `innerHeight` 与 `outerHeight` 的关系 | 正常（小于） | 视口被模拟，两者不再对应 |
| `devicePixelRatio` | 真实系统 DPI 比（这台机器 1.5） | 固定 1.0 |
| 截图与 DOM 坐标 | 需要除以 `devicePixelRatio` 换算 | 1:1 |

## 五、截图的像素尺寸跟着视口策略走

Playwright 默认按**设备像素**出图（`scale: device`），所以出图尺寸 = CSS 视口 × `devicePixelRatio`：

| `browser.viewport` | `devicePixelRatio` | 出图（同一台机器实测） |
| --- | --- | --- |
| `window` | 真实系统 DPI 比（1.5） | 1470×925 的视口 → **2205×1388** |
| `fixed` / `宽x高` | 固定 1.0 | 就是 CSS 视口那么大 |

`window` 下出的是屏幕上真实的物理像素 —— 也就是用户实际看到的那一屏，和「所见即所得」是自洽的，只是文件大一些。**要把图上的像素位置换算成页面坐标**（结构化文本里的坐标、`clip` 参数、`set_viewport` 用的都是 CSS 像素），**就除以 `devicePixelRatio`**。

> 想让它回到 CSS 像素是做不到的，不是没试：Playwright 的截图实现里那一步是
> `clip.scale /= this._browserContext._options.deviceScaleFactor`，而那个选项恰好被 `viewport` 为空禁止设置（见下一节），于是它除以 1、退化成空操作。所以「视口跟随窗口」与「CSS 像素截图」只能二选一 —— 要后者就把 `browser.viewport` 配成 `fixed`。

## 六、实现要点：为什么必须显式传空视口

跟随窗口时用的是 Playwright 协议里的 `noDefaultViewport`（Java API 上是传一个空的 `ViewportSize`）。

**不能靠「什么都不设」来实现跟随窗口**：不设的话 Playwright 会套上它自己的默认视口（宽 1280、高 720），于是又变回「页面尺寸和窗口对不上」，只是数字换了一个。

跟随窗口时有两类参数**不能一起给**：

- `deviceScaleFactor`、`isMobile`、`hasTouch`：Playwright 校验上下文参数时会直接拒绝（报 `"deviceScaleFactor" option is not supported with null "viewport"`），启动失败。它们只在视口钉死时才设。跟随窗口时设备像素比由真实窗口与系统决定，反而更接近用户所见。
- 窗口尺寸：视口为空时 Playwright 不会替我们定窗口大小，所以要自己给一个窗口尺寸参数，否则窗口会用 profile 里上次留下的尺寸或浏览器自己的默认值，那就不可预期了。

Firefox 那条路不额外给窗口尺寸参数：Playwright 给 Firefox 传的是 `-width` / `-height`，自己再补一组会多出一份互相冲突的参数，而这属于引擎差异、不便于在本机实测 —— 结果是 Firefox 的窗口用它自己的默认尺寸，视口跟随这个窗口。

## 七、运行时改视口：`set_viewport`

```json
{"id":"1001","method":"set_viewport","params":{"width":1440,"height":900}}
```

它是**钉死**一个尺寸：钉死之后没有 API 再交还给窗口（`Page.setViewportSize` 只有整数重载），要回到跟随窗口请重新 `start` 一个任务。常见用途是在截图前临时放大视口，一次拿到更多内容。

## 八、元素在视口外时拿不到索引

快照默认只给**视口内**的元素索引。`pixels_above` / `pixels_below` 非 0 就说明页面还有内容在视口外，而 `viewport_height` 就是上面那个视口高度。要一次拿到首屏之外的元素，按优先级：

1. `get_browser_state` 传 `viewportExpansion`（例如 `1500`），把快照范围向外扩；
2. 改用 `click_element_by_selector` / `input_text_by_selector`（不依赖索引）；
3. 滚动到目标位置后重新取快照。

**不可见元素不适用这三条**：隐藏元素既不在快照里，选择器类接口也会因为可操作性检查失败而拿不到它（等满动作超时后返回「没匹配到可操作的元素」），只能用 `execute_js` 直接设值并派发事件。
