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

# 源码教程：DOM、页面状态与元素读取

浏览器服务把 DOM 变为可供模型阅读的文本，同时保存索引与元素、frame 的对应关系。索引是一次快照的定位线索，不是网站自己的元素 ID。

`highlight` 默认 false，关闭绘制仍分配索引。每次读取前清除旧高亮及其监听器；`screenshot` 默认关闭，显式 true 才请求本次截图。CLI 分别为 `state --highlight` 和 `state --screenshot`，两者独立。

## 1. DOM 采集链

`DomService.buildExpression` 读取 `resources/dom/dom_tree/index.js` 与 `run.js`，组合并缓存执行脚本。`getFrameState` 将脚本分别放到对应 Frame 执行，再把结果解析为 `DOMState`、`DOMElementNode`、`DOMTextNode` 和 `FrameSnapshot`。

```text
Page 的 frame 列表
 → 每个 Frame 执行采集脚本
 → 可见性、交互性、实时表单属性、文本及定位信息
 → 分配不重叠的索引范围
 → Java DOM 模型和 frame 映射
 → 文本渲染、交互元素表与快照存储；截图按需请求
```

可见性、是否可交互以及可否接收点击是不同概念。采集需要考虑布局、视口及扩展范围；点击时仍需检查遮挡。密码属性输出为脱敏标记，但这不意味着截图、URL 和普通输入框都被脱敏。

`get_browser_state` 默认读取主文档及可处理的同源 frame，`includeFrames:true` 将跨域 frame 也纳入。各 frame 共享一个全局索引空间，按索引操作时通过映射回到正确 Frame；不能拿子 frame 内的 XPath 在主文档执行。

```json
{"id":"1001","method":"get_browser_state","params":{"includeFrames":true,"highlight":false,"viewportExpansion":800,"includeElements":true,"maxElements":100}}
```

`maxElements` 控制响应内联元素数量，不应被当作整个 DOM 的元素总数。视口外找不到控件时，先检查滚动距离和视口扩展，再决定滚动或使用选择器。

### 快照一致性与索引有效性

当前实现比较采集前后的页面标记，返回 `snapshotConsistent`、`snapshotAttempts`、`indicesUsable` 和 `pageAppearsBlank`。发现文档或 frame 身份变化、部分读取失败或索引重校验不通过时会清除任务快照、作废索引，并有限重取；仍不一致时补充 `snapshotIssues`、`snapshotHint` 等诊断信息。

