feat: 接入常用网页层级与数据表能力

This commit is contained in:
yu committed 2026-10-27 15:52:30 +08:00
1 parent 741dc773c1
commit fbdfb782bb
11 files changed
+958 -3

No files matched your search

+233 -1
View File
@@ -1,7 +1,15 @@
import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import { RuntimeError, type BrowserElementRegion, type BrowserLike } from "../engine/types";
import {
RuntimeError,
type BrowserElementInfo,
type BrowserElementReference,
type BrowserElementRegion,
type BrowserLike,
type BrowserSelectState,
type BrowserSimilarElementOptions
} from "../engine/types";
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
@@ -70,6 +78,7 @@ function selectorSpec(selector: unknown): { kind: string; value: string; role?:
const text = String(selector ?? "").trim();
if (!text) throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "使用 css:#id、text:提交 或 aria:button=提交" });
if (text.startsWith("css:")) return { kind: "css", value: text.slice(4) };
if (text.startsWith("xpath:")) return { kind: "xpath", value: text.slice(6) };
if (text.startsWith("testid:")) return { kind: "css", value: `[data-testid="${text.slice(7).replace(/"/g, '\\"')}"]` };
if (text.startsWith("text:")) return { kind: "text", value: text.slice(5) };
if (text.startsWith("aria:")) {
@@ -148,6 +157,9 @@ export class BrowserSession implements BrowserLike {
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
const spec = ${JSON.stringify(spec)};
const find = () => {
if (spec.kind === 'xpath') {
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
if (spec.kind === 'text') {
const target = spec.value;
const matches = [...document.querySelectorAll('*')].filter((node) => {
@@ -190,6 +202,54 @@ export class BrowserSession implements BrowserLike {
`;
}
private elementReferenceHelpers(): string {
return `
const cssEscape = (value) => {
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
};
const isUnique = (candidate) => {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
};
const cssPath = (node) => {
if (node.id) {
const candidate = '#' + cssEscape(node.id);
if (isUnique(candidate)) return candidate;
}
const testId = node.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate;
}
const parts = [];
let current = node;
while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase();
if (current.id) {
part += '#' + cssEscape(current.id);
parts.unshift(part);
break;
}
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
parts.unshift(part);
current = parent;
}
return parts.join(' > ');
};
const describe = (node) => ({
kind: 'css',
value: cssPath(node),
tag: node.tagName.toLowerCase(),
text: (node.innerText || node.textContent || '').trim().slice(0, 500),
elementValue: String(node.value ?? '')
});
`;
}
async navigate(url: string): Promise<{ url: string; title: string }> {
const target = assertUrl(url);
await this.cdp("Page.enable");
@@ -475,6 +535,178 @@ export class BrowserSession implements BrowserLike {
return target;
}
async getElement(selector: unknown): Promise<BrowserElementReference> {
return this.withElement<BrowserElementReference>(selector, `
${this.elementReferenceHelpers()}
return describe(el);
`);
}
async getElementInfo(selector: unknown): Promise<BrowserElementInfo> {
return this.withElement<BrowserElementInfo>(selector, `
${this.elementReferenceHelpers()}
const reference = describe(el);
const rect = el.getBoundingClientRect();
return {
...reference,
html: el.outerHTML,
href: String(el.href ?? el.getAttribute('href') ?? ''),
checked: 'checked' in el ? Boolean(el.checked) : null,
disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
attributes: Object.fromEntries([...el.attributes].map((item) => [item.name, item.value])),
region: {
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
centerX: rect.x + rect.width / 2, centerY: rect.y + rect.height / 2,
pageX: rect.x + window.scrollX, pageY: rect.y + window.scrollY
}
};
`);
}
async getParent(selector: unknown, level = 1, filter = ""): Promise<BrowserElementReference> {
const normalizedLevel = Math.max(1, Math.trunc(level));
const result = await this.withElement<BrowserElementReference | null>(selector, `
${this.elementReferenceHelpers()}
let target = el;
for (let index = 0; index < ${normalizedLevel}; index += 1) target = target?.parentElement || null;
if (!target) return null;
const filter = ${JSON.stringify(filter.trim())};
if (filter && !target.matches(filter)) return null;
return describe(target);
`);
if (!result) throw new RuntimeError("not_found", `未找到第 ${normalizedLevel} 层父元素`);
return result;
}
async getChildren(selector: unknown, index?: number, filter = ""): Promise<BrowserElementReference[]> {
const normalizedIndex = index && index > 0 ? Math.trunc(index) : 0;
const result = await this.withElement<BrowserElementReference[]>(selector, `
${this.elementReferenceHelpers()}
const filter = ${JSON.stringify(filter.trim())};
let children = [...el.children];
if (filter) children = children.filter((item) => item.matches(filter));
const index = ${normalizedIndex};
if (index > 0) children = children.slice(index - 1, index);
return children.map(describe);
`);
if (normalizedIndex > 0 && result.length === 0) {
throw new RuntimeError("not_found", `未找到第 ${normalizedIndex} 个子元素`);
}
return result;
}
async getSibling(
selector: unknown,
direction: "previous" | "next" = "next",
filter = ""
): Promise<BrowserElementReference> {
const result = await this.withElement<BrowserElementReference | null>(selector, `
${this.elementReferenceHelpers()}
const filter = ${JSON.stringify(filter.trim())};
let target = el.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
while (target && filter && !target.matches(filter)) {
target = target.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
}
return target ? describe(target) : null;
`);
if (!result) throw new RuntimeError("not_found", `未找到${direction === "previous" ? "上一个" : "下一个"}兄弟元素`);
return result;
}
async getSelectOptions(selector: unknown): Promise<BrowserSelectState> {
return this.withElement<BrowserSelectState>(selector, `
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
const options = [...el.options].map((item) => ({
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
}));
const selected = options.filter((item) => item.selected);
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
`);
}
async setSelectOptions(
selector: unknown,
values: string[],
mode: "replace" | "add" | "remove" = "replace"
): Promise<BrowserSelectState> {
return this.withElement<BrowserSelectState>(selector, `
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
const requested = ${JSON.stringify(values)}.map(String);
const mode = ${JSON.stringify(mode)};
const matched = new Set();
for (const option of [...el.options]) {
const hit = requested.includes(option.value) || requested.includes(option.text);
if (hit) matched.add(option.value);
if (mode === 'replace') option.selected = hit;
else if (mode === 'add' && hit) option.selected = true;
else if (mode === 'remove' && hit) option.selected = false;
}
if (matched.size === 0) throw new Error('未找到指定下拉选项');
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
const options = [...el.options].map((item) => ({
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
}));
const selected = options.filter((item) => item.selected);
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
`);
}
async getTableData(selector: unknown): Promise<string[][]> {
return this.withElement<string[][]>(selector, `
const table = el.matches('table') ? el : el.querySelector('table');
if (!table) throw new Error('目标不是标准 HTML table 元素');
return [...table.querySelectorAll('tr')].map((row) =>
[...row.querySelectorAll(':scope > th, :scope > td')].map((cell) =>
(cell.innerText || cell.textContent || '').trim()
)
);
`);
}
async getSimilarElements(
selector: unknown,
options: BrowserSimilarElementOptions = {}
): Promise<BrowserElementReference[]> {
const spec = selectorSpec(selector);
const parentSpec = options.parent === undefined ? null : selectorSpec(options.parent);
const start = Math.max(1, Math.trunc(options.start ?? 1));
const end = Math.trunc(options.end ?? -1);
const result = await this.evaluate<{ parentFound: boolean; items: BrowserElementReference[] }>(`(() => {
const spec = ${JSON.stringify(spec)};
const parentSpec = ${JSON.stringify(parentSpec)};
${this.elementReferenceHelpers()}
const resolveOne = (targetSpec) => {
if (!targetSpec) return document;
if (targetSpec.kind === 'xpath') {
return document.evaluate(targetSpec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
if (targetSpec.kind === 'css') return document.querySelector(targetSpec.value);
return null;
};
const root = resolveOne(parentSpec);
if (!root) return { parentFound: false, items: [] };
let nodes = [];
if (spec.kind === 'xpath') {
const snapshot = document.evaluate(spec.value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let index = 0; index < snapshot.snapshotLength; index += 1) {
const node = snapshot.snapshotItem(index);
if (node?.nodeType === 1 && (root === document || root.contains(node))) nodes.push(node);
}
} else if (spec.kind === 'css') {
nodes = [...root.querySelectorAll(spec.value)];
}
if (${options.reverse ? "true" : "false"}) nodes.reverse();
const startIndex = ${start} - 1;
const endValue = ${end};
const endIndex = endValue < 0 ? nodes.length + endValue + 1 : endValue;
return { parentFound: true, items: nodes.slice(startIndex, Math.max(startIndex, endIndex)).map(describe) };
})()`);
if (!result.parentFound) throw new RuntimeError("not_found", "未找到相似元素的父元素范围");
return result.items;
}
async currentUrl(): Promise<string> {
return this.win.webContents.getURL();
}