feat: 增加本地演示流程与网页DOM能力
This commit is contained in:
1 parent
d4799a4285
commit
6adf772987
19 files changed
+642
-9
No files matched your search
+131
-1
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user