**主 frame 为 null 时连快照都建不出来**（页面正在导航/整页重建，或停在一个重定向循环上）：`get_browser_state` / `list_frames` 会把它归成可重试的 `PAGE_NAVIGATING`，而不是让底层的 `Cannot invoke "...Frame.childFrames()" because "frame" is null`（或构建期的 `Index 0 out of bounds for length 0`）直接冒成「不确定」。见[页面正在导航与主 Frame 缺失](<https://deepseek-browser-use.io.nexus/36>)。

判定一份索引还算不算数，看的是**结构变更 + 逐元素重校验**，而不是「页面总共变过多少次」：

- 索引最终解析成的是**位置型 xpath**（形如 `html/body/div[2]/form/input[1]`，只有同级序号、没有 class 谓词），所以文字改写、`class`/`style` 变化这类**内容类**变更不会挪动任何同级序号，不影响索引；
- 只有**结构类**变更（`childList` 里增删了元素节点）才会挪动同级序号，此时索引区间不再可信，一律作废；
- 除此之外，采集到的每个索引会按它的 xpath 再查一次，比对标签与 `id`/`name`，对不上才作废。

采集期间的变更次数按增量返回，便于判断页面到底动的是什么：`snapshotMutations`、`snapshotStructuralMutations`、`snapshotContentMutations`。只有内容变动、结构未变且重校验通过时，回执另给 `snapshotNote` 说明「为什么这次没有作废」。传 `strictSnapshot:true` 可以恢复旧的「读取期间任何 DOM 变更即作废」判据，用于排查。

调用方必须检查 `indicesUsable`，不能仅看 `ok:true` 就使用索引。遇到 false 时等待目标内容后重新获取状态；`pageAppearsBlank` 只是此次文本为空的提示，不能直接认定网站没有内容或业务已经失败。

## 2. 页面级读取各有用途

| 命令 | 实现与结果 |
| --- | --- |
| `get_browser_state` | 生成 DOM 快照，保存索引，返回页面文本、页签和元素信息，并留档 |
| `list_frames` | 列出当前页面 frame 的序号、地址等信息；`refresh` 控制相关刷新行为 |
| `get_page_snapshot` | 汇总页面快照，可附控制台和请求信息，便于一次获取诊断上下文 |
| `diff_dom_text` | 重新生成快照，与之前的文本按行比较；它会更新快照，不是单纯读取旧缓存 |
| `get_interactive_map` | 返回当前可交互元素映射，适合检查完整定位信息 |
| `get_form_state` | 遍历表单控件，读取实时 value、checked、disabled、readonly、错误提示等；可限制 selector、隐藏项和数量 |
| `extract_structured_data` | 当前实现读取 `document.body.innerText`，按选项收集链接；query 被保留为结果信息，不在此命令中调用大模型抽取 |
| `extract_markdown` | 取页面（或选择器命中的第一个元素）的 HTML 并转成 Markdown，表格按 GFM 表格输出；同样不调用大模型 |

两条命令都不调用大模型：`extract_structured_data` 返回纯文本，`extract_markdown` 返回 Markdown，仅此区别。不要把命令名中的 structured 当成“已经按 query 生成业务 JSON”。如需特定结构，应在上层明确增加解析或模型步骤，见 [正文提取](<https://deepseek-browser-use.io.nexus/17>)。

页面上的表格**不要用 `extract_structured_data` 读**：它的正文取自 `innerText`，会把单元格逐行摊平、列对不上、`colspan` 丢失，改用 `extract_markdown`。

## 3. 元素读取共用 read 包装

多数读取方法调用 `read(browserId, index, action, resultKey, reader)`：先找任务与索引，再执行读取函数，最后将结果写到指定 data 字段。下面是当前源码的调用形式：

```java
return read(browserId, index, "get_element_value", "value",
    locator -> locator.inputValue());
```

| 命令 | 底层读取 | 主要结果字段 |
| --- | --- | --- |
| `get_element_text` | `Locator.innerText()` | `text` |
| `get_element_html` | `Locator.innerHTML()`，不是 outerHTML | `html` |
| `get_element_value` | `Locator.inputValue()` | `value` |
| `get_element_attribute` | `Locator.getAttribute(name)` | `value` |
| `is_visible` | `Locator.isVisible()` | `visible` |
| `is_enabled` | `Locator.isEnabled()` | `enabled` |
| `is_checked` | `Locator.isChecked()` | `checked` |
| `get_element_count` | 指定 Frame 内按 selector 统计 | 数量结果；无匹配与调用失败要区分 |
| `get_element_box` | 获取元素 bounding box | 坐标与尺寸；无布局信息时不能虚构坐标 |
| `get_element_listeners` | `ListenerProbe` 尝试协议探测和框架线索 | `found`、监听信息及检测说明；未知不等于没有监听 |

`inputValue` 与 HTML attribute 不同：用户修改输入框后，初始 value 属性可能没变。状态读取必须使用适合控件的实时属性。对非表单元素调用 inputValue、对非复选控件调用 isChecked，也可能因类型不匹配失败。

## 4. 索引生命周期与验证

在页面更新、导航或页签切换后重新取快照。即使新页面也有索引 8，它也不一定是上次的索引 8。实现中的旧快照提示和 frame 映射旨在帮助排障，不提供跨任意页面变更的稳定句柄。

本地 fixture 应包含输入框、复选框、隐藏字段、只读控件和 iframe。修改 property 后验证文本和 `get_form_state` 显示新值；验证密码脱敏、子 frame 索引不冲突，以及 `get_element_html` 只返回内部 HTML。另用两段等长文本测试差异检测，避免只比较字符串长度。

## 注册参数与 Java 入口

以下按 `CommandTable` 实际读取参数整理。`*` 表示注册层使用必填读取器；其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明，例如上传文件来源、元素定位二选一。外层 `id` 不重复列出。

| 命令 | params 字段 | Java 入口 |
| --- | --- | --- |
| `get_browser_state` | `highlight`、`viewportExpansion`、`includeElements`、`maxElements`、`includeFrames`、`strictSnapshot`、`screenshot` | `getBrowserState` |
| `list_frames` | `refresh` | `listFrames` |
| `get_page_snapshot` | `includeConsole`、`includeRequests`、`requestFilter` | `getPageSnapshot` |
| `diff_dom_text` | `highlight`、`viewportExpansion` | `diffDomText` |
| `get_interactive_map` | 无 | `getInteractiveMap` |
| `get_form_state` | `selector`、`includeHidden`、`max` | `getFormState` |
| `extract_structured_data` | `query`、`extractLinks` | `extractStructuredData` |
| `extract_markdown` | `selector`、`frame`、`includeLinks`、`maxChars` | `extractMarkdown` |
| `get_element_text` | `index`、`selector`、`canvasOnly`、`frame` | `getElementText` |
| `get_element_html` | `index`* | `getElementHtml` |
| `get_element_value` | `index`* | `getElementValue` |
| `get_element_attribute` | `index`*、`name`* | `getElementAttribute` |
| `get_element_listeners` | `index`、`selector`、`frame` | `getElementListeners` |
| `get_element_count` | `selector`*、`frame` | `getElementCount` |
| `get_element_box` | `index`* | `getElementBox` |
| `is_visible` | `index`* | `isVisible` |
| `is_enabled` | `index`* | `isEnabled` |
| `is_checked` | `index`* | `isChecked` |

<!-- current-source-tutorial -->
## 当前源码：命令注册与执行

先在本章上半部分理解行为，再按命令展开实现。注册代码说明 JSON 参数如何传给 Java；服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数，不应脱离原类直接粘贴编译。

### `get_browser_state`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_browser_state",
        (svc, id, a) -> svc.getBrowserState(id, a.getBoolean("highlight"), a.getInteger("viewportExpansion"),
            a.getBoolean("includeElements"), a.getInteger("maxElements"), a.getBoolean("includeFrames"),
            a.getBoolean("strictSnapshot"), a.getBoolean("screenshot")));
```

:::

::: details 展开 getBrowserState 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getBrowserState(Long browserId, Boolean highlight, Integer viewportExpansion,
      Boolean includeElements, Integer maxElements, Boolean includeFrames, Boolean strictSnapshot, Boolean screenshot) {
    return getBrowserStateAttempt(browserId, highlight, viewportExpansion, includeElements, maxElements,
        includeFrames, Boolean.TRUE.equals(strictSnapshot), screenshot, 1);
  }
```

:::

### `list_frames`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("list_frames", (svc, id, a) -> svc.listFrames(id, a.getBoolean("refresh")));
```

:::

::: details 展开 listFrames 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo listFrames(Long browserId, Boolean refresh) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    boolean rebuild = refresh == null || refresh;
    if (rebuild) {
      try {
        recordSnapshot(inst, DomService.getFrameState(inst.page, false, 0));
      } catch (PlaywrightException e) {
        return RespBodyVo.fail("list_frames 失败：读取页面 frame 失败（" + briefMessage(e.getMessage()) + "）");
      }
    }
    List<Kv> frames = frameList(inst.domState);
    if (frames.isEmpty()) {
      // 快照不是带 frame 的那种时,至少把 frame 的 URL/名称给出来,别让调用方以为「一个 frame 都没有」
      for (DomService.FrameNode node : DomService.frameTree(inst.page)) {
        Kv item = Kv.by("index", frames.size()).set("url", safeFrameUrl(node.frame))
            .set("name", safeFrameName(node.frame)).set("isMain", node.parentIndex < 0).set("depth", node.depth);
        if (node.parentIndex >= 0) {
          item.set("parentIndex", node.parentIndex);
        }
        frames.add(item);
      }
    }
    Kv data = Kv.by("count", frames.size()).set("frames", frames)
        .set("note", "index 0 是主 frame;跨域 iframe 也能读到 URL(这是识别它的主要依据)。"
            + "要读 iframe 里的元素用 get_browser_state 的 includeFrames:true");
    return RespBodyVo.ok(data);
  }
```

:::

### `get_page_snapshot`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_page_snapshot", (svc, id, a) -> svc.getPageSnapshot(id, a.getBoolean("includeConsole"),
        a.getBoolean("includeRequests"), optStr(a, "requestFilter")));
