1057 lines
46 KiB
TypeScript
1057 lines
46 KiB
TypeScript
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);
|
||
}
|