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; 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 | 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)?.name); if (last) { const item = last as Record; const props = (item.props as Array>) || []; 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(); 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 { 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 { 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(method: string, params: Record = {}): Promise { return this.transport.send(method, params); } private async evaluate(expression: string): Promise { 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(selector: unknown, operation: string): Promise { const deadline = Date.now() + globalElementWaitMs; for (;;) { try { return await this.withElementOnce(selector, operation); } catch (error) { if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error; await this.wait(120); } } } private async withElementOnce(selector: unknown, operation: string): Promise { 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((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("location.href"); const title = await this.evaluate("document.title"); return { url: currentUrl, title }; } private async wait(milliseconds: number): Promise { await new Promise((resolve) => setTimeout(resolve, milliseconds)); } async locate(selector: unknown, timeoutMs?: number): Promise { 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 { 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(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 { 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 { 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(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(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 { 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(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(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 { return this.withElement(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 { const info = await this.locate(selector); if (attribute) { const value = await this.evaluate(`(() => { 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 { try { await this.locate(selector, 0); return true; } catch { return false; } } async select(selector: unknown, value: string): Promise { const info = await this.locate(selector); const applied = await this.evaluate(`(() => { 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 { return this.evaluate(script); } async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise { 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((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 { 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((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) => { guid = String(params.guid ?? ""); suggestedName = String(params.suggestedFilename ?? ""); }; const onProgress = (params: Record) => { 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 { 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 { return this.withElement(selector, `return el.getAttribute(${JSON.stringify(name)}) ?? '';`); } async setAttribute(selector: unknown, name: string, value: string): Promise { 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 { return this.withElement(selector, "return el.outerHTML;"); } async getChecked(selector: unknown): Promise { return this.withElement(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 { return this.withElement(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 { return this.withElement(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 { return this.withElement(selector, ` el.scrollIntoView({ block: 'center', inline: 'center' }); ${this.regionOperation()} `); } async getRegion(selector: unknown): Promise { return this.withElement(selector, this.regionOperation()); } async hover(selector: unknown): Promise { 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 { 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 { return this.withElement(selector, ` ${this.elementReferenceHelpers()} return describe(el); `); } async getElementInfo(selector: unknown): Promise { return this.withElement(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 { const normalizedLevel = Math.max(1, Math.trunc(level)); const result = await this.withElement(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 { const normalizedIndex = index && index > 0 ? Math.trunc(index) : 0; const result = await this.withElement(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 { const result = await this.withElement(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 { return this.withElement(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 { return this.withElement(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 { return this.withElement(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 { 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 { 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>> { 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> = []; for (const c of captures.slice(-200)) { const item: Record = { 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 { if (this.win) return this.win.webContents.getURL(); return this.evaluate("location.href"); } /** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 target */ async listTabs(): Promise { 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 { 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 { 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 { 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> { 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 { // 拾取脚本注入当前文档,并经 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 { 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([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 | 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 { if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map(); return globalThis.__rpaExternalSessions; } export async function browserSession(): Promise { return BrowserSession.create(); } export async function externalBrowserSession(options: ExternalBrowserOptions): Promise { 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); }