```

:::

::: details 展开 getPageSnapshot 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getPageSnapshot(Long browserId, Boolean includeConsole, Boolean includeRequests,
      String requestFilter) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    Kv kv = Kv.by("url", inst.page.url()).set("title", safeTitle(inst.page)).set("tabs", tabs(inst))
        .set("dialog", inst.lastDialog).set("loading", isPageLoading(inst));
    if (includeConsole != null && includeConsole) {
      kv.set("logs", new ArrayList<>(inst.consoleLogs));
      kv.set("errors", new ArrayList<>(inst.pageErrors));
    }
    if (includeRequests != null && includeRequests) {
      kv.set("requests", filterRequests(inst, requestFilter));
    }
    return RespBodyVo.ok(kv);
  }
```

:::

### `diff_dom_text`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("diff_dom_text",
        (svc, id, a) -> svc.diffDomText(id, a.getBoolean("highlight"), a.getInteger("viewportExpansion")));
```

:::

::: details 展开 diffDomText 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo diffDomText(Long browserId, Boolean highlight, Integer viewportExpansion) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    String previous = inst.lastDomText;
    RespBodyVo current = buildState(inst, highlight, viewportExpansion);
    if (!current.isOk() || !(current.getData() instanceof Kv)) {
      return current;
    }
    Kv data = (Kv) current.getData();
    String text = data.getStr("text");
    Kv diff = Kv.by("url", data.get("url")).set("title", data.get("title")).set("tabs", data.get("tabs"));
    if (previous == null) {
      diff.set("first", true).set("changed", true).set("added", firstLines(text)).set("removed", new ArrayList<>());
      return RespBodyVo.ok(diff);
    }
    Map<String, Integer> before = countLines(previous);
    Map<String, Integer> after = countLines(text);
    List<String> added = new ArrayList<>();
    List<String> removed = new ArrayList<>();
    for (Map.Entry<String, Integer> entry : after.entrySet()) {
      int delta = entry.getValue() - before.getOrDefault(entry.getKey(), 0);
      for (int i = 0; i < delta && added.size() < MAX_DIFF_LINES; i++) {
        added.add(entry.getKey());
      }
    }
    for (Map.Entry<String, Integer> entry : before.entrySet()) {
      int delta = entry.getValue() - after.getOrDefault(entry.getKey(), 0);
      for (int i = 0; i < delta && removed.size() < MAX_DIFF_LINES; i++) {
        removed.add(entry.getKey());
      }
    }
    diff.set("first", false).set("changed", !added.isEmpty() || !removed.isEmpty());
    diff.set("added", added).set("removed", removed);
    return RespBodyVo.ok(diff);
  }
```

