mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): vom method bug fix
This commit is contained in:
@@ -848,11 +848,7 @@ describe("buildVomScene", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an empty field's placeholder over a label-polluted AX name", () => {
|
||||
// Mirrors xiaohongshu: the <input> is wrapped in a <label> that also holds
|
||||
// a "+86" prefix, so the AX accessible name becomes "+86" and the real
|
||||
// placeholder ("输入手机号") is lost. VOM models perceived state — the empty
|
||||
// field shows its placeholder — so the placeholder wins.
|
||||
it("keeps placeholder separate from the accessible name", () => {
|
||||
const axNodes: CdpAxNode[] = [
|
||||
{
|
||||
nodeId: "1",
|
||||
@@ -881,7 +877,12 @@ describe("buildVomScene", () => {
|
||||
};
|
||||
|
||||
expect(buildVomScene(axNodes, captured).nodes[0]).toEqual(
|
||||
expect.objectContaining({ id: 10, role: "textbox", name: "输入手机号" }),
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
role: "textbox",
|
||||
name: "+86",
|
||||
placeholder: "输入手机号",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -979,6 +980,89 @@ describe("buildVomScene", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses preceding AX static text as a form control label", () => {
|
||||
const axNodes: CdpAxNode[] = [
|
||||
{
|
||||
nodeId: "1",
|
||||
role: { type: "role", value: "RootWebArea" },
|
||||
backendDOMNodeId: 1,
|
||||
childIds: ["2", "3"],
|
||||
},
|
||||
{
|
||||
nodeId: "2",
|
||||
parentId: "1",
|
||||
role: { type: "role", value: "StaticText" },
|
||||
name: { type: "computedString", value: "验证码:" },
|
||||
},
|
||||
{
|
||||
nodeId: "3",
|
||||
parentId: "1",
|
||||
role: { type: "role", value: "textbox" },
|
||||
backendDOMNodeId: 20,
|
||||
},
|
||||
];
|
||||
const captured: CapturedViewModel = {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: null,
|
||||
tag: "input",
|
||||
attrs: { type: "text" },
|
||||
rect: { x: 90, y: 0, w: 120, h: 30 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "text",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(buildVomScene(axNodes, captured).nodes.find((node) => node.id === 20)).toEqual(
|
||||
expect.objectContaining({ role: "textbox", name: "验证码", inputState: "empty" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks runtime default input values without treating them as ordinary filled input", () => {
|
||||
const scene = buildVomScene(
|
||||
[
|
||||
{
|
||||
nodeId: "1",
|
||||
role: { type: "role", value: "textbox" },
|
||||
backendDOMNodeId: 10,
|
||||
},
|
||||
],
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "input",
|
||||
attrs: { type: "text" },
|
||||
formValue: "没有手机号的用编码代替",
|
||||
formDefaultValue: "没有手机号的用编码代替",
|
||||
rect: { x: 0, y: 0, w: 240, h: 48 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(scene.nodes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
inputState: "default",
|
||||
value: "没有手机号的用编码代替",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("promotes a cursor:pointer icon control to a button named from its <use> sprite", () => {
|
||||
// A close button built as <div class=close-button><svg><use href="#close"/></svg></div>
|
||||
// — the AX tree exposes it as `generic` with no name, so it would be dropped.
|
||||
|
||||
@@ -329,6 +329,27 @@ function axPropertyString(axNode: CdpAxNode, name: string): string | undefined {
|
||||
return axString(prop?.value);
|
||||
}
|
||||
|
||||
function axSiblingLabel(axNode: CdpAxNode, axById: Map<string, CdpAxNode>): string | undefined {
|
||||
const parent = axNode.parentId ? axById.get(axNode.parentId) : undefined;
|
||||
const siblings = parent?.childIds ?? [];
|
||||
const index = siblings.indexOf(axNode.nodeId);
|
||||
if (index <= 0) return undefined;
|
||||
|
||||
for (let i = index - 1; i >= Math.max(0, index - 4); i -= 1) {
|
||||
const sibling = axById.get(siblings[i]);
|
||||
if (!sibling) continue;
|
||||
const role = axString(sibling.role)?.toLowerCase() ?? "";
|
||||
if (AX_TEXT_STOP_ROLES.has(role)) break;
|
||||
if (!["statictext", "labeltext", "text", "generic"].includes(role)) continue;
|
||||
const label = cleanAttr(axString(sibling.name) ?? axString(sibling.value))?.replace(
|
||||
/[::]\s*$/,
|
||||
"",
|
||||
);
|
||||
if (label && label.length <= 40) return label;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildAxSignals(axNodes: CdpAxNode[]): Map<string, AxNodeSignals> {
|
||||
const axByNodeId = new Map(axNodes.map((node) => [node.nodeId, node]));
|
||||
const virtualText = new Map<string, string>();
|
||||
@@ -619,6 +640,7 @@ function applyCapturedSignals(
|
||||
const attrs = capturedNode.attrs;
|
||||
const text = cleanAttr(capturedNode.textContent);
|
||||
const nearbyText = nearbyTextFor(capturedNode, signals.childrenByParentId);
|
||||
const placeholder = cleanAttr(capturedNode.formPlaceholder) ?? cleanAttr(attrs.placeholder);
|
||||
return {
|
||||
...node,
|
||||
domParentId: capturedNode.parentBackendNodeId,
|
||||
@@ -627,6 +649,7 @@ function applyCapturedSignals(
|
||||
attrs,
|
||||
...(text ? { text } : {}),
|
||||
...(nearbyText ? { nearbyText } : {}),
|
||||
...(placeholder ? { placeholder } : {}),
|
||||
disabled:
|
||||
Object.prototype.hasOwnProperty.call(attrs, "disabled") ||
|
||||
(attrs["aria-disabled"] ?? "").toLowerCase() === "true",
|
||||
@@ -636,6 +659,20 @@ function applyCapturedSignals(
|
||||
};
|
||||
}
|
||||
|
||||
function inputStateFor(
|
||||
capturedNode: CapturedNode | undefined,
|
||||
value: string | undefined,
|
||||
): VomNode["inputState"] {
|
||||
if (!capturedNode || !FORM_CONTROL_TAGS.has(normalizeTag(capturedNode.tag))) return undefined;
|
||||
const formValue = capturedNode.formValue;
|
||||
if (formValue !== undefined) {
|
||||
if (formValue === "") return "empty";
|
||||
if (formValue === (capturedNode.formDefaultValue ?? "")) return "default";
|
||||
return "filled";
|
||||
}
|
||||
return value === undefined || value === "" ? "empty" : "filled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Name for a native form control. VOM models the *perceived* viewport
|
||||
* (spec §1): an empty field displays its placeholder, so when the field has
|
||||
@@ -647,19 +684,17 @@ function applyCapturedSignals(
|
||||
function formControlName(
|
||||
captured: CapturedNode | undefined,
|
||||
axName: string | undefined,
|
||||
axValue: string | undefined,
|
||||
axSiblingName: string | undefined,
|
||||
signals: VomNodeDomSignals,
|
||||
): string | undefined {
|
||||
const placeholder = cleanAttr(captured?.attrs.placeholder);
|
||||
const ariaLabel = cleanAttr(captured?.attrs["aria-label"]);
|
||||
const title = cleanAttr(captured?.attrs.title);
|
||||
const placeholder =
|
||||
cleanAttr(captured?.formPlaceholder) ?? cleanAttr(captured?.attrs.placeholder);
|
||||
const nearbyLabel = captured
|
||||
? previousSiblingTextFor(captured, signals.childrenByParentId)
|
||||
: undefined;
|
||||
const hasValue = axValue !== undefined && axValue !== "";
|
||||
if (nearbyLabel) return nearbyLabel;
|
||||
if (!hasValue && placeholder) return placeholder;
|
||||
return axName ?? ariaLabel ?? placeholder ?? title ?? nearbyLabel;
|
||||
return ariaLabel ?? title ?? axName ?? axSiblingName ?? nearbyLabel ?? placeholder;
|
||||
}
|
||||
|
||||
function vomNodeFromCaptured(
|
||||
@@ -668,7 +703,8 @@ function vomNodeFromCaptured(
|
||||
signals: VomNodeDomSignals,
|
||||
): VomNode {
|
||||
const sensitive = isSensitive(capturedNode);
|
||||
const value = sensitive ? (capturedNode.attrs.value ?? "") : undefined;
|
||||
const value =
|
||||
capturedNode.formValue ?? (sensitive ? (capturedNode.attrs.value ?? "") : undefined);
|
||||
const tag = normalizeTag(capturedNode.tag);
|
||||
const node = applyCapturedSignals(
|
||||
{
|
||||
@@ -681,6 +717,7 @@ function vomNodeFromCaptured(
|
||||
pointerEvents: capturedNode.pointerEvents || "auto",
|
||||
modal: isModalSignal(undefined, capturedNode),
|
||||
sensitive,
|
||||
inputState: inputStateFor(capturedNode, value),
|
||||
...(value !== undefined ? { value } : {}),
|
||||
},
|
||||
capturedNode,
|
||||
@@ -702,10 +739,11 @@ function axVomNode(
|
||||
axSignals: Map<string, AxNodeSignals>,
|
||||
): VomNode {
|
||||
const role = axString(axNode.role);
|
||||
const value = axValue(axNode.value);
|
||||
const capturedValue = capturedNode?.formValue;
|
||||
const value = capturedValue ?? axValue(axNode.value);
|
||||
const signalsForNode = axSignals.get(axNode.nodeId);
|
||||
let name = FORM_CONTROL_TAGS.has(normalizeTag(capturedNode?.tag))
|
||||
? formControlName(capturedNode, axString(axNode.name), value, signals)
|
||||
? formControlName(capturedNode, axString(axNode.name), axSiblingLabel(axNode, axById), signals)
|
||||
: (axString(axNode.name) ?? signalsForNode?.aggregatedText);
|
||||
if (name && signalsForNode?.hasPopup) {
|
||||
name = signalsForNode.expanded ? `${name} [expanded]` : `${name} [has-submenu]`;
|
||||
@@ -720,6 +758,7 @@ function axVomNode(
|
||||
pointerEvents: capturedNode?.pointerEvents || "auto",
|
||||
modal: isModalSignal(axNode, capturedNode),
|
||||
sensitive: isSensitive(capturedNode) || signalsForNode?.sensitive === true,
|
||||
inputState: inputStateFor(capturedNode, value),
|
||||
};
|
||||
if (role) node.role = role;
|
||||
if (name) node.name = name;
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface CapturedNode {
|
||||
*/
|
||||
cursor?: string;
|
||||
textContent?: string;
|
||||
formValue?: string;
|
||||
formDefaultValue?: string;
|
||||
formPlaceholder?: string;
|
||||
}
|
||||
|
||||
export type CapturedIframeNodes = Map<number, CapturedNode[]>;
|
||||
@@ -95,6 +98,52 @@ interface LayoutMetricsReply {
|
||||
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
|
||||
}
|
||||
|
||||
function isFormControlTag(tag: string): boolean {
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
}
|
||||
|
||||
async function enrichFormControlState(cdp: CdpRunner, tabId: number, nodes: CapturedNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (!isFormControlTag(node.tag)) continue;
|
||||
let objectId: string | undefined;
|
||||
try {
|
||||
const resolved = await cdp.send<{ object?: { objectId?: string } }>(
|
||||
tabId,
|
||||
"DOM.resolveNode",
|
||||
{
|
||||
backendNodeId: node.backendNodeId,
|
||||
},
|
||||
);
|
||||
objectId = resolved.object?.objectId;
|
||||
if (!objectId) continue;
|
||||
const result = await cdp.send<{
|
||||
result?: { value?: { value?: string; defaultValue?: string; placeholder?: string } };
|
||||
}>(tabId, "Runtime.callFunctionOn", {
|
||||
objectId,
|
||||
returnByValue: true,
|
||||
functionDeclaration: `function(){
|
||||
return {
|
||||
value: typeof this.value === "string" ? this.value : "",
|
||||
defaultValue: typeof this.defaultValue === "string" ? this.defaultValue : "",
|
||||
placeholder: typeof this.placeholder === "string" ? this.placeholder : ""
|
||||
};
|
||||
}`,
|
||||
});
|
||||
const value = result.result?.value;
|
||||
if (!value) continue;
|
||||
node.formValue = value.value ?? "";
|
||||
node.formDefaultValue = value.defaultValue ?? "";
|
||||
node.formPlaceholder = value.placeholder ?? "";
|
||||
} catch {
|
||||
// Best-effort enrichment. DOMSnapshot/AX data still carries the node.
|
||||
} finally {
|
||||
if (objectId) {
|
||||
await cdp.send(tabId, "Runtime.releaseObject", { objectId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface RuntimeEvaluateReply {
|
||||
result?: {
|
||||
value?: unknown;
|
||||
@@ -533,6 +582,11 @@ export async function captureViewModel(cdp: CdpRunner, tabId: number): Promise<C
|
||||
}
|
||||
}
|
||||
|
||||
await enrichFormControlState(cdp, tabId, nodes);
|
||||
for (const subNodes of iframeNodes.values()) {
|
||||
await enrichFormControlState(cdp, tabId, subNodes);
|
||||
}
|
||||
|
||||
const surfaceProbes = await probeHoverSurfaces(cdp, tabId);
|
||||
|
||||
return { nodes, viewport, iframeNodes, surfaceProbes, excludedBackendNodeIds };
|
||||
|
||||
@@ -450,65 +450,90 @@ describe("renderVom single-layer page", () => {
|
||||
expect(out.text).not.toContain('StaticText "13311030827"');
|
||||
});
|
||||
|
||||
it("adds semantic collection summaries for repeated item containers", () => {
|
||||
it("renders placeholder separately from input value and state", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "Forum" }),
|
||||
node({ id: 2, parentId: 1, role: "main", name: "Posts" }),
|
||||
node({ id: 3, parentId: 2, role: "searchbox", name: "Search posts", tag: "input" }),
|
||||
node({ id: 10, parentId: 2, role: "article" }),
|
||||
node({ id: 11, parentId: 10, role: "heading", name: "First post" }),
|
||||
node({ id: 12, parentId: 10, role: "link", name: "First post", tag: "a" }),
|
||||
node({ id: 13, parentId: 10, role: "button", name: "Upvote", tag: "button" }),
|
||||
node({ id: 20, parentId: 2, role: "article" }),
|
||||
node({ id: 21, parentId: 20, role: "heading", name: "Second post" }),
|
||||
node({ id: 22, parentId: 20, role: "link", name: "Second post", tag: "a" }),
|
||||
node({ id: 23, parentId: 20, role: "button", name: "Upvote", tag: "button" }),
|
||||
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||
node({
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
role: "textbox",
|
||||
name: "手机号",
|
||||
value: "没有手机号的用编码代替",
|
||||
placeholder: "手机号",
|
||||
inputState: "default",
|
||||
tag: "input",
|
||||
}),
|
||||
]),
|
||||
{ semanticCollections: true },
|
||||
);
|
||||
|
||||
expect(out.text).toContain("@collections");
|
||||
expect(out.text).toContain(
|
||||
'collection c1 role=main item_role=article label="First post" items=2',
|
||||
'@e1 textbox "手机号" [default] placeholder="手机号" ="没有手机号的用编码代替"',
|
||||
);
|
||||
expect(out.text).toContain('controls: search=@e1 "Search posts"');
|
||||
expect(out.text).toContain('title="First post" title_handle=@e2');
|
||||
expect(out.text).toContain('actions=[@e3 "Upvote"]');
|
||||
});
|
||||
|
||||
it("does not summarize login forms as semantic collections", () => {
|
||||
it("keeps only local context for duplicate labels", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "北京市小客车指标调控管理信息系统" }),
|
||||
node({ id: 2, parentId: 1, role: "list" }),
|
||||
node({ id: 3, parentId: 2, role: "listitem", name: "用户登录" }),
|
||||
node({ id: 4, parentId: 2, role: "listitem", name: "手机号:" }),
|
||||
node({
|
||||
id: 5,
|
||||
parentId: 4,
|
||||
role: "textbox",
|
||||
value: "13311030827",
|
||||
tag: "input",
|
||||
}),
|
||||
node({ id: 6, parentId: 2, role: "listitem", name: "密 码:" }),
|
||||
node({
|
||||
id: 7,
|
||||
parentId: 6,
|
||||
role: "textbox",
|
||||
value: "secret",
|
||||
tag: "input",
|
||||
sensitive: true,
|
||||
}),
|
||||
node({ id: 8, parentId: 2, role: "listitem", name: "验证码:" }),
|
||||
node({ id: 9, parentId: 8, role: "textbox", value: "h8Mh", tag: "input" }),
|
||||
node({ id: 10, parentId: 2, role: "listitem" }),
|
||||
node({ id: 11, parentId: 10, role: "button", name: "登录", tag: "button" }),
|
||||
node({ id: 2, parentId: 1, role: "section" }),
|
||||
node({ id: 3, parentId: 2, role: "StaticText", name: "账号" }),
|
||||
node({ id: 4, parentId: 2, role: "link", name: "操作说明", tag: "a" }),
|
||||
node({ id: 5, parentId: 1, role: "section" }),
|
||||
node({ id: 6, parentId: 5, role: "StaticText", name: "配置指标" }),
|
||||
node({ id: 7, parentId: 5, role: "link", name: "操作说明", tag: "a" }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).not.toContain("@collections");
|
||||
expect(out.text).toContain('@e2 textbox [filled] ="•••"');
|
||||
expect(out.text).toContain('@e1 link "操作说明" [ctx: 账号]');
|
||||
expect(out.text).toContain('@e2 link "操作说明" [ctx: 配置指标]');
|
||||
expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]");
|
||||
});
|
||||
|
||||
it("does not add low-value root context to duplicated brand links", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "北京市小客车指标调控管理信息系统" }),
|
||||
node({ id: 2, parentId: 1, role: "link", name: "北京市交通委员会", tag: "a" }),
|
||||
node({ id: 3, parentId: 1, role: "link", name: "北京市交通委员会", tag: "a" }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).toContain('@e1 link "北京市交通委员会"');
|
||||
expect(out.text).toContain('@e2 link "北京市交通委员会"');
|
||||
expect(out.text).not.toContain("[ctx:");
|
||||
});
|
||||
|
||||
it("does not render redundant table cell descendants when the cell name covers them", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||
node({ id: 2, parentId: 1, role: "table" }),
|
||||
node({ id: 3, parentId: 2, role: "row" }),
|
||||
node({ id: 4, parentId: 3, role: "cell", name: "申请人姓名:刘珺瑶" }),
|
||||
node({ id: 5, parentId: 4, role: "StaticText", name: "申请人姓名:" }),
|
||||
node({ id: 6, parentId: 4, role: "StaticText", name: "刘珺瑶" }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).toContain('cell "申请人姓名:刘珺瑶"');
|
||||
expect(out.text).not.toContain('StaticText "申请人姓名:"');
|
||||
expect(out.text).not.toContain('StaticText "刘珺瑶"');
|
||||
});
|
||||
|
||||
it("keeps interactive descendants inside table cells", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||
node({ id: 2, parentId: 1, role: "table" }),
|
||||
node({ id: 3, parentId: 2, role: "row" }),
|
||||
node({ id: 4, parentId: 3, role: "cell", name: "操作:下载" }),
|
||||
node({ id: 5, parentId: 4, role: "link", name: "下载", tag: "a" }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).toContain('cell "操作:下载"');
|
||||
expect(out.text).toContain('@e1 link "下载"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+55
-409
@@ -83,12 +83,6 @@ const MAX_RECOVERABLE_SINGLE_INTENT_AREA = 360_000;
|
||||
const MAX_HANDLE_CONTEXT_ITEMS = 3;
|
||||
const MAX_SURFACE_ITEMS = 12;
|
||||
const MAX_SCOPE_LINES = 40;
|
||||
const MAX_SEMANTIC_COLLECTIONS = 3;
|
||||
const MAX_SEMANTIC_ITEMS = 20;
|
||||
const MAX_SEMANTIC_CONTROLS = 8;
|
||||
const MAX_SEMANTIC_ACTIONS = 6;
|
||||
const MAX_SEMANTIC_FIELD_CHARS = 120;
|
||||
|
||||
const RECOVERY_HANDLER_ATTRS = ["onclick", "onmousedown", "onkeydown", "onkeyup", "onkeypress"];
|
||||
const NATIVE_TAGS = new Set(["button", "input", "select", "textarea"]);
|
||||
const GRAPHIC_ONLY_TAGS = new Set(["svg", "use", "path", "g", "symbol"]);
|
||||
@@ -157,6 +151,17 @@ function cleanSemanticText(value: string | undefined): string | undefined {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function isLowValueContext(value: string): boolean {
|
||||
const clean = value.trim();
|
||||
const key = clean.toLowerCase();
|
||||
if (clean === "欢迎") return true;
|
||||
if (key.includes("icp") || key.includes("备案")) return true;
|
||||
if (clean.includes("联系方式") || clean.includes("政府网站标识")) return true;
|
||||
if (/^\d{5,}$/.test(clean)) return true;
|
||||
if (/北京市小客车指标调控管理信息系统|beijing municipal commission/i.test(clean)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizedRole(node: VomNode): string {
|
||||
return node.role?.toLowerCase() ?? "";
|
||||
}
|
||||
@@ -431,6 +436,7 @@ function weakContextText(label: string | undefined, targetName: string): string
|
||||
const key = normalizeContextKey(clean);
|
||||
if (
|
||||
!key ||
|
||||
isLowValueContext(clean) ||
|
||||
key === normalizeContextKey(targetName) ||
|
||||
key.startsWith("ctx_") ||
|
||||
key === "section" ||
|
||||
@@ -450,7 +456,7 @@ interface ContextCandidate {
|
||||
}
|
||||
|
||||
function contextSourceRank(source: ContextSource): number {
|
||||
return source === "owned" ? 0 : source === "same" ? 1 : 2;
|
||||
return source === "same" ? 0 : source === "owned" ? 1 : 2;
|
||||
}
|
||||
|
||||
function contextCategory(key: string): string {
|
||||
@@ -482,7 +488,7 @@ function dedupeContexts(contexts: ContextCandidate[]): string[] {
|
||||
seen.add(key);
|
||||
seenCategories.add(category);
|
||||
unique.push(clean);
|
||||
if (unique.length >= MAX_HANDLE_CONTEXT_ITEMS) break;
|
||||
if (unique.length >= 1) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
@@ -611,9 +617,12 @@ function renderNodeLine(
|
||||
|
||||
const rawValue = cleaned(node.value);
|
||||
const role = normalizedRole(node);
|
||||
if (["textbox", "searchbox"].includes(role)) {
|
||||
line += rawValue === undefined ? " [empty]" : " [filled]";
|
||||
const fillable = ["textbox", "searchbox"].includes(role);
|
||||
if (fillable) {
|
||||
line += ` [${node.inputState ?? (rawValue === undefined ? "empty" : "filled")}]`;
|
||||
}
|
||||
const placeholder = cleaned(node.placeholder);
|
||||
if (placeholder) line += ` placeholder=${JSON.stringify(placeholder)}`;
|
||||
const value = node.sensitive
|
||||
? rawValue !== undefined
|
||||
? SENSITIVE_MASK
|
||||
@@ -644,6 +653,29 @@ function shouldSkipRedundantRefChildren(node: VomNode): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function descendantTextCoveredByName(node: VomNode, state: RenderState): boolean {
|
||||
const name = cleaned(node.name);
|
||||
if (!name) return false;
|
||||
const stack = [...(state.children.get(node.id) ?? [])];
|
||||
const texts: string[] = [];
|
||||
while (stack.length > 0) {
|
||||
const child = stack.pop() as VomNode;
|
||||
if (shouldReference(child)) return false;
|
||||
const text = cleaned(child.name) ?? cleaned(child.text) ?? cleaned(child.value);
|
||||
if (text) texts.push(text);
|
||||
stack.push(...(state.children.get(child.id) ?? []));
|
||||
}
|
||||
return texts.length > 0 && texts.every((text) => name.includes(text));
|
||||
}
|
||||
|
||||
function shouldSkipRedundantChildren(node: VomNode, state: RenderState): boolean {
|
||||
const role = normalizedRole(node);
|
||||
if (["cell", "gridcell", "columnheader", "rowheader"].includes(role)) {
|
||||
return descendantTextCoveredByName(node, state);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface RenderState {
|
||||
lines: string[];
|
||||
refs: Array<{ ref: string; backendNodeId: number }>;
|
||||
@@ -733,7 +765,9 @@ function renderTree(
|
||||
const scope = state.scopeMap.get(node.id);
|
||||
if (scope) emitActiveScopeBlock(scope, depth + 1, state);
|
||||
|
||||
if (ref && shouldSkipRedundantRefChildren(node)) continue;
|
||||
if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) {
|
||||
continue;
|
||||
}
|
||||
renderTree(children, node.id, depth + 1, state);
|
||||
}
|
||||
}
|
||||
@@ -910,386 +944,6 @@ function countRenderable(nodes: VomNode[]): number {
|
||||
return nodes.filter(shouldRender).length;
|
||||
}
|
||||
|
||||
interface SemanticControl {
|
||||
kind: string;
|
||||
handle?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SemanticAction {
|
||||
handle: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SemanticItem {
|
||||
title?: string;
|
||||
titleHandle?: string;
|
||||
author?: string;
|
||||
time?: string;
|
||||
score?: string;
|
||||
comments?: string;
|
||||
commentsHandle?: string;
|
||||
actions: SemanticAction[];
|
||||
}
|
||||
|
||||
interface SemanticCollection {
|
||||
role: string;
|
||||
itemRole: string;
|
||||
label?: string;
|
||||
controls: SemanticControl[];
|
||||
items: SemanticItem[];
|
||||
}
|
||||
|
||||
function truncateField(value: string): string {
|
||||
if ([...value].length <= MAX_SEMANTIC_FIELD_CHARS) return value;
|
||||
return `${[...value].slice(0, MAX_SEMANTIC_FIELD_CHARS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function quoteField(value: string): string {
|
||||
return JSON.stringify(truncateField(value));
|
||||
}
|
||||
|
||||
function roleIsCollectionItem(role: string): boolean {
|
||||
return role === "article" || role === "row" || role === "listitem";
|
||||
}
|
||||
|
||||
function roleIsCollectionContainer(role: string): boolean {
|
||||
return ["main", "feed", "list", "table", "grid", "rowgroup", "region", "section"].includes(role);
|
||||
}
|
||||
|
||||
function textForSemantics(node: VomNode): string | undefined {
|
||||
return (
|
||||
cleanSemanticText(node.name) ??
|
||||
cleanSemanticText(node.value) ??
|
||||
cleanSemanticText(node.text) ??
|
||||
cleanSemanticText(node.nearbyText)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRefLookup(refs: Array<{ ref: string; backendNodeId: number }>): Map<number, string> {
|
||||
return new Map(
|
||||
refs.map((entry) => [
|
||||
entry.backendNodeId,
|
||||
entry.ref.startsWith("@") ? entry.ref : `@${entry.ref}`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function collectDescendantNodes(
|
||||
children: Map<number | null, VomNode[]>,
|
||||
rootId: number,
|
||||
nodesById: Map<number, VomNode>,
|
||||
): VomNode[] {
|
||||
const out: VomNode[] = [];
|
||||
const stack = [rootId];
|
||||
const seen = new Set<number>();
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop() as number;
|
||||
if (!seen.add(id)) continue;
|
||||
const node = nodesById.get(id);
|
||||
if (node) out.push(node);
|
||||
for (const child of [...(children.get(id) ?? [])].reverse()) {
|
||||
stack.push(child.id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectDescendantIdSet(
|
||||
children: Map<number | null, VomNode[]>,
|
||||
rootId: number,
|
||||
): Set<number> {
|
||||
const out = new Set<number>();
|
||||
const stack = [rootId];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop() as number;
|
||||
if (!out.add(id)) continue;
|
||||
for (const child of [...(children.get(id) ?? [])].reverse()) stack.push(child.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectionLabel(
|
||||
node: VomNode,
|
||||
children: Map<number | null, VomNode[]>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
): string | undefined {
|
||||
return (
|
||||
contextLabel(node, "") ??
|
||||
collectDescendantNodes(children, node.id, nodesById).find(
|
||||
(candidate) => normalizedRole(candidate) === "heading" && textForSemantics(candidate),
|
||||
)?.name
|
||||
);
|
||||
}
|
||||
|
||||
function classifyControl(label: string, role: string): string | undefined {
|
||||
const key = label.toLowerCase();
|
||||
if (role === "searchbox" || key.includes("search")) return "search";
|
||||
if (key.includes("sort") || key.includes("order")) return "sort";
|
||||
if (key.includes("filter")) return "filter";
|
||||
if (role === "tab") return "tab";
|
||||
if (key === "next" || key.includes("next page")) return "pagination";
|
||||
if (key === "previous" || key.includes("previous page")) return "pagination";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCollectionControls(
|
||||
container: VomNode,
|
||||
items: VomNode[],
|
||||
children: Map<number | null, VomNode[]>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
refLookup: Map<number, string>,
|
||||
): SemanticControl[] {
|
||||
const itemIds = new Set(items.flatMap((item) => [...collectDescendantIdSet(children, item.id)]));
|
||||
const controls: SemanticControl[] = [];
|
||||
for (const node of collectDescendantNodes(children, container.id, nodesById)) {
|
||||
if (itemIds.has(node.id)) continue;
|
||||
const role = normalizedRole(node);
|
||||
if (!["button", "combobox", "searchbox", "tab", "link"].includes(role)) continue;
|
||||
const label = textForSemantics(node);
|
||||
if (!label) continue;
|
||||
const kind = classifyControl(label, role);
|
||||
if (!kind) continue;
|
||||
controls.push({ kind, handle: refLookup.get(node.id), label });
|
||||
if (controls.length >= MAX_SEMANTIC_CONTROLS) break;
|
||||
}
|
||||
return controls;
|
||||
}
|
||||
|
||||
function isFormLikeCollection(
|
||||
container: VomNode,
|
||||
items: VomNode[],
|
||||
children: Map<number | null, VomNode[]>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
): boolean {
|
||||
const descendants = collectDescendantNodes(children, container.id, nodesById);
|
||||
const interactive = descendants.filter((node) => shouldReference(node));
|
||||
const textboxCount = interactive.filter((node) =>
|
||||
["textbox", "searchbox"].includes(normalizedRole(node)),
|
||||
).length;
|
||||
const sensitiveCount = descendants.filter((node) => node.sensitive).length;
|
||||
const labels = descendants
|
||||
.map((node) => textForSemantics(node))
|
||||
.filter((label): label is string => label !== undefined)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const hasLoginSignals =
|
||||
/(用户登录|登录|验证码|密码|忘记密码|手机号|证书口令|captcha|password|sign in|log in)/i.test(
|
||||
labels,
|
||||
);
|
||||
|
||||
return (
|
||||
(textboxCount >= 2 && hasLoginSignals) ||
|
||||
(sensitiveCount > 0 && hasLoginSignals) ||
|
||||
(items.length <= 8 && textboxCount > 0 && interactive.length >= 3 && hasLoginSignals)
|
||||
);
|
||||
}
|
||||
|
||||
function isCommentLabel(label: string): boolean {
|
||||
const key = label.toLowerCase();
|
||||
return key.includes("comment") || key === "no comments";
|
||||
}
|
||||
|
||||
function parseScoreBetweenVoteButtons(
|
||||
item: VomNode,
|
||||
children: Map<number | null, VomNode[]>,
|
||||
): string | undefined {
|
||||
for (const descendantId of collectDescendantIdSet(children, item.id)) {
|
||||
const siblings = children.get(descendantId) ?? [];
|
||||
for (let index = 0; index + 2 < siblings.length; index += 1) {
|
||||
const prev = textForSemantics(siblings[index])?.toLowerCase() ?? "";
|
||||
const score = textForSemantics(siblings[index + 1]);
|
||||
const next = textForSemantics(siblings[index + 2])?.toLowerCase() ?? "";
|
||||
if (
|
||||
prev.includes("upvote") &&
|
||||
next.includes("downvote") &&
|
||||
score !== undefined &&
|
||||
/^-?\d+$/.test(score.replaceAll(",", ""))
|
||||
) {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractAuthor(descendants: VomNode[]): string | undefined {
|
||||
for (let index = 0; index < descendants.length; index += 1) {
|
||||
const label = textForSemantics(descendants[index]);
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (key === "submitted by" || key === "by" || key.endsWith(" by")) {
|
||||
for (const candidate of descendants.slice(index + 1, index + 9)) {
|
||||
if (normalizedRole(candidate) === "link") return textForSemantics(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSemanticItem(
|
||||
item: VomNode,
|
||||
children: Map<number | null, VomNode[]>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
refLookup: Map<number, string>,
|
||||
): SemanticItem {
|
||||
const descendants = collectDescendantNodes(children, item.id, nodesById);
|
||||
let title: string | undefined;
|
||||
let titleHandle: string | undefined;
|
||||
for (const role of ["heading", "link"]) {
|
||||
const node = descendants.find(
|
||||
(candidate) => normalizedRole(candidate) === role && textForSemantics(candidate),
|
||||
);
|
||||
if (!node) continue;
|
||||
title = textForSemantics(node);
|
||||
titleHandle = refLookup.get(node.id);
|
||||
if (!titleHandle && title) {
|
||||
const matchingLink = descendants.find(
|
||||
(candidate) =>
|
||||
normalizedRole(candidate) === "link" && textForSemantics(candidate) === title,
|
||||
);
|
||||
if (matchingLink) titleHandle = refLookup.get(matchingLink.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!title) {
|
||||
title = textForSemantics(item);
|
||||
titleHandle = refLookup.get(item.id);
|
||||
}
|
||||
|
||||
let time: string | undefined;
|
||||
let comments: string | undefined;
|
||||
let commentsHandle: string | undefined;
|
||||
const actions: SemanticAction[] = [];
|
||||
for (const node of descendants) {
|
||||
const role = normalizedRole(node);
|
||||
const label = textForSemantics(node);
|
||||
if (!time && role === "time") time = label;
|
||||
if (!comments && label && isCommentLabel(label)) {
|
||||
comments = label;
|
||||
commentsHandle = refLookup.get(node.id);
|
||||
}
|
||||
if (
|
||||
actions.length < MAX_SEMANTIC_ACTIONS &&
|
||||
shouldReference(node) &&
|
||||
!["link", "textbox", "searchbox"].includes(role)
|
||||
) {
|
||||
const handle = refLookup.get(node.id);
|
||||
if (handle && label) actions.push({ handle, label });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
titleHandle,
|
||||
author: extractAuthor(descendants),
|
||||
time,
|
||||
score: parseScoreBetweenVoteButtons(item, children),
|
||||
comments,
|
||||
commentsHandle,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
function detectSemanticCollections(
|
||||
nodes: VomNode[],
|
||||
refs: Array<{ ref: string; backendNodeId: number }>,
|
||||
): SemanticCollection[] {
|
||||
const children = buildChildren(nodes);
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const refLookup = buildRefLookup(refs);
|
||||
const collections: SemanticCollection[] = [];
|
||||
const coveredItems = new Set<number>();
|
||||
|
||||
for (const container of nodes) {
|
||||
if (collections.length >= MAX_SEMANTIC_COLLECTIONS) break;
|
||||
const role = normalizedRole(container);
|
||||
if (!roleIsCollectionContainer(role)) continue;
|
||||
const childList = children.get(container.id) ?? [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const child of childList) {
|
||||
const childRole = normalizedRole(child);
|
||||
if (roleIsCollectionItem(childRole)) counts.set(childRole, (counts.get(childRole) ?? 0) + 1);
|
||||
}
|
||||
const best = [...counts].filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1])[0];
|
||||
if (!best) continue;
|
||||
const [itemRole] = best;
|
||||
const items = childList.filter(
|
||||
(child) => normalizedRole(child) === itemRole && !coveredItems.has(child.id),
|
||||
);
|
||||
if (items.length < 2) continue;
|
||||
if (isFormLikeCollection(container, items, children, nodesById)) continue;
|
||||
for (const item of items) coveredItems.add(item.id);
|
||||
collections.push({
|
||||
role,
|
||||
itemRole,
|
||||
label: collectionLabel(container, children, nodesById),
|
||||
controls: collectCollectionControls(container, items, children, nodesById, refLookup),
|
||||
items: items
|
||||
.slice(0, MAX_SEMANTIC_ITEMS)
|
||||
.map((item) => extractSemanticItem(item, children, nodesById, refLookup)),
|
||||
});
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
function renderSemanticCollections(collections: SemanticCollection[]): string[] {
|
||||
if (collections.length === 0) return [];
|
||||
const lines = ["@collections"];
|
||||
for (const [index, collection] of collections.entries()) {
|
||||
const label = collection.label ? ` label=${quoteField(collection.label)}` : "";
|
||||
lines.push(
|
||||
`collection c${index + 1} role=${collection.role} item_role=${collection.itemRole}${label} items=${collection.items.length}`,
|
||||
);
|
||||
if (collection.controls.length > 0) {
|
||||
const rendered = collection.controls
|
||||
.map(
|
||||
(control) =>
|
||||
`${control.kind}=${control.handle ? `${control.handle} ` : ""}${quoteField(control.label)}`,
|
||||
)
|
||||
.join("; ");
|
||||
lines.push(` controls: ${rendered}`);
|
||||
}
|
||||
lines.push(
|
||||
" columns: index title title_handle author time score comments comments_handle actions",
|
||||
);
|
||||
collection.items.forEach((item, itemIndex) => {
|
||||
const parts = [`${itemIndex + 1}. `];
|
||||
if (item.title) parts.push(`title=${quoteField(item.title)}`);
|
||||
if (item.titleHandle) parts.push(`title_handle=${item.titleHandle}`);
|
||||
if (item.author) parts.push(`author=${quoteField(item.author)}`);
|
||||
if (item.time) parts.push(`time=${quoteField(item.time)}`);
|
||||
if (item.score) parts.push(`score=${quoteField(item.score)}`);
|
||||
if (item.comments) parts.push(`comments=${quoteField(item.comments)}`);
|
||||
if (item.commentsHandle) parts.push(`comments_handle=${item.commentsHandle}`);
|
||||
if (item.actions.length > 0) {
|
||||
parts.push(
|
||||
`actions=[${item.actions.map((action) => `${action.handle} ${quoteField(action.label)}`).join(", ")}]`,
|
||||
);
|
||||
}
|
||||
lines.push(` ${parts.join(" ")}`);
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function insertAfterHeader(text: string, insertLines: string[]): string {
|
||||
if (insertLines.length === 0) return text;
|
||||
const lines = text.split("\n");
|
||||
const l1Index = lines.findIndex((line) => line.startsWith("L1 "));
|
||||
const insertAt = l1Index >= 0 ? l1Index + 1 : Math.min(lines.length, 3);
|
||||
lines.splice(insertAt, 0, ...insertLines);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function augmentVomResult(result: VomResult, nodes: VomNode[], options: VomOptions): VomResult {
|
||||
if (!options.semanticCollections) return result;
|
||||
const semanticLines = renderSemanticCollections(detectSemanticCollections(nodes, result.refs));
|
||||
return semanticLines.length === 0
|
||||
? result
|
||||
: { ...result, text: insertAfterHeader(result.text, semanticLines) };
|
||||
}
|
||||
|
||||
function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult {
|
||||
const included = collectDescendantsOfMembers(scene.nodes, layer.members);
|
||||
const visibleNodes = scene.nodes.filter((node) => included.has(node.id));
|
||||
@@ -1303,15 +957,11 @@ function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOp
|
||||
const state = renderNodes(visibleNodes, options, header, scene.surfaces, scene.activeScopeBlocks);
|
||||
state.lines.push(renderPageOcclusionLine(hiddenCount));
|
||||
|
||||
return augmentVomResult(
|
||||
{
|
||||
text: state.lines.join("\n"),
|
||||
refs: state.refs,
|
||||
truncated: state.truncated,
|
||||
},
|
||||
visibleNodes,
|
||||
options,
|
||||
);
|
||||
return {
|
||||
text: state.lines.join("\n"),
|
||||
refs: state.refs,
|
||||
truncated: state.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult {
|
||||
@@ -1334,13 +984,9 @@ export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult
|
||||
scene.activeScopeBlocks,
|
||||
);
|
||||
|
||||
return augmentVomResult(
|
||||
{
|
||||
text: state.lines.join("\n"),
|
||||
refs: state.refs,
|
||||
truncated: state.truncated,
|
||||
},
|
||||
visibleNodes,
|
||||
options,
|
||||
);
|
||||
return {
|
||||
text: state.lines.join("\n"),
|
||||
refs: state.refs,
|
||||
truncated: state.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface VomNode {
|
||||
role?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
inputState?: "empty" | "filled" | "default" | "unknown";
|
||||
href?: string; // hostname of external link target; omitted for same-origin links
|
||||
text?: string;
|
||||
nearbyText?: string;
|
||||
@@ -63,12 +65,6 @@ export interface VomOptions {
|
||||
* public layer header format.
|
||||
*/
|
||||
activeRegionPolicy?: boolean;
|
||||
/**
|
||||
* Experimental: emit semantic summaries for repeated collection-like
|
||||
* containers. Disabled by default because false positives in menus/forms are
|
||||
* still noisy.
|
||||
*/
|
||||
semanticCollections?: boolean;
|
||||
}
|
||||
|
||||
export interface CondSurface {
|
||||
|
||||
Reference in New Issue
Block a user