PRA-only-demo/src/main/browser/session.ts

1057 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { ElementPickResult } from "../../shared/types";
const DEFAULT_ELEMENT_WAIT_MS = 5000;
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
export function setBrowserElementWaitMs(milliseconds: number): void {
const value = Number(milliseconds);
globalElementWaitMs = Number.isFinite(value)
? Math.min(600000, Math.max(0, Math.round(value)))
: DEFAULT_ELEMENT_WAIT_MS;
}
import {
RuntimeError,
type BrowserElementInfo,
type BrowserElementReference,
type BrowserElementRegion,
type BrowserLike,
type BrowserSelectState,
type BrowserSimilarElementOptions
} from "../engine/types";
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}`);
}
return url;
}
interface ElementInfo {
tag: string;
text: string;
value: string;
visible: boolean;
disabled: boolean;
rect: { x: number; y: number; width: number; height: number };
count: 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("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:")) {
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: process.env.RPA_E2E !== "1",
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;
}
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
const deadline = Date.now() + globalElementWaitMs;
for (;;) {
try {
return await this.withElementOnce<T>(selector, operation);
} catch (error) {
if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error;
await this.wait(120);
}
}
}
private async withElementOnce<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 === '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) => {
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
};
`;
}
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");
await this.win.loadURL(target.toString());
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
}
private async wait(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async locate(selector: unknown, timeoutMs?: number): Promise<ElementInfo & { selector: string }> {
const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs);
const deadline = Date.now() + waitMs;
for (;;) {
try {
return await this.locateOnce(selector);
} catch (error) {
const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline;
if (!canRetry) {
if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) {
error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`;
error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集";
}
throw error;
}
await this.wait(120);
}
}
}
private async locateOnce(selector: unknown): Promise<ElementInfo & { selector: string }> {
const spec = selectorSpec(selector);
const expression = `(() => {
const spec = ${JSON.stringify(spec)};
const visible = (nodes) => {
const result = [];
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;
result.push(el);
}
return result;
};
let matches = [];
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);
});
matches = visible(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)];
matches = visible(all.filter((node) => {
const acc = node.getAttribute('aria-label') || node.innerText || '';
return !name || acc.includes(name);
}));
} else {
matches = visible([...document.querySelectorAll(spec.value)]);
}
const el = matches[0] || null;
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),
count: matches.length,
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: "检查选择器,或先用拾取器重新采集"
});
}
if (info.count > 1) {
throw new RuntimeError("ambiguous", `选择器匹配到 ${info.count} 个可见元素:${spec.kind}:${spec.value}`, {
recommendation: "使用更稳定的 data-testid、aria-label 或唯一 CSS 选择器"
});
}
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", buttons: 0 });
await new Promise((resolve) => setTimeout(resolve, 35));
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", buttons: 1, clickCount: 1 });
await new Promise((resolve) => setTimeout(resolve, 25));
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
}
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs);
if (!info.visible) {
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
}
if (info.disabled) {
throw new RuntimeError("not_found", `元素已禁用:${info.selector}`);
}
const clickable = await this.withElement<boolean>(selector, `
const rect = el.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return Boolean(hit && (hit === el || el.contains(hit) || hit.contains(el)));
`);
if (!clickable) throw new RuntimeError("not_found", `目标元素被其他内容遮挡:${info.selector}`, { recommendation: "关闭遮挡弹窗或等待页面稳定" });
const clickToken = `rpa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
await this.withElement(selector, `
const token = ${JSON.stringify(clickToken)};
el.addEventListener('click', () => { el.dataset.rpaClickToken = token; }, { once: true });
return true;
`);
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
await new Promise((resolve) => setTimeout(resolve, 180));
const clicked = await this.withElement<boolean>(selector, `
const matched = el.dataset.rpaClickToken === ${JSON.stringify(clickToken)};
delete el.dataset.rpaClickToken;
return matched;
`);
if (!clicked) throw new RuntimeError("not_found", `目标未收到可信点击:${info.selector}`, { recommendation: "检查元素是否被遮挡或重新拾取元素" });
return { selector: info.selector, tag: info.tag, text: info.text };
}
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs);
this.win.show();
this.win.focus();
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
const focused = await this.withElement<boolean>(selector, `
el.focus({ focusVisible: true });
return document.activeElement === el || el.contains(document.activeElement);
`);
if (!focused) {
const direct = await this.setValueDirect(selector, clearFirst ? text : `${info.value || ""}${text}`);
return { selector: info.selector, value: direct };
}
if (clearFirst) {
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
await new Promise((resolve) => setTimeout(resolve, 30));
}
await this.cdp("Input.insertText", { text });
await new Promise((resolve) => setTimeout(resolve, 120));
const expected = clearFirst ? text : `${info.value || ""}${text}`;
const actual = await this.withElement<string>(selector, `return String(el.value ?? el.textContent ?? '')`);
if (actual !== expected) {
const fallback = await this.setValueDirect(selector, clearFirst ? text : expected);
if (fallback !== expected) {
throw new RuntimeError("evaluate_error", `输入校验失败:期望「${expected}」,实际「${fallback}」`, { recommendation: "检查输入框状态或重新拾取元素" });
}
return { selector: info.selector, value: fallback };
}
return { selector: info.selector, value: actual };
}
private async setValueDirect(selector: unknown, text: string): Promise<string> {
return this.withElement<string>(selector, `
if (el.isContentEditable) {
el.textContent = ${JSON.stringify(text)};
} else {
const prototype = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
descriptor?.set?.call(el, ${JSON.stringify(text)});
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return String(el.value ?? el.textContent ?? '');
`);
}
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, 0);
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 download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((resolve, reject) => {
let settled = false;
const browserSession = this.win.webContents.session;
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
const target = path.join(targetFolder, filename || item.getFilename());
item.setSavePath(target);
item.once("done", (_doneEvent, state) => {
if (state === "completed") finish(undefined, target);
else finish(new RuntimeError("browser_error", `下载未完成:${state}`));
});
};
const finish = (error?: Error, filePath?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
browserSession.removeListener("will-download", onDownload);
error ? reject(error) : resolve(filePath || "");
};
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
browserSession.once("will-download", onDownload);
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
});
}
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 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 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;
}
maximize(): void {
if (!this.win.isDestroyed()) this.win.maximize();
}
minimize(): void {
if (!this.win.isDestroyed()) this.win.minimize();
}
stopLoading(): void {
if (!this.win.isDestroyed()) this.win.webContents.stop();
}
async uploadFileToInput(selector: string, filePath: string): Promise<number> {
const doc = await this.cdp<{ root?: { nodeId?: number } }>("DOM.getDocument");
const rootNodeId = doc.root?.nodeId;
if (!rootNodeId) throw new RuntimeError("browser_error", "无法定位页面 DOM 根节点");
const css = selector.replace(/^css:/, "").trim();
const found = await this.cdp<{ nodeId?: number }>("DOM.querySelector", { nodeId: rootNodeId, selector: css });
if (!found.nodeId) {
throw new RuntimeError("element_not_found", "未找到文件输入元素:" + css, { recommendation: "确认 input[type=file] 存在且已渲染" });
}
await this.cdp("DOM.setFileInputFiles", { files: [filePath], nodeId: found.nodeId });
return found.nodeId;
}
startAutoCloseDialogs(): boolean {
if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
this.cdp("Page.enable").catch(() => {});
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown };
if (flags.__rpaAutoCloseDialogs) return false;
flags.__rpaAutoCloseDialogs = true;
const listener = (_event: unknown, method: string) => {
if (method === "Page.javascriptDialogOpening") {
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
}
};
flags.__rpaAutoCloseDialogListener = listener;
this.win.webContents.debugger.on("message", listener);
return true;
}
stopAutoCloseDialogs(): boolean {
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown };
if (!flags.__rpaAutoCloseDialogs) return false;
if (flags.__rpaAutoCloseDialogListener) {
this.win.webContents.debugger.removeListener("message", flags.__rpaAutoCloseDialogListener as never);
}
flags.__rpaAutoCloseDialogs = false;
flags.__rpaAutoCloseDialogListener = undefined;
return true;
}
startNetworkListening(): boolean {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> };
if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
this.cdp("Network.enable").catch(() => {});
if (flags.__rpaNetListening) return false;
flags.__rpaNetListening = true;
flags.__rpaNetCaptures = [];
const listener = (_event: unknown, method: string, params: Record<string, unknown>) => {
if (method !== "Network.responseReceived") return;
const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined;
const request = params.requestId as string | undefined;
if (!response || !request) return;
flags.__rpaNetCaptures?.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" });
if ((flags.__rpaNetCaptures?.length ?? 0) > 2000) flags.__rpaNetCaptures?.shift();
};
flags.__rpaNetListener = listener;
this.win.webContents.debugger.on("message", listener);
return true;
}
async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> };
let captures = flags.__rpaNetCaptures ?? [];
if (options.resourceType) {
const want = options.resourceType.toLowerCase();
captures = captures.filter(c => c.mimeType.toLowerCase().includes(want));
}
if (options.url) {
const keyword = options.url;
captures = options.wildcard
? captures.filter(c => new RegExp("^" + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\ async currentUrl(): Promise<string> {").replace(/\*/g, ".*")).test(c.url))
: captures.filter(c => c.url.includes(keyword));
}
const result: Array<Record<string, unknown>> = [];
for (const c of captures.slice(-200)) {
const item: Record<string, unknown> = { url: c.url, status: c.status, mimeType: c.mimeType };
if (options.body && /json|text|javascript/i.test(c.mimeType)) {
try {
const body = await this.cdp<{ body?: string }>("Network.getResponseBody", { requestId: c.requestId });
item.body = body.body ?? "";
} catch { item.body = ""; }
}
result.push(item);
}
return result;
}
stopNetworkListening(): boolean {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown };
if (!flags.__rpaNetListening) return false;
if (flags.__rpaNetListener) {
this.win.webContents.debugger.removeListener("message", flags.__rpaNetListener as never);
}
flags.__rpaNetListening = false;
flags.__rpaNetListener = undefined;
this.cdp("Network.disable").catch(() => {});
return true;
}
async currentUrl(): Promise<string> {
return this.win.webContents.getURL();
}
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
const url = this.win.webContents.getURL();
if (!url || url === "about:blank") {
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
}
this.win.show();
this.win.focus();
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
const timeout = ${JSON.stringify(timeoutMs)};
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
let host = null;
let root = null;
let box = null;
let badge = null;
let target = null;
let timer = 0;
let finished = false;
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) => {
const rect = node.getBoundingClientRect();
return {
selector: 'css:' + cssPath(node),
tag: node.tagName.toLowerCase(),
id: node.id || undefined,
className: node.getAttribute('class') || undefined,
name: node.getAttribute('name') || undefined,
type: node.getAttribute('type') || undefined,
placeholder: node.getAttribute('placeholder') || undefined,
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
value: String(node.value ?? '') || undefined,
url: location.href
};
};
const updateBox = () => {
if (!target || !box || !badge) return;
const rect = target.getBoundingClientRect();
box.style.left = Math.max(0, rect.left - 3) + 'px';
box.style.top = Math.max(0, rect.top - 3) + 'px';
box.style.width = Math.max(4, rect.width + 6) + 'px';
box.style.height = Math.max(4, rect.height + 6) + 'px';
const tag = target.tagName.toLowerCase();
const name = target.getAttribute('aria-label') || target.getAttribute('placeholder') || target.getAttribute('name') || target.id || '';
badge.textContent = '拾取:' + tag + (name ? ' · ' + name.slice(0, 60) : '');
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 30) + 'px';
};
const firstElement = (event) => {
for (const item of event.composedPath()) if (item instanceof Element) return item;
return event.target instanceof Element ? event.target : null;
};
const move = (event) => {
if (finished) return;
target = firstElement(event);
updateBox();
};
const click = (event) => {
if (finished) return;
const selected = firstElement(event);
if (!selected) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
cleanup();
resolve(describe(selected));
};
const key = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cleanup();
reject(new Error('已取消元素拾取'));
}
};
const cleanup = () => {
if (finished) return;
finished = true;
clearTimeout(timer);
window.removeEventListener('mousemove', move, true);
window.removeEventListener('mousedown', click, true);
window.removeEventListener('click', click, true);
window.removeEventListener('scroll', updateBox, true);
window.removeEventListener('resize', updateBox);
window.removeEventListener('keydown', key, true);
host?.remove();
delete window.__rpaElementPicker;
};
host = document.createElement('div');
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
root = host.attachShadow({ mode: 'closed' });
box = document.createElement('div');
badge = document.createElement('div');
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
root.append(box, badge);
document.documentElement.append(host);
window.addEventListener('mousemove', move, true);
window.addEventListener('mousedown', click, true);
window.addEventListener('click', click, true);
window.addEventListener('scroll', updateBox, true);
window.addEventListener('resize', updateBox);
window.addEventListener('keydown', key, true);
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
timer = setTimeout(() => {
cleanup();
reject(new Error('拾取超时,请重新点击拾取'));
}, timeout);
}))()`);
return result;
}
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 hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
}
export function assertAllowedUrl(url: string): URL {
return assertUrl(url);
}