:::

### `get_interactive_map`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_interactive_map", (svc, id, a) -> svc.getInteractiveMap(id));
```

:::

::: details 展开 getInteractiveMap 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getInteractiveMap(Long browserId) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    if (inst.domState == null) {
      // 以前这里直接失败,要求调用方「先调 get_browser_state」——多一次往返,而且很容易漏。
      // 索引本来就是本命令的产物,自己建一次快照即可(不落盘,不产生截图/文本垃圾文件)。
      RespBodyVo built = buildState(inst, false, 0);
      if (!built.isOk()) {
        return built;
      }
    }
    List<Kv> items = interactiveElements(inst);
    return RespBodyVo.ok(Kv.by("count", items.size()).set("elements", items)
        .set("source", "get_browser_state 快照(索引与 click_element_by_index / input_text / "
            + "check_element_by_index 等按索引命令共用)"));
  }
```

:::

### `get_form_state`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_form_state", (svc, id, a) -> svc.getFormState(id, optStr(a, "selector"), a.getBoolean("includeHidden"),
        a.getInteger("max")));
```

:::

::: details 展开 getFormState 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getFormState(Long browserId, String selector, Boolean includeHidden, Integer max) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    JSONObject args = new JSONObject();
    args.put("selector", selector);
    args.put("includeHidden", includeHidden != null && includeHidden);
    args.put("max", max == null || max <= 0 ? 200 : max);
    Object result;
    try {
      result = inst.page.evaluate(FORM_STATE, args);
    } catch (PlaywrightException e) {
      return RespBodyVo.fail("get_form_state 失败：" + briefMessage(e.getMessage()));
    }
    if (!(result instanceof Map)) {
      return RespBodyVo.fail("get_form_state 失败：页面没有返回表单信息");
    }
    Kv data = new Kv();
    data.putAll((Map) result);
    data.set("url", safeUrl(inst.page));
    data.set("scope", selector == null || selector.isBlank() ? "document" : selector);
    return RespBodyVo.ok(data);
  }
```

