feat: 接入常用网页层级与数据表能力
This commit is contained in:
1 parent
741dc773c1
commit
fbdfb782bb
11 files changed
+958
-3
No files matched your search
+233
-1
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user