feat: 增加本地演示流程与网页DOM能力

This commit is contained in:
yu committed 2026-10-27 13:50:03 +08:00
1 parent d4799a4285
commit 6adf772987
19 files changed
+642 -9

No files matched your search

+131 -1
View File
@@ -1,7 +1,7 @@
import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import { RuntimeError, type BrowserLike } from "../engine/types";
import { RuntimeError, type BrowserElementRegion, type BrowserLike } from "../engine/types";
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
@@ -142,6 +142,53 @@ export class BrowserSession implements BrowserLike {
return result.result?.value as T;
}
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
const spec = selectorSpec(selector);
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
const spec = ${JSON.stringify(spec)};
const find = () => {
if (spec.kind === 'text') {
const target = spec.value;
const matches = [...document.querySelectorAll('*')].filter((node) => {
const text = (node.innerText || node.textContent || '').trim();
return text === target || (text.includes(target) && text.length <= target.length + 20);
});
return matches.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length)[0] || null;
}
if (spec.kind === 'aria') {
const role = spec.role || spec.value;
const name = spec.name || '';
return [...document.querySelectorAll('[role="' + role + '"], ' + role)].find((node) => {
const accessibleName = node.getAttribute('aria-label') || node.innerText || '';
return !name || accessibleName.includes(name);
}) || null;
}
return document.querySelector(spec.value);
};
const el = find();
if (!el) return { found: false };
return { found: true, value: (() => { ${operation} })() };
})()`);
if (!result.found) {
throw new RuntimeError("not_found", `Element not found: ${spec.kind}:${spec.value}`);
}
return result.value as T;
}
private regionOperation(): string {
return `
const rect = el.getBoundingClientRect();
const x = rect.x;
const y = rect.y;
return {
x, y, width: rect.width, height: rect.height,
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
centerX: x + rect.width / 2, centerY: y + rect.height / 2,
pageX: x + window.scrollX, pageY: y + window.scrollY
};
`;
}
async navigate(url: string): Promise<{ url: string; title: string }> {
const target = assertUrl(url);
await this.cdp("Page.enable");
@@ -318,6 +365,89 @@ export class BrowserSession implements BrowserLike {
return target;
}
async getAttribute(selector: unknown, name: string): Promise<string> {
return this.withElement<string>(selector, `return el.getAttribute(${JSON.stringify(name)}) ?? '';`);
}
async setAttribute(selector: unknown, name: string, value: string): Promise<unknown> {
return this.withElement(selector, `
el.setAttribute(${JSON.stringify(name)}, ${JSON.stringify(value)});
return { name: ${JSON.stringify(name)}, value: el.getAttribute(${JSON.stringify(name)}) ?? '' };
`);
}
async getHtml(selector: unknown): Promise<string> {
return this.withElement<string>(selector, "return el.outerHTML;");
}
async getChecked(selector: unknown): Promise<boolean> {
return this.withElement<boolean>(selector, `
if (!('checked' in el)) throw new Error('The target element has no checked state');
return Boolean(el.checked);
`);
}
async setChecked(selector: unknown, checked: boolean): Promise<boolean> {
return this.withElement<boolean>(selector, `
if (!('checked' in el)) throw new Error('The target element has no checked state');
const next = ${checked ? "true" : "false"};
if (Boolean(el.checked) !== next) {
el.checked = next;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
return Boolean(el.checked);
`);
}
async isEnabled(selector: unknown, includeHidden = false): Promise<boolean> {
return this.withElement<boolean>(selector, `
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
const visible = rect.width > 0 && rect.height > 0 && style.display !== 'none' &&
style.visibility !== 'hidden' && Number(style.opacity) !== 0;
const enabled = !Boolean(el.disabled) && el.getAttribute('aria-disabled') !== 'true' && style.pointerEvents !== 'none';
return enabled && (${includeHidden ? "true" : "false"} || visible);
`);
}
async scrollIntoView(selector: unknown): Promise<BrowserElementRegion> {
return this.withElement<BrowserElementRegion>(selector, `
el.scrollIntoView({ block: 'center', inline: 'center' });
${this.regionOperation()}
`);
}
async getRegion(selector: unknown): Promise<BrowserElementRegion> {
return this.withElement<BrowserElementRegion>(selector, this.regionOperation());
}
async hover(selector: unknown): Promise<BrowserElementRegion> {
const region = await this.scrollIntoView(selector);
await this.cdp("Input.dispatchMouseEvent", {
type: "mouseMoved", x: Math.round(region.centerX), y: Math.round(region.centerY), button: "none"
});
await new Promise((resolve) => setTimeout(resolve, 120));
return region;
}
async screenshotElement(selector: unknown, filePath: string, captureBeyondViewport = true): Promise<string> {
const region = await this.scrollIntoView(selector);
if (region.width <= 0 || region.height <= 0) {
throw new RuntimeError("not_found", "The target element has no screenshot area");
}
await this.cdp("Page.enable");
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", {
format: "png",
captureBeyondViewport,
clip: { x: region.pageX, y: region.pageY, width: region.width, height: region.height, scale: 1 }
});
const target = path.resolve(filePath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, Buffer.from(result.data, "base64"));
return target;
}
async currentUrl(): Promise<string> {
return this.win.webContents.getURL();
}