:::

### `extract_structured_data`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("extract_structured_data",
        (svc, id, a) -> svc.extractStructuredData(id, optStr(a, "query"), optBool(a, "extractLinks")));
```

:::

::: details 展开 extractStructuredData 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo extractStructuredData(Long browserId, String query, boolean extractLinks) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    try {
      Object text = inst.page
          .evaluate("() => {" + " const c = document.getElementById('playwright-highlight-container');"
              + " const prev = c ? c.style.display : null;" + " if (c) c.style.display = 'none';"
              + " const t = document.body ? document.body.innerText.slice(0, 20000) : '';"
              + " if (c) c.style.display = prev || '';" + " return t; }");
      Kv kv = Kv.by("query", query).set("text", text);
      if (extractLinks) {
        kv.set("links", linksIn(inst.page.mainFrame()));
      }
      return RespBodyVo.ok(kv);
    } catch (PlaywrightException e) {
      return RespBodyVo.fail("extract_structured_data 失败：" + briefMessage(e.getMessage()));
    }
  }

  /** 页面链接清单:文本截到 80 字符,`href` 用解析后的绝对地址 */
  private static Object linksIn(Frame frame) {
    return frame.evaluate("() => Array.from(document.querySelectorAll('a[href]'))"
        + ".map(a => ({text: (a.innerText || '').trim().slice(0, 80), href: a.href}))");
  }
```

:::

### `extract_markdown`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("extract_markdown", (svc, id, a) -> svc.extractMarkdown(id, optStr(a, "selector"), optStr(a, "frame"),
        optBool(a, "includeLinks"), a.getInteger("maxChars")));
