DOM 原理:Java 模型与跨 Frame 索引
前端脚本返回节点映射后,Java 负责恢复树形结构、生成可读文本,并保存索引与 Frame 的对应关系。
当前模型
源码位于 playwright-server/src/main/java/nexus/io/ai/browser/dom/model/:
| 类型 | 职责 |
|---|---|
DOMBaseNode | 节点公共信息及父节点关系 |
DOMElementNode | 标签、属性、子节点、可见性、交互索引等 |
DOMTextNode | 文本节点 |
DOMState | 根元素树、selectorMap、页面尺寸、索引到 Frame 的映射及 Frame 快照 |
DOMState 不只是一个元素编号表,还包括 indexToFrame 与 frames。这些数据让跨 Frame 的元素操作能回到正确的文档。
DomService 的两遍组装
dom/service/DomService.java 的 getClickableElements(...):
- 调用
evaluate(...),取得脚本返回的map和rootId。 - 第一遍根据节点类型创建 Java 对象,并记录交互元素索引。
- 第二遍连接父子关系。
- 取得根节点,连同 selectorMap 与滚动、页面尺寸信息构造
DOMState。
evaluate(Page,...) 可以委托给主 Frame;跨 Frame 场景使用接受 Frame 的重载,并传入起始索引。具体参数见 前端采集。
多 Frame 快照如何合并
浏览器服务在采集多个 Frame 时按顺序分配不重叠的索引,同时建立“索引 → Frame 序号”的映射。执行按索引操作时,先查 Frame,再在该文档中定位元素。
单 Frame 快照中的 Frame 映射可以为空,此时元素属于主 Frame。不要在调用方为每个 iframe 独立分配从零开始的索引,然后把结果直接拼接。
快照何时失效
快照反映采集时的 DOM。前端重新渲染、导航、关闭弹窗或切换业务页后,原来的路径可能已经失效。报错时先重新读取状态并确认元素;对提交类动作还需先判断业务是否已经成功,避免因回执异常重复提交。
服务中的 BrowserInstance 保存任务当前页和快照等状态,浏览器上下文则由服务共享。它不是“每个任务拥有一份独立 Playwright 浏览器”的模型。
从使用回到源码
按以下路径跟踪一个实际调用:
get_browser_state
→ CommandTable 读取参数
→ PlaywrightService 采集页面或多个 Frame
→ DomService 执行脚本并组装 DOMState
→ 返回 text、元素信息与页签状态
→ 后续按索引操作查找相应 Frame 和元素
不要复制一份简化的 DOMState 到业务项目中代替服务模型。新增参数时,需要同时核对注册层、服务方法、脚本入参与返回结构。
下一步:正文提取与模型处理。
代码阅读:从脚本结果到元素树
前端返回普通 Map,服务需要将其还原为 Java 节点,并保留索引映射。下面给出实际组装代码及完整模型,重点观察两遍构建、Frame 索引和文本渲染。
加载与缓存脚本
脚本从 classpath 读取,只在首次调用时组合。
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/service/DomService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public static String buildExpression() {
String cached = cachedExpression;
if (cached != null) {
return cached;
}
// 从 resources 加载 index.js
String js_path = "dom/dom_tree/index.js";
URL url = ResourceUtil.getResource(js_path);
if (url == null) {
throw new RuntimeException("not found index.js");
}
String stringBuilder = FileUtil.readString(url);
String buildScript = stringBuilder.toString();
String run_js = "dom/dom_tree/run.js";
URL run_js_url = ResourceUtil.getResource(run_js);
if (run_js_url == null) {
throw new RuntimeException("not found run_js");
}
stringBuilder = FileUtil.readString(run_js_url);
String expression = String.format(stringBuilder.toString(), buildScript);
cachedExpression = expression;
return expression;
}
执行脚本
选择参数最完整的 Frame 重载,调用 frame.evaluate 将参数对象传入 JavaScript。
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/service/DomService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public static Map<String, Object> evaluate(Frame frame, String expression, boolean highlightElements, int focusElement,
int viewportExpansion, int highlightIndexStart, boolean descendIframes) {
// 构造 JS 参数
Map<String, Object> args = new HashMap<>();
args.put("doHighlightElements", highlightElements);
args.put("focusHighlightIndex", focusElement);
args.put("viewportExpansion", viewportExpansion);
args.put("debugMode", log.isDebugEnabled());
args.put("highlightIndexStart", highlightIndexStart);
args.put("descendIframes", descendIframes);
@SuppressWarnings("unchecked")
Map<String, Object> evalPage = (Map<String, Object>) frame.evaluate(expression, args);
return evalPage;
}
两遍组装节点
第一遍创建节点并登记索引,第二遍连接父子节点,再返回 DOMState。
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/service/DomService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public static DOMState getClickableElements(Frame frame, String expression, boolean highlightElements, int focusElement,
int viewportExpansion, int highlightIndexStart, boolean descendIframes) {
Map<String, Object> evalPage = evaluate(frame, expression, highlightElements, focusElement, viewportExpansion,
highlightIndexStart, descendIframes);
// 拿到 map 和 rootId
@SuppressWarnings("unchecked")
Map<String, Map<String, Object>> jsNodeMap = (Map<String, Map<String, Object>>) evalPage.get("map");
Object object = evalPage.get("rootId");
String rootId = object.toString();
// 第一次遍历:构建所有节点(不设 parent/children)
Map<String, DOMBaseNode> nodeMap = new HashMap<>();
Map<Integer, DOMElementNode> selectorMap = new HashMap<>();
Set<Entry<String, Map<String, Object>>> entrySet = jsNodeMap.entrySet();
for (Entry<String, Map<String, Object>> entry : entrySet) {
String id = entry.getKey();
Map<String, Object> nd = entry.getValue();
String type = (String) nd.get("type");
if ("TEXT_NODE".equals(type)) {
// 文本节点
String text = (String) nd.get("text");
boolean isVis = Boolean.TRUE.equals(nd.get("isVisible"));
nodeMap.put(id, new DOMTextNode(text, isVis));
continue;
}
// 元素节点
String tag = (String) nd.get("tagName");
String xpath = (String) nd.get("xpath");
@SuppressWarnings("unchecked")
Map<String, String> attrs = (Map<String, String>) nd.get("attributes");
boolean isVis = Boolean.TRUE.equals(nd.get("isVisible"));
boolean isInt = Boolean.TRUE.equals(nd.get("isInteractive"));
boolean isTop = Boolean.TRUE.equals(nd.get("isTopElement"));
boolean inVP = Boolean.TRUE.equals(nd.get("isInViewport"));
boolean sr = Boolean.TRUE.equals(nd.get("shadowRoot"));
Integer hi = nd.get("highlightIndex") != null ? ((Number) nd.get("highlightIndex")).intValue() : null;
Boolean isNew = (Boolean) nd.get("isNew");
DOMElementNode el = new DOMElementNode(tag, xpath, attrs, isVis, isInt, isTop, inVP, sr, hi, isNew);
nodeMap.put(id, el);
if (hi != null) {
selectorMap.put(hi, el);
}
}
// 第二次遍历:建立 parent–children 关系
for (Entry<String, Map<String, Object>> entry : entrySet) {
String id = entry.getKey();
Map<String, Object> raw = entry.getValue();
if (!(nodeMap.get(id) instanceof DOMElementNode)) {
continue;
}
DOMElementNode el = (DOMElementNode) nodeMap.get(id);
@SuppressWarnings("unchecked")
List<Object> childList = (List<Object>) raw.get("children");
if (childList == null) {
continue;
}
for (Object o : childList) {
String cid = o.toString();
DOMBaseNode child = nodeMap.get(cid);
if (child != null) {
el.addChild(child);
}
}
}
// 拿到根节点
DOMElementNode root = (DOMElementNode) nodeMap.get(rootId);
int pixelsAbove = ((Number) evalPage.get("pixels_above")).intValue();
int pixelsBelow = ((Number) evalPage.get("pixels_below")).intValue();
int viewportHeight = ((Number) evalPage.get("viewport_height")).intValue();
int pageHeight = ((Number) evalPage.get("page_height")).intValue();
return new DOMState(root, selectorMap, pixelsAbove, pixelsBelow, viewportHeight, pageHeight);
}
合并多个 Frame
逐个采集可用 Frame,推进全局索引并保存每个索引的 Frame 来源。
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/service/DomService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public static DOMState getFrameState(Page page, boolean highlightElements, int viewportExpansion,
boolean onlySameOriginFrames) {
String expression = buildExpression();
List<FrameNode> nodes = frameTree(page);
String mainOrigin = nodes.isEmpty() ? null : frameOrigin(nodes.get(0).frame);
List<FrameSnapshot> snapshots = new java.util.ArrayList<>(nodes.size());
Map<Integer, DOMElementNode> merged = new java.util.LinkedHashMap<>();
Map<Integer, Integer> indexToFrame = new java.util.LinkedHashMap<>();
DOMElementNode mainTree = null;
int cursor = 0;
// 每个 frame 都占一条记录(被跳过的也在内),而且**按下标就是 frame 序号**:
// 这样 frames.get(ordinal) 永远拿得到那个 frame,快照里显示的 frame 序号也与 frame 参数一致
for (int ordinal = 0; ordinal < nodes.size(); ordinal++) {
FrameNode node = nodes.get(ordinal);
String url = safeFrameUrl(node.frame);
String name = safeFrameName(node.frame);
boolean main = ordinal == 0;
String origin = main ? mainOrigin : frameOrigin(node.frame);
boolean crossOrigin = mainOrigin != null && origin != null && !mainOrigin.equals(origin);
if (onlySameOriginFrames && crossOrigin) {
// 跨域 iframe:默认快照不纳入(要看它请传 includeFrames:true,或直接用 list_frames)
snapshots.add(new FrameSnapshot(ordinal, url, name, false, node.parentIndex, node.depth, -1, -1, 0, "",
null, 0, 0, 0, 0, node.frame, null, true,
"跨域 frame(" + origin + "):默认快照不纳入,传 includeFrames:true 可以一起读"));
continue;
}
DOMState state = null;
String failure = null;
try {
// viewportExpansion=-1 表示「不管在不在视口都给索引」,与单 frame 路径的取法一致
state = getClickableElements(node.frame, expression, highlightElements, -1, viewportExpansion, cursor, false);
} catch (RuntimeException e) {
// Main-frame failure is not a successful empty snapshot. Let the read-only
// dispatcher retry transient Playwright errors instead of hiding them.
if (main) {
throw e;
}
failure = e.getMessage();
}
int first = -1;
int last = -1;
int count = 0;
String text = "";
if (state != null) {
List<Integer> indices = new java.util.ArrayList<>(state.getSelectorMap().keySet());
java.util.Collections.sort(indices);
for (Integer index : indices) {
merged.put(index, state.getSelectorMap().get(index));
indexToFrame.put(index, ordinal);
}
count = indices.size();
if (count > 0) {
first = indices.get(0);
last = indices.get(count - 1);
cursor = last + 1;
}
text = state.getElementTree() == null ? "" : state.getElementTree().clickableElementsToString(null);
if (main) {
mainTree = state.getElementTree();
}
}
snapshots.add(new FrameSnapshot(ordinal, url, name, main, node.parentIndex, node.depth, first, last, count,
text, state == null ? null : state.getElementTree(),
state == null ? 0 : state.getPixelsAbove(), state == null ? 0 : state.getPixelsBelow(),
state == null ? 0 : state.getViewportHeight(), state == null ? 0 : state.getPageHeight(), node.frame,
failure, false, null));
}
FrameSnapshot mainState = snapshots.isEmpty() ? null : snapshots.get(0);
return new DOMState(mainTree, merged, mainState == null ? 0 : mainState.pixelsAbove,
mainState == null ? 0 : mainState.pixelsBelow, mainState == null ? 0 : mainState.viewportHeight,
mainState == null ? 0 : mainState.pageHeight, indexToFrame, snapshots);
}
展开 DOMBaseNode 完整实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/model/DOMBaseNode.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
package nexus.io.ai.browser.dom.model;
public abstract class DOMBaseNode {
protected boolean isVisible;
protected DOMElementNode parent;
public DOMBaseNode(boolean isVisible) {
this.isVisible = isVisible;
}
public boolean isVisible() {
return isVisible;
}
public DOMElementNode getParent() {
return parent;
}
public void setParent(DOMElementNode parent) {
this.parent = parent;
}
}
展开 DOMTextNode 完整实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/model/DOMTextNode.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
package nexus.io.ai.browser.dom.model;
public class DOMTextNode extends DOMBaseNode {
private final String text;
private final String type = "TEXT_NODE";
public DOMTextNode(String text, boolean isVisible) {
super(isVisible);
this.text = text;
}
public String getText() {
return text;
}
public boolean hasParentWithHighlightIndex() {
DOMElementNode cur = parent;
while (cur != null) {
if (cur.getHighlightIndex() != null)
return true;
cur = cur.getParent();
}
return false;
}
public boolean isParentInViewport() {
return parent != null && parent.isInViewport();
}
public boolean isParentTopElement() {
return parent != null && parent.isTopElement();
}
}
展开 DOMElementNode 完整实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/model/DOMElementNode.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
package nexus.io.ai.browser.dom.model;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import nexus.io.tio.utils.collect.Lists;
public class DOMElementNode extends DOMBaseNode {
private final String tagName;
private final String xpath;
private final Map<String, String> attributes;
private final List<DOMBaseNode> children = new ArrayList<>();
private final boolean isInteractive;
private final boolean isTopElement;
private final boolean isInViewport;
private final boolean shadowRoot;
private final Integer highlightIndex;
private final Boolean isNew;
// (viewportInfo / pageCoordinates 按需补)
public static final List<String> DEFAULT_INCLUDE_ATTRIBUTES = Lists.of("title", "type", "checked", "name", "role", "value",
//
"placeholder", "data-date-format", "alt", "aria-label", "aria-expanded", "data-state", "aria-checked",
"readonly", "disabled", "editable", "selected", "selected-text");
public DOMElementNode(String tagName, String xpath, Map<String, String> attributes, boolean isVisible, boolean
//
isInteractive, boolean isTopElement, boolean isInViewport, boolean shadowRoot, Integer highlightIndex, Boolean isNew) {
super(isVisible);
this.tagName = tagName;
this.xpath = xpath;
this.attributes = attributes;
this.isInteractive = isInteractive;
this.isTopElement = isTopElement;
this.isInViewport = isInViewport;
this.shadowRoot = shadowRoot;
this.highlightIndex = highlightIndex;
this.isNew = isNew;
}
public String getTagName() {
return tagName;
}
/** buildDomTree 生成的相对 xpath,如 html/body/div[2]/form/input[1] */
public String getXpath() {
return xpath;
}
public Map<String, String> getAttributes() {
return attributes;
}
public Integer getHighlightIndex() {
return highlightIndex;
}
public boolean isInteractive() {
return isInteractive;
}
public boolean isTopElement() {
return isTopElement;
}
public boolean isInViewport() {
return isInViewport;
}
public Boolean isNew() {
return isNew;
}
public List<DOMBaseNode> getChildren() {
return children;
}
public void addChild(DOMBaseNode child) {
children.add(child);
if (child instanceof DOMElementNode) {
((DOMElementNode) child).setParent(this);
} else if (child instanceof DOMTextNode) {
((DOMTextNode) child).setParent(this);
}
}
/**
* 收集从 this 节点开始,直到下一个可点击元素前的所有文本
*/
public String getAllTextTillNextClickableElement(int maxDepth) {
StringBuilder sb = new StringBuilder();
collectText(this, 0, maxDepth, sb);
return sb.toString().trim();
}
private void collectText(DOMBaseNode node, int depth, int maxDepth, StringBuilder sb) {
if (maxDepth != -1 && depth > maxDepth) {
return;
}
if (node instanceof DOMElementNode) {
DOMElementNode el = (DOMElementNode) node;
if (el != this && el.getHighlightIndex() != null) {
return;
}
for (DOMBaseNode c : el.getChildren()) {
collectText(c, depth + 1, maxDepth, sb);
}
} else if (node instanceof DOMTextNode) {
//sb.append(((DOMTextNode) node).getText()).append("\n");
String text = ((DOMTextNode) node).getText();
sb.append(text.trim()).append(" ");
}
}
/**
* 核心:把可点击元素按照 [index]<tag 属性拼接>文本/> 的格式输出
*/
public String clickableElementsToString(List<String> includeAttributes) {
if (includeAttributes == null) {
includeAttributes = DEFAULT_INCLUDE_ATTRIBUTES;
}
List<String> out = new ArrayList<>();
processNode(this, "", includeAttributes, out);
return out.stream().collect(Collectors.joining("\n"));
}
private void processNode(DOMBaseNode node, String indent, List<String> includeAttributes, List<String> out) {
if (node instanceof DOMElementNode) {
DOMElementNode el = (DOMElementNode) node;
if (el.getHighlightIndex() != null || (el.isVisible() && el.isTopElement &&
List.of("input", "textarea", "select", "option", "button").contains(el.tagName))) {
String text = el.getAllTextTillNextClickableElement(-1);
String attrStr = buildAttributesHtml(el.attributes, includeAttributes, text);
String indicator = el.highlightIndex == null ? "" : Boolean.TRUE.equals(el.isNew()) ? "*[" + el.getHighlightIndex() + "]" : "[" + el.getHighlightIndex() + "]";
String line = indent + indicator + "<" + el.tagName + (attrStr.isEmpty() ? "" : " " + attrStr)
+ (text.isEmpty() ? (attrStr.isEmpty() ? " " : "") + "/>" : (attrStr.isEmpty() ? " " : "") + ">" + text + "/>");
out.add(line);
}
// 继续递归
boolean meaningful = el.highlightIndex != null || List.of("form", "fieldset", "table", "thead", "tbody",
"tr", "td", "th", "ul", "ol", "li", "nav", "section", "dialog").contains(el.tagName);
String childIndent = meaningful && indent.length() < 6 ? indent + "\t" : indent;
for (DOMBaseNode c : el.getChildren()) {
processNode(c, childIndent, includeAttributes, out);
}
} else if (node instanceof DOMTextNode) {
DOMTextNode txt = (DOMTextNode) node;
if (txt.hasParentWithHighlightIndex())
return;
DOMElementNode p = txt.getParent();
if (p != null && p.isVisible() && p.isTopElement()) {
out.add(indent + txt.getText());
}
}
}
private String buildAttributesHtml(Map<String, String> attrs, List<String> includeAttributes, String text) {
Map<String, String> keep = new LinkedHashMap<>();
for (String k : includeAttributes) {
if (attrs.containsKey(k) && attrs.get(k) != null) {
String value = attrs.get(k);
if ("value".equals(k) && "password".equalsIgnoreCase(attrs.get("type"))) {
value = "[redacted]";
}
if (List.of("title", "placeholder", "alt", "aria-label", "selected-text").contains(k)) {
value = cap(value, 160);
}
keep.put(k, value);
}
}
// “去重”“剔除与文本相同的属性” 等…可按需补
return keep.entrySet().stream().map(e -> e.getKey() + "='" + escapeAttribute(e.getValue()) + "'")
.collect(Collectors.joining(" "));
}
private static String escapeAttribute(String value) {
return value.replace("&", "&").replace("'", "'").replace("<", "<")
.replace(">", ">").replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t");
}
private static String cap(String s, int max) {
return s.length() > max ? s.substring(0, max) + "..." : s;
}
}
展开 DOMState 完整实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/model/DOMState.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
package nexus.io.ai.browser.dom.model;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class DOMState {
private final DOMElementNode elementTree;
private final Map<Integer, DOMElementNode> selectorMap;
public final int pixelsAbove;
public final int pixelsBelow;
public final int viewportHeight;
public final int pageHeight;
/**
* 全局索引 -> frame 序号(0 是主 frame)
*
* <p>
* 单 frame 快照时是空表(所有索引都在主 frame);带 frame 的快照(见 {@code get_browser_state} 的
* {@code includeFrames})里每个索引都有一条。按索引命令靠它把动作路由到正确的 frame —— 索引里已经带了
* frame 信息,所以调用方不必再传 frame 参数。
*/
private final Map<Integer, Integer> indexToFrame;
/**
* 这次快照涉及的 frame,下标即 frame 序号(0 是主 frame)
*
* <p>单 frame 快照时是空表,调用方按「所有索引都在主 frame」处理。
*/
private final List<FrameSnapshot> frames;
public DOMState(DOMElementNode tree, Map<Integer, DOMElementNode> sel,
//
int pixelsAbove, int pixelsBelow, int viewportHeight, int pageHeight) {
this(tree, sel, pixelsAbove, pixelsBelow, viewportHeight, pageHeight, Collections.emptyMap(),
Collections.emptyList());
}
public DOMState(DOMElementNode tree, Map<Integer, DOMElementNode> sel, int pixelsAbove, int pixelsBelow,
int viewportHeight, int pageHeight, Map<Integer, Integer> indexToFrame, List<FrameSnapshot> frames) {
this.elementTree = tree;
this.selectorMap = sel;
this.pixelsAbove = pixelsAbove;
this.pixelsBelow = pixelsBelow;
this.viewportHeight = viewportHeight;
this.pageHeight = pageHeight;
this.indexToFrame = indexToFrame == null ? Collections.emptyMap() : indexToFrame;
this.frames = frames == null ? Collections.emptyList() : frames;
}
public DOMElementNode getElementTree() {
return elementTree;
}
public Map<Integer, DOMElementNode> getSelectorMap() {
return selectorMap;
}
public int getPixelsAbove() {
return pixelsAbove;
}
public int getPixelsBelow() {
return pixelsBelow;
}
public int getViewportHeight() {
return viewportHeight;
}
public int getPageHeight() {
return pageHeight;
}
public Map<Integer, Integer> getIndexToFrame() {
return indexToFrame;
}
public List<FrameSnapshot> getFrames() {
return frames;
}
/** 这次快照是不是带 frame 的(带了才有 {@link FrameSnapshot} 与索引路由) */
public boolean hasFrames() {
return !frames.isEmpty();
}
/** 取某个索引所属的 frame;没有 frame 信息时返回 null(表示主 frame) */
public FrameSnapshot frameOf(int index) {
Integer ordinal = indexToFrame.get(index);
if (ordinal == null || ordinal < 0 || ordinal >= frames.size()) {
return null;
}
return frames.get(ordinal);
}
}
展开 FrameSnapshot 完整实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/dom/model/FrameSnapshot.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
package nexus.io.ai.browser.dom.model;
import com.microsoft.playwright.Frame;
/**
* 一次快照里的一个 frame
*
* <p>
* <b>为什么需要它</b>:企业微信后台把「邮件」应用套在 {@code exmail.qq.com} 的**跨域 iframe** 里,微盘 /
* 文档 / 会议同理。这种形态下顶层 {@code document} 里一个元素都看不见,而 Playwright 本身是能跨 frame
* 的(它走浏览器协议取内容,不依赖往页面里注入 JS)—— 缺的只是把 {@code page.frames()} 暴露出来。这个类
* 就是那份暴露:每个 frame 一行元信息 + 它自己的可交互元素树 + 它占用的**全局索引区间**。
*
* <p>
* <b>索引怎么保证全局唯一</b>:每个 frame 单独求值时索引会各自从 0 开始,必然撞车。所以按 frame 顺序依次
* 求值,把「前面几个 frame 已经用掉多少」当起点传给下一个 frame(见 {@code DomService} 的
* {@code highlightIndexStart}),于是 {@code firstIndex..lastIndex} 就是该 frame 在快照里的索引区间,
* 而按索引命令(click_element_by_index / input_text …)靠 {@code indexToFrame} 自动路由到正确的 frame,
* 调用方**不需要**再传 frame 参数。
*/
public class FrameSnapshot {
/**
* frame 序号:0 固定是主 frame,其余按 Playwright 的 frame 树顺序深度优先编号
*
* <p>与命令里的 {@code frame} 参数、{@code list_frames} 里的 {@code index} 都是同一个编号 ——
* 从快照文本里读到 {@code --- frame[2] … ---} 就直接写 {@code {"frame":2}}。
*/
public final int index;
/** 该 frame 的 URL(跨域 frame 也能读到,这是识别它的主要依据) */
public final String url;
/** {@code <iframe name="...">} 的 name(没有则为空串) */
public final String name;
/** 是不是主 frame */
public final boolean main;
/** 父 frame 的序号;-1 表示没有父(主 frame) */
public final int parentIndex;
/** 嵌套深度,主 frame 是 0 */
public final int depth;
/** 这个 frame 占用的全局索引下界;没有可交互元素时与 lastIndex 一起为 -1 */
public final int firstIndex;
/** 这个 frame 占用的全局索引上界(含);没有可交互元素时为 -1 */
public final int lastIndex;
/** 可交互元素个数 */
public final int elementCount;
/** 这个 frame 自己的可交互结构化文本(与 data.text 里那一段完全一致) */
public final String text;
/** 该 frame 的元素树根(用于按 xpath 找回元素) */
public final DOMElementNode tree;
public final int pixelsAbove;
public final int pixelsBelow;
public final int viewportHeight;
public final int pageHeight;
/**
* 快照时用的 frame 句柄
*
* <p>
* frame 是运行期对象,页面一导航(或 iframe 被重建)它就 detach 了。所以动作执行时**不能**无条件拿它去定位:
* 先按 {@link #url} 在当前 {@code page.frames()} 里重新认一次,认不出来才退回这个句柄(见
* {@code PlaywrightService.frameForIndex})。
*/
public final Frame frame;
/** 这个 frame 读取失败的原因(跨域被拒 / frame 正在销毁 / 还没有文档);成功时为 null */
public final String readFailure;
/**
* 这个 frame 是不是**没被纳入**本次快照
*
* <p>默认快照(不带 {@code includeFrames})只纳入同源 frame —— 与以前「顺着同源 iframe 递归」的行为一致;
* 跨域 frame 被跳过的原因记在 {@link #skipReason}。要纳入全部 frame 就传 {@code includeFrames:true}。
*/
public final boolean skipped;
/** 被跳过的原因 */
public final String skipReason;
public FrameSnapshot(int index, String url, String name, boolean main, int parentIndex, int depth,
int firstIndex, int lastIndex, int elementCount, String text, DOMElementNode tree, int pixelsAbove,
int pixelsBelow, int viewportHeight, int pageHeight, Frame frame, String readFailure) {
this(index, url, name, main, parentIndex, depth, firstIndex, lastIndex, elementCount, text, tree, pixelsAbove,
pixelsBelow, viewportHeight, pageHeight, frame, readFailure, false, null);
}
public FrameSnapshot(int index, String url, String name, boolean main, int parentIndex, int depth,
int firstIndex, int lastIndex, int elementCount, String text, DOMElementNode tree, int pixelsAbove,
int pixelsBelow, int viewportHeight, int pageHeight, Frame frame, String readFailure, boolean skipped,
String skipReason) {
this.index = index;
this.url = url;
this.name = name;
this.main = main;
this.parentIndex = parentIndex;
this.depth = depth;
this.firstIndex = firstIndex;
this.lastIndex = lastIndex;
this.elementCount = elementCount;
this.text = text;
this.tree = tree;
this.pixelsAbove = pixelsAbove;
this.pixelsBelow = pixelsBelow;
this.viewportHeight = viewportHeight;
this.pageHeight = pageHeight;
this.frame = frame;
this.readFailure = readFailure;
this.skipped = skipped;
this.skipReason = skipReason;
}
/** 这个 frame 有没有可交互元素 */
public boolean hasElements() {
return elementCount > 0;
}
/** 给模型读的一行元信息(快照文本里的 frame 分隔行用它) */
public String header() {
StringBuilder sb = new StringBuilder("--- frame[").append(index).append(']');
if (main) {
sb.append(" (main)");
}
sb.append(' ').append(url == null ? "" : url);
if (name != null && !name.isEmpty()) {
sb.append(" name=\"").append(name).append('"');
}
sb.append(" elements=").append(elementCount);
if (hasElements()) {
sb.append(" index=").append(firstIndex).append("..").append(lastIndex);
}
if (readFailure != null) {
sb.append(" [读取失败: ").append(readFailure).append(']');
}
sb.append(" ---");
return sb.toString();
}
}
如何从元素树输出可读文本
DOMElementNode.clickableElementsToString 通过 processNode 遍历树,使用 getAllTextTillNextClickableElement 避免把下一层可点击控件的文本重复并入父元素,最终形成带索引的行。属性经过转义与长度限制,密码值在前端采集阶段已经隐藏。上方完整模型展示了这些辅助方法。
