feat: 接入本地执行内核与运行面板

This commit is contained in:
yu committed 2026-10-26 17:41:39 +08:00
1 parent 32ca0a1af8
commit 6a2cc24898
23 files changed
+2937 -41

No files matched your search

+315
View File
@@ -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);
}