```

:::

::: details 展开 extractMarkdown 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo extractMarkdown(Long browserId, String selector, String frame, boolean includeLinks,
      Integer maxChars) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    int limit = maxChars == null || maxChars <= 0 ? DEFAULT_MARKDOWN_MAX_CHARS : maxChars;
    try {
      Frame target = frameOf(inst, frame);
      String html;
      String source;
      if (selector == null || selector.trim().isEmpty()) {
        Object body = target.evaluate("() => document.body ? document.body.innerHTML : ''");
        html = body == null ? "" : String.valueOf(body);
        source = "body";
      } else {
        Locator locator = target.locator(selector);
        if (locator.count() == 0) {
          return RespBodyVo.fail("extract_markdown 失败：选择器没有匹配到元素: " + selector);
        }
        Object outer = locator.first().evaluate("el => el.outerHTML");
        html = outer == null ? "" : String.valueOf(outer);
        source = selector;
      }

      String markdown = HtmlMarkdown.toMarkdown(html);
      int length = markdown.length();
      boolean truncated = length > limit;
      Kv kv = Kv.by("markdown", truncated ? markdown.substring(0, limit) : markdown).set("length", length)
          .set("truncated", truncated).set("source", source).set("url", inst.page.url())
          .set("title", inst.page.title());
      if (includeLinks) {
        kv.set("links", linksIn(target));
      }
      return RespBodyVo.ok(kv);
    } catch (IllegalArgumentException e) {
      return RespBodyVo.fail("extract_markdown 失败：" + e.getMessage());
    } catch (PlaywrightException e) {
      return RespBodyVo.fail("extract_markdown 失败：" + briefMessage(e.getMessage()));
    }
  }
```

转换本身与浏览器无关，单独成类、可独立测试：先用 jsoup 摘掉不该进正文的节点，再交给转换器。

```java
public class HtmlMarkdown {

  private static final String HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";

  private static final String NON_CONTENT_SELECTOR = "script, style, noscript, template, head";

  public static String toMarkdown(String html) {
    if (html == null || html.isEmpty()) {
      return "";
    }
    Document doc = Jsoup.parse(html);
    doc.select(NON_CONTENT_SELECTOR).remove();
    doc.select("#" + HIGHLIGHT_CONTAINER_ID).remove();

    Element body = doc.body();
    String source = body != null && !body.html().isEmpty() ? body.html() : doc.html();

    FlexmarkHtmlConverter converter = FlexmarkHtmlConverter.builder().build();
    return tidy(converter.convert(source));
  }
}
```

:::

### `get_element_text`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_text", (svc, id, a) -> svc.getElementText(id, a.getInteger("index"), optStr(a, "selector"),
        Boolean.valueOf(optBool(a, "canvasOnly")), optStr(a, "frame")));
```

:::

::: details 展开 getElementText 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementText(Long browserId, Integer index, String selector, Boolean canvasOnly,
      String frame) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    if ((selector == null || selector.isBlank()) && index == null) {
      return RespBodyVo.fail("get_element_text 需要 index 或 selector 之一");
    }
    Locator locator;
    String target;
    if (selector != null && !selector.isBlank()) {
      try {
        locator = locatorIn(inst, frame, selector);
      } catch (IllegalArgumentException e) {
        return RespBodyVo.fail("get_element_text 失败：" + e.getMessage());
      }
      int count = locator.count();
      if (count == 0) {
        return RespBodyVo.fail("get_element_text 没匹配到元素: 选择器 " + selector + frameSuffix(frame));
      }
      target = "选择器 " + selector + frameSuffix(frame);
    } else {
      locator = locatorOf(inst, index);
      if (locator == null) {
        return RespBodyVo.fail("get_element_text 索引越界: " + index + indexHint(inst));
      }
      target = "index=" + index;
    }
    Kv data = new Kv();
    data.set("target", target);
    String text;
    try {
      text = locator.innerText();
      data.set("text", text);
    } catch (PlaywrightException e) {
      return RespBodyVo.fail(actionFailure("get_element_text", e));
    }
    if (Boolean.TRUE.equals(canvasOnly) && (text == null || text.isBlank())) {
      Boolean onlyCanvas = null;
      try {
        Object raw = locator.evaluate("el => { if (!el.querySelector) return null;"
            + " const canvases = el.tagName === 'CANVAS' ? [el] : Array.from(el.querySelectorAll('canvas'));"
            + " if (!canvases.length) return false;"
            + " const textish = Array.from(el.querySelectorAll('*')).filter(n => n.children.length === 0"
            + " && (n.textContent || '').trim().length > 0).length;"
            + " return textish === 0; }");
        onlyCanvas = raw instanceof Boolean ? (Boolean) raw : null;
      } catch (PlaywrightException e) {
        // 诊断取不到不算失败:文字那一段已经如实返回了
        onlyCanvas = null;
      }
      if (onlyCanvas != null) {
        data.set("canvasOnly", onlyCanvas);
        if (Boolean.TRUE.equals(onlyCanvas)) {
          data.set("hint", "这个元素里的内容画在 <canvas> 上(图表 / 地图 / 看板),没有文字节点:"
              + "innerText 必然是空的。改走像素手段(截图后用 OCR),"
              + "或去读同一页面上**同时是文字**的地方(图例、数据面板、十字线浮标对应的 DOM 文本)");
        }
      }
    }
    return RespBodyVo.ok(data);
  }
```

