feat: 接入本地执行内核与运行面板
This commit is contained in:
1 parent
32ca0a1af8
commit
6a2cc24898
23 files changed
+2937
-41
No files matched your search
@@ -0,0 +1,315 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { RuntimeError, type BrowserLike } from "../engine/types";
|
||||
|
||||
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
|
||||
|
||||
function allowedDomains(): string[] {
|
||||
const extra = (process.env.RPA_ALLOWED_DOMAINS || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return [...DEFAULT_DOMAINS, ...extra];
|
||||
}
|
||||
|
||||
function assertUrl(raw: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new RuntimeError("blocked_url", `无效的 URL:${raw}`, { recommendation: "填写完整的 http/https 地址" });
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new RuntimeError("blocked_url", `只允许 http/https,当前为 ${url.protocol}`);
|
||||
}
|
||||
const host = url.hostname;
|
||||
const allowed = allowedDomains().some((domain) => host === domain || host.endsWith(`.${domain}`));
|
||||
if (!allowed) {
|
||||
throw new RuntimeError("blocked_url", `域名不在白名单:${host}`, {
|
||||
recommendation: `在设置中把 ${host} 加入白名单(或设置 RPA_ALLOWED_DOMAINS)`
|
||||
});
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
interface ElementInfo {
|
||||
tag: string;
|
||||
text: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string } {
|
||||
if (selector && typeof selector === "object") {
|
||||
const record = selector as Record<string, unknown>;
|
||||
if (record.kind && record.value) {
|
||||
return { kind: String(record.kind), value: String(record.value), role: record.role as string, name: record.name as string };
|
||||
}
|
||||
const common = record.commonProps as Record<string, unknown> | undefined;
|
||||
if (common?.cssSelector) {
|
||||
return { kind: "css", value: String(common.cssSelector) };
|
||||
}
|
||||
if (record.selector && Array.isArray(record.selector)) {
|
||||
const last = [...record.selector].reverse().find((item) => (item as Record<string, unknown>)?.name);
|
||||
if (last) {
|
||||
const item = last as Record<string, unknown>;
|
||||
const props = (item.props as Array<Record<string, unknown>>) || [];
|
||||
const id = props.find((prop) => prop.name === "id")?.textValue;
|
||||
const cls = props.find((prop) => prop.name === "class")?.textValue;
|
||||
const tag = String(item.name);
|
||||
if (id) return { kind: "css", value: `${tag}#${id}` };
|
||||
if (cls) return { kind: "css", value: `${tag}.${String(cls).split(/\s+/).join(".")}` };
|
||||
return { kind: "css", value: tag };
|
||||
}
|
||||
}
|
||||
}
|
||||
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("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:")) {
|
||||
const rest = text.slice(5);
|
||||
const [role, name] = rest.split("=");
|
||||
return { kind: "aria", value: role, role, name: name || "" };
|
||||
}
|
||||
return { kind: "css", value: text };
|
||||
}
|
||||
|
||||
export class BrowserSession implements BrowserLike {
|
||||
private win: BrowserWindow;
|
||||
|
||||
private constructor(win: BrowserWindow) {
|
||||
this.win = win;
|
||||
}
|
||||
|
||||
static async create(): Promise<BrowserSession> {
|
||||
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
||||
return new BrowserSession(globalThis.__rpaBrowserWindow);
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 820,
|
||||
show: true,
|
||||
title: "RPA 运行浏览器",
|
||||
backgroundColor: "#ffffff",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
globalThis.__rpaBrowserWindow = win;
|
||||
win.on("closed", () => {
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
});
|
||||
await win.loadURL("about:blank");
|
||||
if (!win.webContents.debugger.isAttached()) {
|
||||
win.webContents.debugger.attach("1.3");
|
||||
}
|
||||
return new BrowserSession(win);
|
||||
}
|
||||
|
||||
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
if (this.win.isDestroyed()) {
|
||||
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
|
||||
}
|
||||
try {
|
||||
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
|
||||
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
|
||||
} catch (error) {
|
||||
if (/destroyed|Target closed|not attached/i.test((error as Error).message)) {
|
||||
throw new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: (error as Error).message });
|
||||
}
|
||||
throw new RuntimeError("browser_error", (error as Error).message, { recommendation: "重试或检查页面状态" });
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
||||
const result = await this.cdp<{ result?: { value?: T }; exceptionDetails?: { exception?: { description?: string }; text?: string } }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression, returnByValue: true, awaitPromise: true }
|
||||
);
|
||||
if (result.exceptionDetails) {
|
||||
throw new RuntimeError(
|
||||
"evaluate_error",
|
||||
result.exceptionDetails.exception?.description || result.exceptionDetails.text || "页面脚本执行失败"
|
||||
);
|
||||
}
|
||||
return result.result?.value as T;
|
||||
}
|
||||
|
||||
async navigate(url: string): Promise<{ url: string; title: string }> {
|
||||
const target = assertUrl(url);
|
||||
await this.cdp("Page.enable");
|
||||
await this.win.loadURL(target.toString());
|
||||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
||||
}
|
||||
|
||||
async locate(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||||
const spec = selectorSpec(selector);
|
||||
const expression = `(() => {
|
||||
const spec = ${JSON.stringify(spec)};
|
||||
const pick = (nodes) => {
|
||||
for (const el of nodes) {
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let el = null;
|
||||
if (spec.kind === 'text') {
|
||||
const target = spec.value;
|
||||
const all = [...document.querySelectorAll('*')].filter((node) => {
|
||||
const text = (node.innerText || node.textContent || '').trim();
|
||||
return text === target || (text.includes(target) && text.length <= target.length + 20);
|
||||
});
|
||||
el = pick(all.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length));
|
||||
} else if (spec.kind === 'aria') {
|
||||
const role = spec.role || spec.value;
|
||||
const name = spec.name || '';
|
||||
const all = [...document.querySelectorAll('[role="' + role + '"], ' + role)];
|
||||
el = pick(all.filter((node) => {
|
||||
const acc = node.getAttribute('aria-label') || node.innerText || '';
|
||||
return !name || acc.includes(name);
|
||||
}));
|
||||
} else {
|
||||
el = pick([...document.querySelectorAll(spec.value)]);
|
||||
}
|
||||
if (!el) return null;
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.innerText || el.textContent || '').trim().slice(0, 200),
|
||||
value: el.value ?? '',
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
disabled: Boolean(el.disabled),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
};
|
||||
})()`;
|
||||
const info = await this.evaluate<ElementInfo | null>(expression);
|
||||
if (!info) {
|
||||
throw new RuntimeError("not_found", `未找到元素:${spec.kind}:${spec.value}`, {
|
||||
recommendation: "检查选择器,或先用拾取器重新采集"
|
||||
});
|
||||
}
|
||||
return { ...info, selector: `${spec.kind}:${spec.value}` };
|
||||
}
|
||||
|
||||
private async mouseClick(x: number, y: number): Promise<void> {
|
||||
const point = { x: Math.round(x), y: Math.round(y) };
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, button: "none" });
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", clickCount: 1 });
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", clickCount: 1 });
|
||||
}
|
||||
|
||||
async click(selector: unknown): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
if (!info.visible) {
|
||||
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
||||
}
|
||||
if (info.disabled) {
|
||||
throw new RuntimeError("not_found", `元素已禁用:${info.selector}`);
|
||||
}
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
return { selector: info.selector, tag: info.tag, text: info.text };
|
||||
}
|
||||
|
||||
async type(selector: unknown, text: string, clearFirst = true): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
if (clearFirst) {
|
||||
await this.evaluate(`(() => {
|
||||
const el = document.activeElement;
|
||||
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { el.select(); return true; }
|
||||
return false;
|
||||
})()`);
|
||||
await this.cdp("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
||||
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
||||
}
|
||||
await this.cdp("Input.insertText", { text });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const actual = await this.evaluate<string>(`(() => { const el = document.activeElement; return el ? (el.value ?? '') : ''; })()`);
|
||||
return { selector: info.selector, value: actual };
|
||||
}
|
||||
|
||||
async getText(selector: unknown, attribute?: string): Promise<string> {
|
||||
const info = await this.locate(selector);
|
||||
if (attribute) {
|
||||
const value = await this.evaluate<string | null>(`(() => {
|
||||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||||
const el = document.querySelector(spec.value);
|
||||
return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
|
||||
})()`);
|
||||
return value ?? "";
|
||||
}
|
||||
return info.value || info.text;
|
||||
}
|
||||
|
||||
async exists(selector: unknown): Promise<boolean> {
|
||||
try {
|
||||
await this.locate(selector);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async select(selector: unknown, value: string): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
const applied = await this.evaluate<boolean>(`(() => {
|
||||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||||
const el = document.querySelector(spec.value);
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
el.value = ${JSON.stringify(value)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
if (!applied) throw new RuntimeError("not_found", `下拉框未找到:${info.selector}`);
|
||||
return { selector: info.selector, value };
|
||||
}
|
||||
|
||||
async runJs(script: string): Promise<unknown> {
|
||||
return this.evaluate(script);
|
||||
}
|
||||
|
||||
async screenshot(filePath: string): Promise<string> {
|
||||
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
|
||||
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();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.win.isDestroyed()) this.win.close();
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __rpaBrowserWindow: BrowserWindow | null | undefined;
|
||||
}
|
||||
|
||||
export async function browserSession(): Promise<BrowserSession> {
|
||||
return BrowserSession.create();
|
||||
}
|
||||
|
||||
export function assertAllowedUrl(url: string): URL {
|
||||
return assertUrl(url);
|
||||
}
|
||||
Reference in new issue
Block a user