PRA-only-demo/src/main/browser/session.ts
yu 91d998b2ea 元素拾取跟随前台标签页: 点搜索等开新标签页后附着目标自动切换
- pickElement 轮询前经 /json/list 检查前台标签页, 变化时 activateTab 切换附着并重新注册/注入拾取脚本
- 结束时对所有已附着标签页清理拾取残留, 并移除全部 addScriptToEvaluateOnNewDocument 注入
2026-11-05 10:12:59 +08:00

1514 lines
63 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";
import { EmbeddedTransport } from "./transport";
import {
ExternalTransport,
listPageTargets,
openExternalBrowser,
openNewPageTarget,
sessionKeyOf,
type CdpEventHandler,
type CdpTransport,
type ExternalBrowserOptions,
type ExternalConnection
} from "@rpa/core";
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,
type BrowserTabInfo
} from "@rpa/core";
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 | null;
private transport: CdpTransport;
private external: ExternalConnection | null;
private externalKey: string;
/** 外部浏览器调试 HTTP 根地址(多标签管理用),内置为空 */
private httpBase = "";
/** 外部浏览器各标签页的 ws 会话缓存(targetId → transport),this.transport 指向当前激活页 */
private tabTransports = new Map<string, ExternalTransport>();
private activeTargetId = "";
// 实例级监听器状态(自动关弹窗 / 网络监听)——仅对当前激活页生效
private dialogAutoClose = false;
private dialogHandler: CdpEventHandler | null = null;
private netListening = false;
private netHandler: CdpEventHandler | null = null;
private netCaptures: Array<{ requestId: string; url: string; status: number; mimeType: string }> = [];
private constructor(win: BrowserWindow | null, transport: CdpTransport, external: ExternalConnection | null = null) {
this.win = win;
this.transport = transport;
this.external = external;
this.externalKey = external?.sessionKey ?? "";
this.httpBase = external?.httpBase ?? "";
if (external) {
this.activeTargetId = external.targetId;
this.tabTransports.set(external.targetId, external.transport);
}
}
static async create(): Promise<BrowserSession> {
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
return new BrowserSession(globalThis.__rpaBrowserWindow, new EmbeddedTransport(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, new EmbeddedTransport(win));
}
/**
* 创建外部浏览器会话(Edge/Chrome 或远程调试端口连接)。
* 会话按 (mode, browserType/host, port) 缓存复用,同一流程多次「打开网页」不会重复拉起。
*/
static async createExternal(options: ExternalBrowserOptions): Promise<BrowserSession> {
const key = sessionKeyOf(options);
const cache = externalSessionCache();
const existing = cache.get(key);
if (existing && !existing.transport.closed) return existing;
if (existing) cache.delete(key);
const connection = await openExternalBrowser(options);
const session = new BrowserSession(null, connection.transport, connection);
cache.set(key, session);
return session;
}
/** 当前会话是否为外部浏览器 */
/** 会话底层 CDP 是否已断开(缓存清理用) */
get closed(): boolean {
return this.transport.closed;
}
/** 缓存键,外部会话排序用 */
get sessionKey(): string {
return this.externalKey;
}
get isExternal(): boolean {
return this.external !== null;
}
/** 当前会话标识(用于日志,如 "Edge (127.0.0.1:9222)") */
get label(): string {
return this.external?.label ?? "内置运行浏览器";
}
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
return this.transport.send<T>(method, params);
}
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 escapeSelectorValue = (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 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 = escapeSelectorValue(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;
}
if (spec.kind === 'css') {
const segments = String(spec.value).split('>>>').map((item) => item.trim()).filter(Boolean);
let root = document;
for (let index = 0; index < segments.length; index += 1) {
const found = root.querySelector(segments[index]);
if (!found) return null;
if (index === segments.length - 1) return found;
root = found.shadowRoot;
if (!root) return null;
}
return 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) => {
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
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 trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return trees.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");
if (this.win) {
await this.win.loadURL(target.toString());
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
}
// 外部浏览器:Page.navigate + 等待 loadEventFired(与内置 loadURL 等加载完成的语义一致)
const loaded = new Promise<void>((resolve) => {
let done = false;
const onLoad = () => {
if (done) return;
done = true;
clearTimeout(timer);
this.transport.off("Page.loadEventFired", onLoad);
// 留 150ms 余量让 load 事件后的首帧渲染稳定
setTimeout(resolve, 150);
};
const timer = setTimeout(() => {
if (done) return;
done = true;
this.transport.off("Page.loadEventFired", onLoad);
resolve();
}, 30000);
this.transport.on("Page.loadEventFired", onLoad);
});
const result = await this.cdp<{ errorText?: string }>("Page.navigate", { url: target.toString() });
if (result?.errorText) {
throw new RuntimeError("browser_error", `页面导航失败:${result.errorText}`, {
detail: target.toString(),
recommendation: "确认网址可达"
});
}
await loaded;
const currentUrl = await this.evaluate<string>("location.href");
const title = await this.evaluate<string>("document.title");
return { url: currentUrl, title };
}
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);
if (this.win) {
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> {
if (!this.win) return this.downloadExternal(selector, folder, filename, timeoutMs);
const win = this.win;
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((resolve, reject) => {
let settled = false;
const browserSession = 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))));
});
}
/** 外部浏览器下载:Browser.setDownloadBehavior + downloadProgress 事件(guid 命名,完成后重命名为期望文件名) */
private async downloadExternal(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
await this.cdp("Browser.setDownloadBehavior", {
behavior: "allowAndName",
downloadPath: targetFolder,
eventsEnabled: true
});
return new Promise<string>((resolve, reject) => {
let settled = false;
let guid = "";
let suggestedName = "";
const finish = (error?: Error, guidFile?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
this.transport.off("Browser.downloadWillBegin", onBegin);
this.transport.off("Browser.downloadProgress", onProgress);
if (error || !guidFile) return reject(error ?? new RuntimeError("browser_error", "下载未完成"));
const desired = filename || suggestedName;
const desiredPath = desired ? path.join(targetFolder, desired) : guidFile;
fs
.rename(guidFile, desiredPath)
.then(() => resolve(desiredPath))
.catch(() => resolve(guidFile));
};
const onBegin = (params: Record<string, unknown>) => {
guid = String(params.guid ?? "");
suggestedName = String(params.suggestedFilename ?? "");
};
const onProgress = (params: Record<string, unknown>) => {
if (guid && params.guid !== guid) return;
if (params.state === "completed") finish(undefined, path.join(targetFolder, guid));
else if (params.state === "canceled") finish(new RuntimeError("browser_error", "下载被取消"));
};
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
this.transport.on("Browser.downloadWillBegin", onBegin);
this.transport.on("Browser.downloadProgress", onProgress);
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 && !this.win.isDestroyed()) this.win.maximize();
}
minimize(): void {
if (this.win && !this.win.isDestroyed()) this.win.minimize();
}
stopLoading(): void {
if (this.win) {
if (!this.win.isDestroyed()) this.win.webContents.stop();
return;
}
void this.cdp("Page.stopLoading").catch(() => {});
}
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.dialogAutoClose) return false;
this.dialogAutoClose = true;
this.cdp("Page.enable").catch(() => {});
const handler: CdpEventHandler = () => {
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
};
this.dialogHandler = handler;
this.transport.on("Page.javascriptDialogOpening", handler);
return true;
}
stopAutoCloseDialogs(): boolean {
if (!this.dialogAutoClose) return false;
if (this.dialogHandler) {
this.transport.off("Page.javascriptDialogOpening", this.dialogHandler);
}
this.dialogAutoClose = false;
this.dialogHandler = null;
return true;
}
startNetworkListening(): boolean {
if (this.netListening) return false;
this.netListening = true;
this.netCaptures = [];
this.cdp("Network.enable").catch(() => {});
const handler: CdpEventHandler = (params) => {
const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined;
const request = params.requestId as string | undefined;
if (!response || !request) return;
this.netCaptures.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" });
if (this.netCaptures.length > 2000) this.netCaptures.shift();
};
this.netHandler = handler;
this.transport.on("Network.responseReceived", handler);
return true;
}
async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> {
let captures = this.netCaptures;
if (options.resourceType) {
const want = options.resourceType.toLowerCase();
captures = captures.filter(c => c.mimeType.toLowerCase().includes(want));
}
if (options.url) {
const keyword = options.url;
// wildcard 语义是「关键字可出现在 URL 任意位置,* 匹配任意片段」,不加 ^$ 锚定
captures = options.wildcard
? captures.filter(c => new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").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 {
if (!this.netListening) return false;
if (this.netHandler) {
this.transport.off("Network.responseReceived", this.netHandler);
}
this.netListening = false;
this.netHandler = null;
this.cdp("Network.disable").catch(() => {});
return true;
}
async currentUrl(): Promise<string> {
if (this.win) return this.win.webContents.getURL();
return this.evaluate<string>("location.href");
}
/** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 target */
async listTabs(): Promise<BrowserTabInfo[]> {
if (this.win) {
if (this.win.isDestroyed()) return [];
return [{ index: 0, targetId: "", title: this.win.webContents.getTitle(), url: this.win.webContents.getURL(), active: true }];
}
const pages = await listPageTargets(this.httpBase);
return pages.map((page, index) => ({
index,
targetId: page.id,
title: page.title,
url: page.url,
active: page.id === this.activeTargetId
}));
}
/** 切换当前操作的标签页(外部浏览器按 targetId 复用或新建 ws 会话;标题支持正则) */
async switchTab(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo> {
if (this.win) {
const tabs = await this.listTabs();
if (tabs.length === 0) throw new RuntimeError("tab_unavailable", "运行浏览器已关闭");
return tabs[0];
}
const tabs = await this.listTabs();
if (tabs.length === 0) {
throw new RuntimeError("browser_disconnected", "外部浏览器上没有可切换的页面", { recommendation: "重新运行「打开网页」" });
}
let matched: BrowserTabInfo | undefined;
if (match.targetId) {
matched = tabs.find((tab) => tab.targetId === match.targetId);
} else if (match.index !== undefined) {
matched = tabs[match.index] ?? (match.index < 0 ? tabs[tabs.length + match.index] : undefined);
} else if (match.title) {
let pattern: RegExp;
try {
pattern = new RegExp(match.title);
} catch {
pattern = new RegExp(match.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
}
matched = tabs.find((tab) => pattern.test(tab.title));
} else if (match.url) {
matched = tabs.find((tab) => tab.url.includes(match.url as string));
}
if (!matched) {
throw new RuntimeError("tab_not_found", "没有匹配的标签页", {
detail: JSON.stringify(match),
recommendation: "用「获取标签页」查看当前打开的页面标题与网址"
});
}
await this.activateTab(matched.targetId);
return matched;
}
/** 在外部浏览器新建标签页并切换过去;内置浏览器退化为当前页导航 */
async openNewTab(url: string): Promise<BrowserTabInfo> {
if (this.win) {
const result = await this.navigate(url);
return { index: 0, targetId: "", title: result.title, url: result.url, active: true };
}
const target = await openNewPageTarget(this.httpBase, assertUrl(url).toString());
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl, `tab:${target.id}`);
this.tabTransports.set(target.id, transport);
this.activeTargetId = target.id;
this.transport = transport;
const info: BrowserTabInfo = { index: -1, targetId: target.id, title: "", url: target.url, active: true };
try {
const tabs = await this.listTabs();
const found = tabs.find((tab) => tab.targetId === target.id);
if (found) return found;
} catch {
// 枚举失败时返回基本信息
}
return info;
}
/** 连接(或复用)指定页面的 ws 会话并设为当前操作页;监听器状态重置以跟随新页面 */
private async activateTab(targetId: string): Promise<void> {
if (targetId === this.activeTargetId && this.tabTransports.has(targetId) && !this.tabTransports.get(targetId)?.closed) return;
let transport = this.tabTransports.get(targetId);
if (!transport || transport.closed) {
const pages = await listPageTargets(this.httpBase);
const page = pages.find((item) => item.id === targetId);
if (!page) throw new RuntimeError("tab_not_found", "标签页已关闭", { detail: targetId });
transport = await ExternalTransport.connect(page.webSocketDebuggerUrl, `tab:${targetId}`);
this.tabTransports.set(targetId, transport);
}
// 监听器(弹窗/网络)绑定在旧 transport 上,切页后重新建立
const previousHandler = { dialog: this.dialogAutoClose, net: this.netListening };
if (previousHandler.dialog) this.stopAutoCloseDialogs();
if (previousHandler.net) this.stopNetworkListening();
this.transport = transport;
this.activeTargetId = targetId;
this.netCaptures = [];
if (previousHandler.dialog) this.startAutoCloseDialogs();
if (previousHandler.net) this.startNetworkListening();
}
/** 全量 Cookie(含 HttpOnly),内置/外部统一走 Network.getCookies */
async getCookies(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>> {
const result = await this.cdp<{ cookies?: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }> }>(
"Network.getCookies"
);
return result.cookies ?? [];
}
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
// 拾取脚本注入当前文档,并经 Page.addScriptToEvaluateOnNewDocument 随页面跳转自动重注入;
// 主进程轮询 window.__rpaPickResult——用户在拾取期间可随意翻页/搜索/过滑块,停在哪就能拾哪。
const url = await this.currentUrl();
if (!url || url === "about:blank") {
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
}
if (this.win) {
this.win.show();
this.win.focus();
}
const inject = () =>
this.evaluate(`(() => { try { delete window.__rpaPickResult; ${PICKER_BOOTSTRAP_SOURCE} } catch {} })()`);
const injectionIds: string[] = [];
const registerInjection = async () => {
const registered = await this.cdp<{ identifier?: string }>("Page.addScriptToEvaluateOnNewDocument", {
source: PICKER_BOOTSTRAP_SOURCE
}).catch(() => null);
if (registered?.identifier) injectionIds.push(registered.identifier);
};
// 跟随前台标签页:搜索/点按钮常开新标签页,附着目标切过去并重新注入,拾取不中断
const followForegroundTab = async () => {
if (this.win || !this.httpBase) return;
const pages = await listPageTargets(this.httpBase).catch(() => []);
const page = pages[0];
if (!page || page.id === this.activeTargetId) return;
await this.activateTab(page.id);
await this.cdp("Page.enable").catch(() => {});
await registerInjection();
await inject();
};
await this.cdp("Page.enable").catch(() => {});
await registerInjection();
try {
await inject();
const deadline = Date.now() + Math.max(5000, timeoutMs);
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 400));
await followForegroundTab().catch(() => {});
const state = await this.evaluate<{ result: { ok: boolean; value?: ElementPickResult; reason?: string } | null; active: boolean }>(
"({ result: window.__rpaPickResult ?? null, active: window.__rpaPickerActive?.active === true })"
).catch(() => null);
if (!state) continue; // 页面跳转中:等待新文档自动注入
if (state.result) {
if (state.result.ok && state.result.value) return state.result.value;
throw new RuntimeError("cancelled", state.result.reason || "已取消元素拾取");
}
if (!state.active) await inject().catch(() => {}); // 注入未生效时兜底重注入
}
throw new RuntimeError("timeout", "拾取超时,请重新点击拾取");
} finally {
const cleanupExpr =
"(() => { try { window.__rpaElementPicker?.cancelHard?.(); window.__rpaPickerActive = undefined; delete window.__rpaPickResult; } catch {} })()";
await this.evaluate(cleanupExpr).catch(() => {});
for (const tabTransport of this.tabTransports.values()) {
await tabTransport.send("Runtime.evaluate", { expression: cleanupExpr }).catch(() => {});
}
for (const identifier of injectionIds) {
await this.cdp("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
}
}
}
async close(): Promise<void> {
if (this.win) {
if (!this.win.isDestroyed()) this.win.close();
globalThis.__rpaBrowserWindow = null;
return;
}
// 外部浏览器:断开全部标签页 ws 会话;本模块拉起的进程按 killOnClose 决定是否关闭
if (this.externalKey) externalSessionCache().delete(this.externalKey);
const all = new Set<CdpTransport>([this.transport, ...this.tabTransports.values()]);
await Promise.all([...all].map((transport) => transport.close().catch(() => {})));
this.tabTransports.clear();
const child = this.external?.child;
if (this.external?.killOnClose && child && child.exitCode === null) {
try {
child.kill();
} catch {
// 进程可能已自行退出
}
}
this.external = null;
}
}
declare global {
// eslint-disable-next-line no-var
var __rpaBrowserWindow: BrowserWindow | null | undefined;
// eslint-disable-next-line no-var
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
}
const PICKER_BOOTSTRAP_SOURCE = `(() => {
if (window.__rpaPickerActive && window.__rpaPickerActive.active) return;
document.getElementById('__rpa_pick_host') && document.getElementById('__rpa_pick_host').remove();
window.__rpaPickResult = null;
let host = null;
let root = null;
let box = null;
let badge = null;
let target = null;
let locked = false;
let chain = [];
let chainIndex = 0;
let selected = null;
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) => {
const localPath = (start, root) => {
const stable = (item) => {
if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
const testId = item.getAttribute('data-testid');
if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
}
return null;
};
const parts = [];
let current = start;
while (current && current.nodeType === 1) {
const stableCandidate = stable(current);
if (stableCandidate) {
parts.unshift(stableCandidate);
return parts.join(' > ');
}
let part = current.tagName.toLowerCase();
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 trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return trees.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 nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
const updateBox = () => {
const node = locked ? selected : target;
if (!node || !box || !badge) return;
const rect = node.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 = node.tagName.toLowerCase();
const name = nodeName(node);
if (locked) {
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp/PageDown]调层级 [空格]确认 [Esc]取消';
} else {
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [空格]锁定 [Esc]取消 · 可直接翻页后继续拾取';
}
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 34) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
].join(',');
const closestInteractive = (start) => {
let node = start;
while (node && node.nodeType === 1) {
try { if (node.matches(interactiveSelector)) return node; } catch {}
if (node.parentElement) {
node = node.parentElement;
continue;
}
const root = node.getRootNode();
node = root instanceof ShadowRoot ? root.host : null;
}
return start;
};
const firstElement = (event) => {
let deepest = null;
for (const item of event.composedPath()) {
if (item instanceof Element) {
deepest = item;
break;
}
}
if (!deepest && event.target instanceof Element) deepest = event.target;
return closestInteractive(deepest);
};
const buildChain = (node) => {
const result = [];
let current = node;
while (current && current.nodeType === 1) {
result.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return result;
};
const lock = () => {
if (locked || !target) return;
locked = true;
chain = buildChain(target);
chainIndex = Math.max(0, chain.length - 1);
selected = chain[chainIndex] || target;
updateBox();
};
const adjustLevel = (delta) => {
if (!locked || !chain.length) return;
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
if (next === chainIndex) return;
chainIndex = next;
selected = chain[chainIndex];
updateBox();
};
const confirm = () => {
if (!locked || !selected) return;
finish({ ok: true, value: describe(selected) });
};
const move = (event) => {
if (finished || locked) return;
target = firstElement(event);
updateBox();
};
const click = (event) => {
if (finished) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const key = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
finish({ ok: false, reason: '已取消元素拾取' });
return;
}
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) confirm();
else lock();
return;
}
if (locked && event.key === 'PageUp' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(-1);
return;
}
if (locked && event.key === 'PageDown' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(1);
}
};
const cleanup = () => {
if (finished) return;
finished = true;
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 && host.remove();
window.__rpaElementPicker = null;
window.__rpaPickerActive = { active: false };
};
const finish = (payload) => {
cleanup();
window.__rpaPickResult = payload;
};
host = document.createElement('div');
host.id = '__rpa_pick_host';
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: () => { if (!finished) finish({ ok: false, reason: '已取消元素拾取' }); },
cancelHard: () => { cleanup(); }
};
window.__rpaPickerActive = { active: true };
})();`;
function externalSessionCache(): Map<string, BrowserSession> {
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
return globalThis.__rpaExternalSessions;
}
export async function browserSession(): Promise<BrowserSession> {
return BrowserSession.create();
}
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
return BrowserSession.createExternal(options);
}
export function listExternalSessions(): BrowserSession[] {
return [...externalSessionCache().values()]
.filter((session) => session.isExternal && !session.closed)
.sort((left, right) => right.sessionKey.localeCompare(left.sessionKey));
}
export function latestExternalSession(): BrowserSession | null {
return listExternalSessions()[0] || null;
}
export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
}
export function assertAllowedUrl(url: string): URL {
return assertUrl(url);
}