:::

这个命令有两个地方与同类「按索引读取」的命令不同，都是为了把「读不到」说清楚：

- **`selector` 与 `index` 二选一**。`canvas` 通常**不进快照**（不可交互的元素拿不到索引），所以只能按选择器够到它；
  找不到匹配时直接报「没匹配到元素」，不会等满可操作性超时。
- **`canvasOnly` 是诊断开关**。`innerText` 只认真正的文字节点，而图表 / 地图 / 看板上的刻度与浮标是画在
  `<canvas>` 上的像素，这里必然是空。开关打开且确实读不到文字时，回执追加 `canvasOnly`（这里是不是基本只有
  canvas）与 `hint`（给出路：截图像素 + OCR，或去读旁边同时是 DOM 文字的地方）。诊断取不到不算失败，
  文字那一段照常返回。

回执里的 `target` 说明这次定位到的是谁（`index=…` 或 `选择器 …`），便于排查「读到的是不是我以为的那个元素」。

### `get_element_html`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_html", (svc, id, a) -> svc.getElementHtml(id, reqInt(a, "index")));
```

:::

::: details 展开 getElementHtml 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementHtml(Long browserId, int index) {
    return read(browserId, index, "get_element_html", "html", (locator) -> locator.innerHTML());
  }
```

:::

### `get_element_value`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_value", (svc, id, a) -> svc.getElementValue(id, reqInt(a, "index")));
```

:::

::: details 展开 getElementValue 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementValue(Long browserId, int index) {
    return read(browserId, index, "get_element_value", "value", (locator) -> locator.inputValue());
  }
```

:::

### `get_element_attribute`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_attribute", (svc, id, a) -> svc.getElementAttribute(id, reqInt(a, "index"), reqStr(a, "name")));
```

:::

::: details 展开 getElementAttribute 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementAttribute(Long browserId, int index, String name) {
    return read(browserId, index, "get_element_attribute", "value", (locator) -> locator.getAttribute(name));
  }
```

:::

### `get_element_listeners`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_listeners", (svc, id, a) -> svc.getElementListeners(id, a.getInteger("index"),
        optStr(a, "selector"), optStr(a, "frame")));
```

:::

::: details 展开 getElementListeners 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementListeners(Long browserId, Integer index, String selector, String frame) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    if ((selector == null || selector.isBlank()) && index == null) {
      return RespBodyVo.fail("get_element_listeners 需要 index 或 selector 之一");
    }
    Locator locator;
    String target;
    if (selector != null && !selector.isBlank()) {
      try {
        locator = locatorIn(inst, frame, selector);
      } catch (IllegalArgumentException e) {
        return RespBodyVo.fail("get_element_listeners 失败：" + e.getMessage());
      }
      target = "selector=" + selector + frameSuffix(frame);
    } else {
      locator = locatorOf(inst, index);
      target = "index=" + index;
      if (locator == null) {
        return RespBodyVo.fail("get_element_listeners 索引越界: " + index + indexHint(inst));
      }
    }
    Kv probe;
    Frame probeFrame = null;
    String elementScript;
    try {
      if (locator.count() == 0) {
        return RespBodyVo.ok(Kv.by("found", false).set("target", target)
            .set("note", "没找到这个元素:选择器/索引可能已经过期,重新 get_browser_state 再试"));
      }
      // CDP 的权威探测需要 frame 与本元素的解析表达式;拿不到就退回启发式,并把结论标成「未知」
      if (selector != null && !selector.isBlank()) {
        probeFrame = frame == null || frame.isBlank() ? inst.page.mainFrame() : frameOf(inst, frame);
      } else {
        probeFrame = frameForIndex(inst, inst.domState, index);
        if (probeFrame == null) {
          probeFrame = inst.page.mainFrame();
        }
      }
      elementScript = elementResolveScript(inst, index, selector);
      probe = ListenerProbe.probe(locator, probeFrame, elementScript);
    } catch (IllegalArgumentException e) {
      return RespBodyVo.fail("get_element_listeners 失败：" + e.getMessage());
    } catch (PlaywrightException e) {
      return RespBodyVo.fail("get_element_listeners 失败：" + briefMessage(e.getMessage()));
    }
    if (!Boolean.TRUE.equals(probe.getBoolean("found"))) {
      return RespBodyVo.ok(Kv.by("found", false).set("target", target)
          .set("note", "没找到这个元素:选择器/索引可能已经过期,重新 get_browser_state 再试"));
    }
    Kv data = Kv.by("found", true).set("target", target).set(probe);
    String note = ListenerProbe.note(probe);
    if (note != null) {
      data.set("note", note);
    }
    return RespBodyVo.ok(data);
  }
```

:::

### `get_element_count`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_count", (svc, id, a) -> svc.getElementCount(id, reqStr(a, "selector"), optStr(a, "frame")));
```

:::

::: details 展开 getElementCount 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementCount(Long browserId, String selector, String frame) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    try {
      return RespBodyVo.ok(Kv.by("count", frameOf(inst, frame).locator(selector).count()));
    } catch (IllegalArgumentException e) {
      return RespBodyVo.fail("get_element_count 失败：" + e.getMessage());
    } catch (PlaywrightException e) {
      return RespBodyVo.fail("get_element_count 失败：" + briefMessage(e.getMessage()));
    }
  }
```

:::

### `get_element_box`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_element_box", (svc, id, a) -> svc.getElementBox(id, reqInt(a, "index")));
```

:::

::: details 展开 getElementBox 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getElementBox(Long browserId, int index) {
    BrowserInstance inst = INSTANCES.get(browserId);
    if (inst == null) {
      return notFound(browserId);
    }
    Locator locator = locatorOf(inst, index);
    if (locator == null) {
      return RespBodyVo.fail("get_element_box 索引越界: " + index + indexHint(inst));
    }
    try {
      BoundingBox box = locator.boundingBox();
      if (box == null) {
        return RespBodyVo.fail("get_element_box 失败：元素不可见或没有布局信息");
      }
      return RespBodyVo.ok(Kv.by("x", box.x).set("y", box.y).set("width", box.width).set("height", box.height));
    } catch (PlaywrightException e) {
      return RespBodyVo.fail(actionFailure("get_element_box", e));
    }
  }
```

:::

### `is_visible`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("is_visible", (svc, id, a) -> svc.isVisible(id, reqInt(a, "index")));
```

:::

::: details 展开 isVisible 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo isVisible(Long browserId, int index) {
    return read(browserId, index, "is_visible", "visible", (locator) -> locator.isVisible());
  }
```

:::

### `is_enabled`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("is_enabled", (svc, id, a) -> svc.isEnabled(id, reqInt(a, "index")));
```

:::

::: details 展开 isEnabled 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo isEnabled(Long browserId, int index) {
    return read(browserId, index, "is_enabled", "enabled", (locator) -> locator.isEnabled());
  }
```

:::

### `is_checked`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("is_checked", (svc, id, a) -> svc.isChecked(id, reqInt(a, "index")));
```

:::

::: details 展开 isChecked 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo isChecked(Long browserId, int index) {
    return read(browserId, index, "is_checked", "checked", (locator) -> locator.isChecked());
  }
```

:::
