- pickElement 轮询前经 /json/list 检查前台标签页, 变化时 activateTab 切换附着并重新注册/注入拾取脚本 - 结束时对所有已附着标签页清理拾取残留, 并移除全部 addScriptToEvaluateOnNewDocument 注入
1514 lines
63 KiB
TypeScript
1514 lines
63 KiB
TypeScript
import { BrowserWindow } from "electron";
|
||
import { promises as fs } from "node:fs";
|
||
import path from "node:path";
|
||
import type { ElementPickResult } from "../../shared/types";
|
||
import { EmbeddedTransport } from "./transport";
|
||
import {
|
||
ExternalTransport,
|
||
listPageTargets,
|
||
openExternalBrowser,
|
||
openNewPageTarget,
|
||
sessionKeyOf,
|
||
type CdpEventHandler,
|
||
type CdpTransport,
|
||
type ExternalBrowserOptions,
|
||
type ExternalConnection
|
||
} from "@rpa/core";
|
||
|
||
const DEFAULT_ELEMENT_WAIT_MS = 5000;
|
||
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
|
||
|
||
export function setBrowserElementWaitMs(milliseconds: number): void {
|
||
const value = Number(milliseconds);
|
||
globalElementWaitMs = Number.isFinite(value)
|
||
? Math.min(600000, Math.max(0, Math.round(value)))
|
||
: DEFAULT_ELEMENT_WAIT_MS;
|
||
}
|
||
import {
|
||
RuntimeError,
|
||
type BrowserElementInfo,
|
||
type BrowserElementReference,
|
||
type BrowserElementRegion,
|
||
type BrowserLike,
|
||
type BrowserSelectState,
|
||
type BrowserSimilarElementOptions,
|
||
type BrowserTabInfo
|
||
} from "@rpa/core";
|
||
|
||
function assertUrl(raw: string): URL {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(raw);
|
||
} catch {
|
||
throw new RuntimeError("blocked_url", `无效的 URL:${raw}`, { recommendation: "填写完整的 http/https 地址" });
|
||
}
|
||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||
throw new RuntimeError("blocked_url", `只允许 http/https,当前为 ${url.protocol}`);
|
||
}
|
||
return url;
|
||
}
|
||
|
||
interface ElementInfo {
|
||
tag: string;
|
||
text: string;
|
||
value: string;
|
||
visible: boolean;
|
||
disabled: boolean;
|
||
rect: { x: number; y: number; width: number; height: number };
|
||
count: number;
|
||
}
|
||
|
||
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string } {
|
||
if (selector && typeof selector === "object") {
|
||
const record = selector as Record<string, unknown>;
|
||
if (record.kind && record.value) {
|
||
return { kind: String(record.kind), value: String(record.value), role: record.role as string, name: record.name as string };
|
||
}
|
||
const common = record.commonProps as Record<string, unknown> | undefined;
|
||
if (common?.cssSelector) {
|
||
return { kind: "css", value: String(common.cssSelector) };
|
||
}
|
||
if (record.selector && Array.isArray(record.selector)) {
|
||
const last = [...record.selector].reverse().find((item) => (item as Record<string, unknown>)?.name);
|
||
if (last) {
|
||
const item = last as Record<string, unknown>;
|
||
const props = (item.props as Array<Record<string, unknown>>) || [];
|
||
const id = props.find((prop) => prop.name === "id")?.textValue;
|
||
const cls = props.find((prop) => prop.name === "class")?.textValue;
|
||
const tag = String(item.name);
|
||
if (id) return { kind: "css", value: `${tag}#${id}` };
|
||
if (cls) return { kind: "css", value: `${tag}.${String(cls).split(/\s+/).join(".")}` };
|
||
return { kind: "css", value: tag };
|
||
}
|
||
}
|
||
}
|
||
const text = String(selector ?? "").trim();
|
||
if (!text) throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "使用 css:#id、text:提交 或 aria:button=提交" });
|
||
if (text.startsWith("css:")) return { kind: "css", value: text.slice(4) };
|
||
if (text.startsWith("xpath:")) return { kind: "xpath", value: text.slice(6) };
|
||
if (text.startsWith("testid:")) return { kind: "css", value: `[data-testid="${text.slice(7).replace(/"/g, '\\"')}"]` };
|
||
if (text.startsWith("text:")) return { kind: "text", value: text.slice(5) };
|
||
if (text.startsWith("aria:")) {
|
||
const rest = text.slice(5);
|
||
const [role, name] = rest.split("=");
|
||
return { kind: "aria", value: role, role, name: name || "" };
|
||
}
|
||
return { kind: "css", value: text };
|
||
}
|
||
|
||
export class BrowserSession implements BrowserLike {
|
||
private win: BrowserWindow | null;
|
||
private transport: CdpTransport;
|
||
private external: ExternalConnection | null;
|
||
private externalKey: string;
|
||
/** 外部浏览器调试 HTTP 根地址(多标签管理用),内置为空 */
|
||
private httpBase = "";
|
||
/** 外部浏览器各标签页的 ws 会话缓存(targetId → transport),this.transport 指向当前激活页 */
|
||
private tabTransports = new Map<string, ExternalTransport>();
|
||
private activeTargetId = "";
|
||
// 实例级监听器状态(自动关弹窗 / 网络监听)——仅对当前激活页生效
|
||
private dialogAutoClose = false;
|
||
private dialogHandler: CdpEventHandler | null = null;
|
||
private netListening = false;
|
||
private netHandler: CdpEventHandler | null = null;
|
||
private netCaptures: Array<{ requestId: string; url: string; status: number; mimeType: string }> = [];
|
||
|
||
private constructor(win: BrowserWindow | null, transport: CdpTransport, external: ExternalConnection | null = null) {
|
||
this.win = win;
|
||
this.transport = transport;
|
||
this.external = external;
|
||
this.externalKey = external?.sessionKey ?? "";
|
||
this.httpBase = external?.httpBase ?? "";
|
||
if (external) {
|
||
this.activeTargetId = external.targetId;
|
||
this.tabTransports.set(external.targetId, external.transport);
|
||
}
|
||
}
|
||
|
||
static async create(): Promise<BrowserSession> {
|
||
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
||
return new BrowserSession(globalThis.__rpaBrowserWindow, new EmbeddedTransport(globalThis.__rpaBrowserWindow));
|
||
}
|
||
const win = new BrowserWindow({
|
||
width: 1200,
|
||
height: 820,
|
||
show: process.env.RPA_E2E !== "1",
|
||
title: "RPA 运行浏览器",
|
||
backgroundColor: "#ffffff",
|
||
webPreferences: {
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: true
|
||
}
|
||
});
|
||
globalThis.__rpaBrowserWindow = win;
|
||
win.on("closed", () => {
|
||
globalThis.__rpaBrowserWindow = null;
|
||
});
|
||
await win.loadURL("about:blank");
|
||
if (!win.webContents.debugger.isAttached()) {
|
||
win.webContents.debugger.attach("1.3");
|
||
}
|
||
return new BrowserSession(win, new EmbeddedTransport(win));
|
||
}
|
||
|
||
/**
|
||
* 创建外部浏览器会话(Edge/Chrome 或远程调试端口连接)。
|
||
* 会话按 (mode, browserType/host, port) 缓存复用,同一流程多次「打开网页」不会重复拉起。
|
||
*/
|
||
static async createExternal(options: ExternalBrowserOptions): Promise<BrowserSession> {
|
||
const key = sessionKeyOf(options);
|
||
const cache = externalSessionCache();
|
||
const existing = cache.get(key);
|
||
if (existing && !existing.transport.closed) return existing;
|
||
if (existing) cache.delete(key);
|
||
const connection = await openExternalBrowser(options);
|
||
const session = new BrowserSession(null, connection.transport, connection);
|
||
cache.set(key, session);
|
||
return session;
|
||
}
|
||
|
||
/** 当前会话是否为外部浏览器 */
|
||
/** 会话底层 CDP 是否已断开(缓存清理用) */
|
||
get closed(): boolean {
|
||
return this.transport.closed;
|
||
}
|
||
|
||
/** 缓存键,外部会话排序用 */
|
||
get sessionKey(): string {
|
||
return this.externalKey;
|
||
}
|
||
|
||
get isExternal(): boolean {
|
||
return this.external !== null;
|
||
}
|
||
|
||
/** 当前会话标识(用于日志,如 "Edge (127.0.0.1:9222)") */
|
||
get label(): string {
|
||
return this.external?.label ?? "内置运行浏览器";
|
||
}
|
||
|
||
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||
return this.transport.send<T>(method, params);
|
||
}
|
||
|
||
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
||
const result = await this.cdp<{ result?: { value?: T }; exceptionDetails?: { exception?: { description?: string }; text?: string } }>(
|
||
"Runtime.evaluate",
|
||
{ expression, returnByValue: true, awaitPromise: true }
|
||
);
|
||
if (result.exceptionDetails) {
|
||
throw new RuntimeError(
|
||
"evaluate_error",
|
||
result.exceptionDetails.exception?.description || result.exceptionDetails.text || "页面脚本执行失败"
|
||
);
|
||
}
|
||
return result.result?.value as T;
|
||
}
|
||
|
||
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
|
||
const deadline = Date.now() + globalElementWaitMs;
|
||
for (;;) {
|
||
try {
|
||
return await this.withElementOnce<T>(selector, operation);
|
||
} catch (error) {
|
||
if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error;
|
||
await this.wait(120);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async withElementOnce<T>(selector: unknown, operation: string): Promise<T> {
|
||
const spec = selectorSpec(selector);
|
||
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
|
||
const spec = ${JSON.stringify(spec)};
|
||
const escapeSelectorValue = (value) => {
|
||
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||
};
|
||
const find = () => {
|
||
if (spec.kind === 'xpath') {
|
||
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||
}
|
||
if (spec.kind === 'text') {
|
||
const target = spec.value;
|
||
const matches = [...document.querySelectorAll('*')].filter((node) => {
|
||
const text = (node.innerText || node.textContent || '').trim();
|
||
return text === target || (text.includes(target) && text.length <= target.length + 20);
|
||
});
|
||
return matches.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length)[0] || null;
|
||
}
|
||
if (spec.kind === 'aria') {
|
||
const role = escapeSelectorValue(spec.role || spec.value);
|
||
const name = spec.name || '';
|
||
return [...document.querySelectorAll('[role="' + role + '"], ' + role)].find((node) => {
|
||
const accessibleName = node.getAttribute('aria-label') || node.innerText || '';
|
||
return !name || accessibleName.includes(name);
|
||
}) || null;
|
||
}
|
||
if (spec.kind === 'css') {
|
||
const segments = String(spec.value).split('>>>').map((item) => item.trim()).filter(Boolean);
|
||
let root = document;
|
||
for (let index = 0; index < segments.length; index += 1) {
|
||
const found = root.querySelector(segments[index]);
|
||
if (!found) return null;
|
||
if (index === segments.length - 1) return found;
|
||
root = found.shadowRoot;
|
||
if (!root) return null;
|
||
}
|
||
return null;
|
||
}
|
||
return document.querySelector(spec.value);
|
||
};
|
||
const el = find();
|
||
if (!el) return { found: false };
|
||
return { found: true, value: (() => { ${operation} })() };
|
||
})()`);
|
||
if (!result.found) {
|
||
throw new RuntimeError("not_found", `Element not found: ${spec.kind}:${spec.value}`);
|
||
}
|
||
return result.value as T;
|
||
}
|
||
|
||
private regionOperation(): string {
|
||
return `
|
||
const rect = el.getBoundingClientRect();
|
||
const x = rect.x;
|
||
const y = rect.y;
|
||
return {
|
||
x, y, width: rect.width, height: rect.height,
|
||
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
|
||
centerX: x + rect.width / 2, centerY: y + rect.height / 2,
|
||
pageX: x + window.scrollX, pageY: y + window.scrollY
|
||
};
|
||
`;
|
||
}
|
||
|
||
private elementReferenceHelpers(): string {
|
||
return `
|
||
const cssEscape = (value) => {
|
||
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||
};
|
||
const isUnique = (candidate) => {
|
||
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
|
||
};
|
||
const cssPath = (node) => {
|
||
const localPath = (start, root) => {
|
||
const stable = (item) => {
|
||
if (item.id) {
|
||
const candidate = '#' + cssEscape(item.id);
|
||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||
}
|
||
const testId = item.getAttribute('data-testid');
|
||
if (testId) {
|
||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||
}
|
||
return null;
|
||
};
|
||
const parts = [];
|
||
let current = start;
|
||
while (current && current.nodeType === 1) {
|
||
const stableCandidate = stable(current);
|
||
if (stableCandidate) {
|
||
parts.unshift(stableCandidate);
|
||
return parts.join(' > ');
|
||
}
|
||
let part = current.tagName.toLowerCase();
|
||
const parent = current.parentElement;
|
||
if (parent) {
|
||
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||
}
|
||
parts.unshift(part);
|
||
current = parent;
|
||
}
|
||
return parts.join(' > ');
|
||
};
|
||
const trees = [];
|
||
let current = node;
|
||
while (current && current.nodeType === 1) {
|
||
const root = current.getRootNode();
|
||
trees.unshift(localPath(current, root));
|
||
current = root instanceof ShadowRoot ? root.host : null;
|
||
}
|
||
return trees.join(' >>> ');
|
||
};
|
||
const describe = (node) => ({
|
||
kind: 'css',
|
||
value: cssPath(node),
|
||
tag: node.tagName.toLowerCase(),
|
||
text: (node.innerText || node.textContent || '').trim().slice(0, 500),
|
||
elementValue: String(node.value ?? '')
|
||
});
|
||
`;
|
||
}
|
||
|
||
async navigate(url: string): Promise<{ url: string; title: string }> {
|
||
const target = assertUrl(url);
|
||
await this.cdp("Page.enable");
|
||
if (this.win) {
|
||
await this.win.loadURL(target.toString());
|
||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
||
}
|
||
// 外部浏览器:Page.navigate + 等待 loadEventFired(与内置 loadURL 等加载完成的语义一致)
|
||
const loaded = new Promise<void>((resolve) => {
|
||
let done = false;
|
||
const onLoad = () => {
|
||
if (done) return;
|
||
done = true;
|
||
clearTimeout(timer);
|
||
this.transport.off("Page.loadEventFired", onLoad);
|
||
// 留 150ms 余量让 load 事件后的首帧渲染稳定
|
||
setTimeout(resolve, 150);
|
||
};
|
||
const timer = setTimeout(() => {
|
||
if (done) return;
|
||
done = true;
|
||
this.transport.off("Page.loadEventFired", onLoad);
|
||
resolve();
|
||
}, 30000);
|
||
this.transport.on("Page.loadEventFired", onLoad);
|
||
});
|
||
const result = await this.cdp<{ errorText?: string }>("Page.navigate", { url: target.toString() });
|
||
if (result?.errorText) {
|
||
throw new RuntimeError("browser_error", `页面导航失败:${result.errorText}`, {
|
||
detail: target.toString(),
|
||
recommendation: "确认网址可达"
|
||
});
|
||
}
|
||
await loaded;
|
||
const currentUrl = await this.evaluate<string>("location.href");
|
||
const title = await this.evaluate<string>("document.title");
|
||
return { url: currentUrl, title };
|
||
}
|
||
|
||
private async wait(milliseconds: number): Promise<void> {
|
||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||
}
|
||
|
||
async locate(selector: unknown, timeoutMs?: number): Promise<ElementInfo & { selector: string }> {
|
||
const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs);
|
||
const deadline = Date.now() + waitMs;
|
||
for (;;) {
|
||
try {
|
||
return await this.locateOnce(selector);
|
||
} catch (error) {
|
||
const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline;
|
||
if (!canRetry) {
|
||
if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) {
|
||
error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`;
|
||
error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集";
|
||
}
|
||
throw error;
|
||
}
|
||
await this.wait(120);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async locateOnce(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||
const spec = selectorSpec(selector);
|
||
const expression = `(() => {
|
||
const spec = ${JSON.stringify(spec)};
|
||
const visible = (nodes) => {
|
||
const result = [];
|
||
for (const el of nodes) {
|
||
const style = getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
||
result.push(el);
|
||
}
|
||
return result;
|
||
};
|
||
let matches = [];
|
||
if (spec.kind === 'text') {
|
||
const target = spec.value;
|
||
const all = [...document.querySelectorAll('*')].filter((node) => {
|
||
const text = (node.innerText || node.textContent || '').trim();
|
||
return text === target || (text.includes(target) && text.length <= target.length + 20);
|
||
});
|
||
matches = visible(all.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length));
|
||
} else if (spec.kind === 'aria') {
|
||
const role = spec.role || spec.value;
|
||
const name = spec.name || '';
|
||
const all = [...document.querySelectorAll('[role="' + role + '"], ' + role)];
|
||
matches = visible(all.filter((node) => {
|
||
const acc = node.getAttribute('aria-label') || node.innerText || '';
|
||
return !name || acc.includes(name);
|
||
}));
|
||
} else {
|
||
matches = visible([...document.querySelectorAll(spec.value)]);
|
||
}
|
||
const el = matches[0] || null;
|
||
if (!el) return null;
|
||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||
const rect = el.getBoundingClientRect();
|
||
return {
|
||
tag: el.tagName.toLowerCase(),
|
||
text: (el.innerText || el.textContent || '').trim().slice(0, 200),
|
||
value: el.value ?? '',
|
||
visible: rect.width > 0 && rect.height > 0,
|
||
disabled: Boolean(el.disabled),
|
||
count: matches.length,
|
||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||
};
|
||
})()`;
|
||
const info = await this.evaluate<ElementInfo | null>(expression);
|
||
if (!info) {
|
||
throw new RuntimeError("not_found", `未找到元素:${spec.kind}:${spec.value}`, {
|
||
recommendation: "检查选择器,或先用拾取器重新采集"
|
||
});
|
||
}
|
||
if (info.count > 1) {
|
||
throw new RuntimeError("ambiguous", `选择器匹配到 ${info.count} 个可见元素:${spec.kind}:${spec.value}`, {
|
||
recommendation: "使用更稳定的 data-testid、aria-label 或唯一 CSS 选择器"
|
||
});
|
||
}
|
||
return { ...info, selector: `${spec.kind}:${spec.value}` };
|
||
}
|
||
|
||
private async mouseClick(x: number, y: number): Promise<void> {
|
||
const point = { x: Math.round(x), y: Math.round(y) };
|
||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, button: "none", buttons: 0 });
|
||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", buttons: 1, clickCount: 1 });
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
|
||
}
|
||
|
||
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
|
||
const info = await this.locate(selector, timeoutMs);
|
||
if (!info.visible) {
|
||
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
||
}
|
||
if (info.disabled) {
|
||
throw new RuntimeError("not_found", `元素已禁用:${info.selector}`);
|
||
}
|
||
const clickable = await this.withElement<boolean>(selector, `
|
||
const rect = el.getBoundingClientRect();
|
||
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||
return Boolean(hit && (hit === el || el.contains(hit) || hit.contains(el)));
|
||
`);
|
||
if (!clickable) throw new RuntimeError("not_found", `目标元素被其他内容遮挡:${info.selector}`, { recommendation: "关闭遮挡弹窗或等待页面稳定" });
|
||
const clickToken = `rpa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await this.withElement(selector, `
|
||
const token = ${JSON.stringify(clickToken)};
|
||
el.addEventListener('click', () => { el.dataset.rpaClickToken = token; }, { once: true });
|
||
return true;
|
||
`);
|
||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||
const clicked = await this.withElement<boolean>(selector, `
|
||
const matched = el.dataset.rpaClickToken === ${JSON.stringify(clickToken)};
|
||
delete el.dataset.rpaClickToken;
|
||
return matched;
|
||
`);
|
||
if (!clicked) throw new RuntimeError("not_found", `目标未收到可信点击:${info.selector}`, { recommendation: "检查元素是否被遮挡或重新拾取元素" });
|
||
return { selector: info.selector, tag: info.tag, text: info.text };
|
||
}
|
||
|
||
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
|
||
const info = await this.locate(selector, timeoutMs);
|
||
if (this.win) {
|
||
this.win.show();
|
||
this.win.focus();
|
||
}
|
||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||
const focused = await this.withElement<boolean>(selector, `
|
||
el.focus({ focusVisible: true });
|
||
return document.activeElement === el || el.contains(document.activeElement);
|
||
`);
|
||
if (!focused) {
|
||
const direct = await this.setValueDirect(selector, clearFirst ? text : `${info.value || ""}${text}`);
|
||
return { selector: info.selector, value: direct };
|
||
}
|
||
if (clearFirst) {
|
||
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
||
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
|
||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||
}
|
||
await this.cdp("Input.insertText", { text });
|
||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||
const expected = clearFirst ? text : `${info.value || ""}${text}`;
|
||
const actual = await this.withElement<string>(selector, `return String(el.value ?? el.textContent ?? '')`);
|
||
if (actual !== expected) {
|
||
const fallback = await this.setValueDirect(selector, clearFirst ? text : expected);
|
||
if (fallback !== expected) {
|
||
throw new RuntimeError("evaluate_error", `输入校验失败:期望「${expected}」,实际「${fallback}」`, { recommendation: "检查输入框状态或重新拾取元素" });
|
||
}
|
||
return { selector: info.selector, value: fallback };
|
||
}
|
||
return { selector: info.selector, value: actual };
|
||
}
|
||
|
||
private async setValueDirect(selector: unknown, text: string): Promise<string> {
|
||
return this.withElement<string>(selector, `
|
||
if (el.isContentEditable) {
|
||
el.textContent = ${JSON.stringify(text)};
|
||
} else {
|
||
const prototype = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||
descriptor?.set?.call(el, ${JSON.stringify(text)});
|
||
}
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return String(el.value ?? el.textContent ?? '');
|
||
`);
|
||
}
|
||
|
||
async getText(selector: unknown, attribute?: string): Promise<string> {
|
||
const info = await this.locate(selector);
|
||
if (attribute) {
|
||
const value = await this.evaluate<string | null>(`(() => {
|
||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||
const el = document.querySelector(spec.value);
|
||
return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
|
||
})()`);
|
||
return value ?? "";
|
||
}
|
||
return info.value || info.text;
|
||
}
|
||
|
||
async exists(selector: unknown): Promise<boolean> {
|
||
try {
|
||
await this.locate(selector, 0);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async select(selector: unknown, value: string): Promise<unknown> {
|
||
const info = await this.locate(selector);
|
||
const applied = await this.evaluate<boolean>(`(() => {
|
||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||
const el = document.querySelector(spec.value);
|
||
if (!el) return false;
|
||
el.focus();
|
||
el.value = ${JSON.stringify(value)};
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return true;
|
||
})()`);
|
||
if (!applied) throw new RuntimeError("not_found", `下拉框未找到:${info.selector}`);
|
||
return { selector: info.selector, value };
|
||
}
|
||
|
||
async runJs(script: string): Promise<unknown> {
|
||
return this.evaluate(script);
|
||
}
|
||
|
||
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
|
||
if (!this.win) return this.downloadExternal(selector, folder, filename, timeoutMs);
|
||
const win = this.win;
|
||
const targetFolder = path.resolve(folder);
|
||
await fs.mkdir(targetFolder, { recursive: true });
|
||
return new Promise<string>((resolve, reject) => {
|
||
let settled = false;
|
||
const browserSession = win.webContents.session;
|
||
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
|
||
const target = path.join(targetFolder, filename || item.getFilename());
|
||
item.setSavePath(target);
|
||
item.once("done", (_doneEvent, state) => {
|
||
if (state === "completed") finish(undefined, target);
|
||
else finish(new RuntimeError("browser_error", `下载未完成:${state}`));
|
||
});
|
||
};
|
||
const finish = (error?: Error, filePath?: string) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
browserSession.removeListener("will-download", onDownload);
|
||
error ? reject(error) : resolve(filePath || "");
|
||
};
|
||
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
|
||
browserSession.once("will-download", onDownload);
|
||
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
|
||
});
|
||
}
|
||
|
||
/** 外部浏览器下载:Browser.setDownloadBehavior + downloadProgress 事件(guid 命名,完成后重命名为期望文件名) */
|
||
private async downloadExternal(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
|
||
const targetFolder = path.resolve(folder);
|
||
await fs.mkdir(targetFolder, { recursive: true });
|
||
await this.cdp("Browser.setDownloadBehavior", {
|
||
behavior: "allowAndName",
|
||
downloadPath: targetFolder,
|
||
eventsEnabled: true
|
||
});
|
||
return new Promise<string>((resolve, reject) => {
|
||
let settled = false;
|
||
let guid = "";
|
||
let suggestedName = "";
|
||
const finish = (error?: Error, guidFile?: string) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
this.transport.off("Browser.downloadWillBegin", onBegin);
|
||
this.transport.off("Browser.downloadProgress", onProgress);
|
||
if (error || !guidFile) return reject(error ?? new RuntimeError("browser_error", "下载未完成"));
|
||
const desired = filename || suggestedName;
|
||
const desiredPath = desired ? path.join(targetFolder, desired) : guidFile;
|
||
fs
|
||
.rename(guidFile, desiredPath)
|
||
.then(() => resolve(desiredPath))
|
||
.catch(() => resolve(guidFile));
|
||
};
|
||
const onBegin = (params: Record<string, unknown>) => {
|
||
guid = String(params.guid ?? "");
|
||
suggestedName = String(params.suggestedFilename ?? "");
|
||
};
|
||
const onProgress = (params: Record<string, unknown>) => {
|
||
if (guid && params.guid !== guid) return;
|
||
if (params.state === "completed") finish(undefined, path.join(targetFolder, guid));
|
||
else if (params.state === "canceled") finish(new RuntimeError("browser_error", "下载被取消"));
|
||
};
|
||
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
|
||
this.transport.on("Browser.downloadWillBegin", onBegin);
|
||
this.transport.on("Browser.downloadProgress", onProgress);
|
||
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
|
||
});
|
||
}
|
||
|
||
async screenshot(filePath: string): Promise<string> {
|
||
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
|
||
const target = path.resolve(filePath);
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
await fs.writeFile(target, Buffer.from(result.data, "base64"));
|
||
return target;
|
||
}
|
||
|
||
async getAttribute(selector: unknown, name: string): Promise<string> {
|
||
return this.withElement<string>(selector, `return el.getAttribute(${JSON.stringify(name)}) ?? '';`);
|
||
}
|
||
|
||
async setAttribute(selector: unknown, name: string, value: string): Promise<unknown> {
|
||
return this.withElement(selector, `
|
||
el.setAttribute(${JSON.stringify(name)}, ${JSON.stringify(value)});
|
||
return { name: ${JSON.stringify(name)}, value: el.getAttribute(${JSON.stringify(name)}) ?? '' };
|
||
`);
|
||
}
|
||
|
||
async getHtml(selector: unknown): Promise<string> {
|
||
return this.withElement<string>(selector, "return el.outerHTML;");
|
||
}
|
||
|
||
async getChecked(selector: unknown): Promise<boolean> {
|
||
return this.withElement<boolean>(selector, `
|
||
if (!('checked' in el)) throw new Error('The target element has no checked state');
|
||
return Boolean(el.checked);
|
||
`);
|
||
}
|
||
|
||
async setChecked(selector: unknown, checked: boolean): Promise<boolean> {
|
||
return this.withElement<boolean>(selector, `
|
||
if (!('checked' in el)) throw new Error('The target element has no checked state');
|
||
const next = ${checked ? "true" : "false"};
|
||
if (Boolean(el.checked) !== next) {
|
||
el.checked = next;
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
return Boolean(el.checked);
|
||
`);
|
||
}
|
||
|
||
async isEnabled(selector: unknown, includeHidden = false): Promise<boolean> {
|
||
return this.withElement<boolean>(selector, `
|
||
const style = getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
const visible = rect.width > 0 && rect.height > 0 && style.display !== 'none' &&
|
||
style.visibility !== 'hidden' && Number(style.opacity) !== 0;
|
||
const enabled = !Boolean(el.disabled) && el.getAttribute('aria-disabled') !== 'true' && style.pointerEvents !== 'none';
|
||
return enabled && (${includeHidden ? "true" : "false"} || visible);
|
||
`);
|
||
}
|
||
|
||
async scrollIntoView(selector: unknown): Promise<BrowserElementRegion> {
|
||
return this.withElement<BrowserElementRegion>(selector, `
|
||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||
${this.regionOperation()}
|
||
`);
|
||
}
|
||
|
||
async getRegion(selector: unknown): Promise<BrowserElementRegion> {
|
||
return this.withElement<BrowserElementRegion>(selector, this.regionOperation());
|
||
}
|
||
|
||
async hover(selector: unknown): Promise<BrowserElementRegion> {
|
||
const region = await this.scrollIntoView(selector);
|
||
await this.cdp("Input.dispatchMouseEvent", {
|
||
type: "mouseMoved", x: Math.round(region.centerX), y: Math.round(region.centerY), button: "none"
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||
return region;
|
||
}
|
||
|
||
async screenshotElement(selector: unknown, filePath: string, captureBeyondViewport = true): Promise<string> {
|
||
const region = await this.scrollIntoView(selector);
|
||
if (region.width <= 0 || region.height <= 0) {
|
||
throw new RuntimeError("not_found", "The target element has no screenshot area");
|
||
}
|
||
await this.cdp("Page.enable");
|
||
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", {
|
||
format: "png",
|
||
captureBeyondViewport,
|
||
clip: { x: region.pageX, y: region.pageY, width: region.width, height: region.height, scale: 1 }
|
||
});
|
||
const target = path.resolve(filePath);
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
await fs.writeFile(target, Buffer.from(result.data, "base64"));
|
||
return target;
|
||
}
|
||
|
||
async getElement(selector: unknown): Promise<BrowserElementReference> {
|
||
return this.withElement<BrowserElementReference>(selector, `
|
||
${this.elementReferenceHelpers()}
|
||
return describe(el);
|
||
`);
|
||
}
|
||
|
||
async getElementInfo(selector: unknown): Promise<BrowserElementInfo> {
|
||
return this.withElement<BrowserElementInfo>(selector, `
|
||
${this.elementReferenceHelpers()}
|
||
const reference = describe(el);
|
||
const rect = el.getBoundingClientRect();
|
||
return {
|
||
...reference,
|
||
html: el.outerHTML,
|
||
href: String(el.href ?? el.getAttribute('href') ?? ''),
|
||
checked: 'checked' in el ? Boolean(el.checked) : null,
|
||
disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
|
||
attributes: Object.fromEntries([...el.attributes].map((item) => [item.name, item.value])),
|
||
region: {
|
||
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
||
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
|
||
centerX: rect.x + rect.width / 2, centerY: rect.y + rect.height / 2,
|
||
pageX: rect.x + window.scrollX, pageY: rect.y + window.scrollY
|
||
}
|
||
};
|
||
`);
|
||
}
|
||
|
||
async getParent(selector: unknown, level = 1, filter = ""): Promise<BrowserElementReference> {
|
||
const normalizedLevel = Math.max(1, Math.trunc(level));
|
||
const result = await this.withElement<BrowserElementReference | null>(selector, `
|
||
${this.elementReferenceHelpers()}
|
||
let target = el;
|
||
for (let index = 0; index < ${normalizedLevel}; index += 1) target = target?.parentElement || null;
|
||
if (!target) return null;
|
||
const filter = ${JSON.stringify(filter.trim())};
|
||
if (filter && !target.matches(filter)) return null;
|
||
return describe(target);
|
||
`);
|
||
if (!result) throw new RuntimeError("not_found", `未找到第 ${normalizedLevel} 层父元素`);
|
||
return result;
|
||
}
|
||
|
||
async getChildren(selector: unknown, index?: number, filter = ""): Promise<BrowserElementReference[]> {
|
||
const normalizedIndex = index && index > 0 ? Math.trunc(index) : 0;
|
||
const result = await this.withElement<BrowserElementReference[]>(selector, `
|
||
${this.elementReferenceHelpers()}
|
||
const filter = ${JSON.stringify(filter.trim())};
|
||
let children = [...el.children];
|
||
if (filter) children = children.filter((item) => item.matches(filter));
|
||
const index = ${normalizedIndex};
|
||
if (index > 0) children = children.slice(index - 1, index);
|
||
return children.map(describe);
|
||
`);
|
||
if (normalizedIndex > 0 && result.length === 0) {
|
||
throw new RuntimeError("not_found", `未找到第 ${normalizedIndex} 个子元素`);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async getSibling(
|
||
selector: unknown,
|
||
direction: "previous" | "next" = "next",
|
||
filter = ""
|
||
): Promise<BrowserElementReference> {
|
||
const result = await this.withElement<BrowserElementReference | null>(selector, `
|
||
${this.elementReferenceHelpers()}
|
||
const filter = ${JSON.stringify(filter.trim())};
|
||
let target = el.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
|
||
while (target && filter && !target.matches(filter)) {
|
||
target = target.${direction === "previous" ? "previousElementSibling" : "nextElementSibling"};
|
||
}
|
||
return target ? describe(target) : null;
|
||
`);
|
||
if (!result) throw new RuntimeError("not_found", `未找到${direction === "previous" ? "上一个" : "下一个"}兄弟元素`);
|
||
return result;
|
||
}
|
||
|
||
async getSelectOptions(selector: unknown): Promise<BrowserSelectState> {
|
||
return this.withElement<BrowserSelectState>(selector, `
|
||
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
|
||
const options = [...el.options].map((item) => ({
|
||
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
|
||
}));
|
||
const selected = options.filter((item) => item.selected);
|
||
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
|
||
`);
|
||
}
|
||
|
||
async setSelectOptions(
|
||
selector: unknown,
|
||
values: string[],
|
||
mode: "replace" | "add" | "remove" = "replace"
|
||
): Promise<BrowserSelectState> {
|
||
return this.withElement<BrowserSelectState>(selector, `
|
||
if (el.tagName.toLowerCase() !== 'select') throw new Error('目标不是标准 HTML select 元素');
|
||
const requested = ${JSON.stringify(values)}.map(String);
|
||
const mode = ${JSON.stringify(mode)};
|
||
const matched = new Set();
|
||
for (const option of [...el.options]) {
|
||
const hit = requested.includes(option.value) || requested.includes(option.text);
|
||
if (hit) matched.add(option.value);
|
||
if (mode === 'replace') option.selected = hit;
|
||
else if (mode === 'add' && hit) option.selected = true;
|
||
else if (mode === 'remove' && hit) option.selected = false;
|
||
}
|
||
if (matched.size === 0) throw new Error('未找到指定下拉选项');
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
const options = [...el.options].map((item) => ({
|
||
text: item.text, value: item.value, selected: item.selected, disabled: item.disabled
|
||
}));
|
||
const selected = options.filter((item) => item.selected);
|
||
return { values: selected.map((item) => item.value), texts: selected.map((item) => item.text), options };
|
||
`);
|
||
}
|
||
|
||
async getTableData(selector: unknown): Promise<string[][]> {
|
||
return this.withElement<string[][]>(selector, `
|
||
const table = el.matches('table') ? el : el.querySelector('table');
|
||
if (!table) throw new Error('目标不是标准 HTML table 元素');
|
||
return [...table.querySelectorAll('tr')].map((row) =>
|
||
[...row.querySelectorAll(':scope > th, :scope > td')].map((cell) =>
|
||
(cell.innerText || cell.textContent || '').trim()
|
||
)
|
||
);
|
||
`);
|
||
}
|
||
|
||
async getSimilarElements(
|
||
selector: unknown,
|
||
options: BrowserSimilarElementOptions = {}
|
||
): Promise<BrowserElementReference[]> {
|
||
const spec = selectorSpec(selector);
|
||
const parentSpec = options.parent === undefined ? null : selectorSpec(options.parent);
|
||
const start = Math.max(1, Math.trunc(options.start ?? 1));
|
||
const end = Math.trunc(options.end ?? -1);
|
||
const result = await this.evaluate<{ parentFound: boolean; items: BrowserElementReference[] }>(`(() => {
|
||
const spec = ${JSON.stringify(spec)};
|
||
const parentSpec = ${JSON.stringify(parentSpec)};
|
||
${this.elementReferenceHelpers()}
|
||
const resolveOne = (targetSpec) => {
|
||
if (!targetSpec) return document;
|
||
if (targetSpec.kind === 'xpath') {
|
||
return document.evaluate(targetSpec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||
}
|
||
if (targetSpec.kind === 'css') return document.querySelector(targetSpec.value);
|
||
return null;
|
||
};
|
||
const root = resolveOne(parentSpec);
|
||
if (!root) return { parentFound: false, items: [] };
|
||
let nodes = [];
|
||
if (spec.kind === 'xpath') {
|
||
const snapshot = document.evaluate(spec.value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
||
for (let index = 0; index < snapshot.snapshotLength; index += 1) {
|
||
const node = snapshot.snapshotItem(index);
|
||
if (node?.nodeType === 1 && (root === document || root.contains(node))) nodes.push(node);
|
||
}
|
||
} else if (spec.kind === 'css') {
|
||
nodes = [...root.querySelectorAll(spec.value)];
|
||
}
|
||
if (${options.reverse ? "true" : "false"}) nodes.reverse();
|
||
const startIndex = ${start} - 1;
|
||
const endValue = ${end};
|
||
const endIndex = endValue < 0 ? nodes.length + endValue + 1 : endValue;
|
||
return { parentFound: true, items: nodes.slice(startIndex, Math.max(startIndex, endIndex)).map(describe) };
|
||
})()`);
|
||
if (!result.parentFound) throw new RuntimeError("not_found", "未找到相似元素的父元素范围");
|
||
return result.items;
|
||
}
|
||
|
||
maximize(): void {
|
||
if (this.win && !this.win.isDestroyed()) this.win.maximize();
|
||
}
|
||
|
||
minimize(): void {
|
||
if (this.win && !this.win.isDestroyed()) this.win.minimize();
|
||
}
|
||
|
||
stopLoading(): void {
|
||
if (this.win) {
|
||
if (!this.win.isDestroyed()) this.win.webContents.stop();
|
||
return;
|
||
}
|
||
void this.cdp("Page.stopLoading").catch(() => {});
|
||
}
|
||
|
||
async uploadFileToInput(selector: string, filePath: string): Promise<number> {
|
||
const doc = await this.cdp<{ root?: { nodeId?: number } }>("DOM.getDocument");
|
||
const rootNodeId = doc.root?.nodeId;
|
||
if (!rootNodeId) throw new RuntimeError("browser_error", "无法定位页面 DOM 根节点");
|
||
const css = selector.replace(/^css:/, "").trim();
|
||
const found = await this.cdp<{ nodeId?: number }>("DOM.querySelector", { nodeId: rootNodeId, selector: css });
|
||
if (!found.nodeId) {
|
||
throw new RuntimeError("element_not_found", "未找到文件输入元素:" + css, { recommendation: "确认 input[type=file] 存在且已渲染" });
|
||
}
|
||
await this.cdp("DOM.setFileInputFiles", { files: [filePath], nodeId: found.nodeId });
|
||
return found.nodeId;
|
||
}
|
||
|
||
startAutoCloseDialogs(): boolean {
|
||
if (this.dialogAutoClose) return false;
|
||
this.dialogAutoClose = true;
|
||
this.cdp("Page.enable").catch(() => {});
|
||
const handler: CdpEventHandler = () => {
|
||
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
|
||
};
|
||
this.dialogHandler = handler;
|
||
this.transport.on("Page.javascriptDialogOpening", handler);
|
||
return true;
|
||
}
|
||
|
||
stopAutoCloseDialogs(): boolean {
|
||
if (!this.dialogAutoClose) return false;
|
||
if (this.dialogHandler) {
|
||
this.transport.off("Page.javascriptDialogOpening", this.dialogHandler);
|
||
}
|
||
this.dialogAutoClose = false;
|
||
this.dialogHandler = null;
|
||
return true;
|
||
}
|
||
|
||
startNetworkListening(): boolean {
|
||
if (this.netListening) return false;
|
||
this.netListening = true;
|
||
this.netCaptures = [];
|
||
this.cdp("Network.enable").catch(() => {});
|
||
const handler: CdpEventHandler = (params) => {
|
||
const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined;
|
||
const request = params.requestId as string | undefined;
|
||
if (!response || !request) return;
|
||
this.netCaptures.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" });
|
||
if (this.netCaptures.length > 2000) this.netCaptures.shift();
|
||
};
|
||
this.netHandler = handler;
|
||
this.transport.on("Network.responseReceived", handler);
|
||
return true;
|
||
}
|
||
|
||
async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> {
|
||
let captures = this.netCaptures;
|
||
if (options.resourceType) {
|
||
const want = options.resourceType.toLowerCase();
|
||
captures = captures.filter(c => c.mimeType.toLowerCase().includes(want));
|
||
}
|
||
if (options.url) {
|
||
const keyword = options.url;
|
||
// wildcard 语义是「关键字可出现在 URL 任意位置,* 匹配任意片段」,不加 ^$ 锚定
|
||
captures = options.wildcard
|
||
? captures.filter(c => new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")).test(c.url))
|
||
: captures.filter(c => c.url.includes(keyword));
|
||
}
|
||
const result: Array<Record<string, unknown>> = [];
|
||
for (const c of captures.slice(-200)) {
|
||
const item: Record<string, unknown> = { url: c.url, status: c.status, mimeType: c.mimeType };
|
||
if (options.body && /json|text|javascript/i.test(c.mimeType)) {
|
||
try {
|
||
const body = await this.cdp<{ body?: string }>("Network.getResponseBody", { requestId: c.requestId });
|
||
item.body = body.body ?? "";
|
||
} catch { item.body = ""; }
|
||
}
|
||
result.push(item);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
stopNetworkListening(): boolean {
|
||
if (!this.netListening) return false;
|
||
if (this.netHandler) {
|
||
this.transport.off("Network.responseReceived", this.netHandler);
|
||
}
|
||
this.netListening = false;
|
||
this.netHandler = null;
|
||
this.cdp("Network.disable").catch(() => {});
|
||
return true;
|
||
}
|
||
|
||
async currentUrl(): Promise<string> {
|
||
if (this.win) return this.win.webContents.getURL();
|
||
return this.evaluate<string>("location.href");
|
||
}
|
||
|
||
/** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 target */
|
||
async listTabs(): Promise<BrowserTabInfo[]> {
|
||
if (this.win) {
|
||
if (this.win.isDestroyed()) return [];
|
||
return [{ index: 0, targetId: "", title: this.win.webContents.getTitle(), url: this.win.webContents.getURL(), active: true }];
|
||
}
|
||
const pages = await listPageTargets(this.httpBase);
|
||
return pages.map((page, index) => ({
|
||
index,
|
||
targetId: page.id,
|
||
title: page.title,
|
||
url: page.url,
|
||
active: page.id === this.activeTargetId
|
||
}));
|
||
}
|
||
|
||
/** 切换当前操作的标签页(外部浏览器按 targetId 复用或新建 ws 会话;标题支持正则) */
|
||
async switchTab(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo> {
|
||
if (this.win) {
|
||
const tabs = await this.listTabs();
|
||
if (tabs.length === 0) throw new RuntimeError("tab_unavailable", "运行浏览器已关闭");
|
||
return tabs[0];
|
||
}
|
||
const tabs = await this.listTabs();
|
||
if (tabs.length === 0) {
|
||
throw new RuntimeError("browser_disconnected", "外部浏览器上没有可切换的页面", { recommendation: "重新运行「打开网页」" });
|
||
}
|
||
let matched: BrowserTabInfo | undefined;
|
||
if (match.targetId) {
|
||
matched = tabs.find((tab) => tab.targetId === match.targetId);
|
||
} else if (match.index !== undefined) {
|
||
matched = tabs[match.index] ?? (match.index < 0 ? tabs[tabs.length + match.index] : undefined);
|
||
} else if (match.title) {
|
||
let pattern: RegExp;
|
||
try {
|
||
pattern = new RegExp(match.title);
|
||
} catch {
|
||
pattern = new RegExp(match.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||
}
|
||
matched = tabs.find((tab) => pattern.test(tab.title));
|
||
} else if (match.url) {
|
||
matched = tabs.find((tab) => tab.url.includes(match.url as string));
|
||
}
|
||
if (!matched) {
|
||
throw new RuntimeError("tab_not_found", "没有匹配的标签页", {
|
||
detail: JSON.stringify(match),
|
||
recommendation: "用「获取标签页」查看当前打开的页面标题与网址"
|
||
});
|
||
}
|
||
await this.activateTab(matched.targetId);
|
||
return matched;
|
||
}
|
||
|
||
/** 在外部浏览器新建标签页并切换过去;内置浏览器退化为当前页导航 */
|
||
async openNewTab(url: string): Promise<BrowserTabInfo> {
|
||
if (this.win) {
|
||
const result = await this.navigate(url);
|
||
return { index: 0, targetId: "", title: result.title, url: result.url, active: true };
|
||
}
|
||
const target = await openNewPageTarget(this.httpBase, assertUrl(url).toString());
|
||
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl, `tab:${target.id}`);
|
||
this.tabTransports.set(target.id, transport);
|
||
this.activeTargetId = target.id;
|
||
this.transport = transport;
|
||
const info: BrowserTabInfo = { index: -1, targetId: target.id, title: "", url: target.url, active: true };
|
||
try {
|
||
const tabs = await this.listTabs();
|
||
const found = tabs.find((tab) => tab.targetId === target.id);
|
||
if (found) return found;
|
||
} catch {
|
||
// 枚举失败时返回基本信息
|
||
}
|
||
return info;
|
||
}
|
||
|
||
/** 连接(或复用)指定页面的 ws 会话并设为当前操作页;监听器状态重置以跟随新页面 */
|
||
private async activateTab(targetId: string): Promise<void> {
|
||
if (targetId === this.activeTargetId && this.tabTransports.has(targetId) && !this.tabTransports.get(targetId)?.closed) return;
|
||
let transport = this.tabTransports.get(targetId);
|
||
if (!transport || transport.closed) {
|
||
const pages = await listPageTargets(this.httpBase);
|
||
const page = pages.find((item) => item.id === targetId);
|
||
if (!page) throw new RuntimeError("tab_not_found", "标签页已关闭", { detail: targetId });
|
||
transport = await ExternalTransport.connect(page.webSocketDebuggerUrl, `tab:${targetId}`);
|
||
this.tabTransports.set(targetId, transport);
|
||
}
|
||
// 监听器(弹窗/网络)绑定在旧 transport 上,切页后重新建立
|
||
const previousHandler = { dialog: this.dialogAutoClose, net: this.netListening };
|
||
if (previousHandler.dialog) this.stopAutoCloseDialogs();
|
||
if (previousHandler.net) this.stopNetworkListening();
|
||
this.transport = transport;
|
||
this.activeTargetId = targetId;
|
||
this.netCaptures = [];
|
||
if (previousHandler.dialog) this.startAutoCloseDialogs();
|
||
if (previousHandler.net) this.startNetworkListening();
|
||
}
|
||
|
||
/** 全量 Cookie(含 HttpOnly),内置/外部统一走 Network.getCookies */
|
||
async getCookies(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>> {
|
||
const result = await this.cdp<{ cookies?: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }> }>(
|
||
"Network.getCookies"
|
||
);
|
||
return result.cookies ?? [];
|
||
}
|
||
|
||
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
|
||
// 拾取脚本注入当前文档,并经 Page.addScriptToEvaluateOnNewDocument 随页面跳转自动重注入;
|
||
// 主进程轮询 window.__rpaPickResult——用户在拾取期间可随意翻页/搜索/过滑块,停在哪就能拾哪。
|
||
const url = await this.currentUrl();
|
||
if (!url || url === "about:blank") {
|
||
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
|
||
}
|
||
if (this.win) {
|
||
this.win.show();
|
||
this.win.focus();
|
||
}
|
||
const inject = () =>
|
||
this.evaluate(`(() => { try { delete window.__rpaPickResult; ${PICKER_BOOTSTRAP_SOURCE} } catch {} })()`);
|
||
const injectionIds: string[] = [];
|
||
const registerInjection = async () => {
|
||
const registered = await this.cdp<{ identifier?: string }>("Page.addScriptToEvaluateOnNewDocument", {
|
||
source: PICKER_BOOTSTRAP_SOURCE
|
||
}).catch(() => null);
|
||
if (registered?.identifier) injectionIds.push(registered.identifier);
|
||
};
|
||
// 跟随前台标签页:搜索/点按钮常开新标签页,附着目标切过去并重新注入,拾取不中断
|
||
const followForegroundTab = async () => {
|
||
if (this.win || !this.httpBase) return;
|
||
const pages = await listPageTargets(this.httpBase).catch(() => []);
|
||
const page = pages[0];
|
||
if (!page || page.id === this.activeTargetId) return;
|
||
await this.activateTab(page.id);
|
||
await this.cdp("Page.enable").catch(() => {});
|
||
await registerInjection();
|
||
await inject();
|
||
};
|
||
await this.cdp("Page.enable").catch(() => {});
|
||
await registerInjection();
|
||
try {
|
||
await inject();
|
||
const deadline = Date.now() + Math.max(5000, timeoutMs);
|
||
while (Date.now() < deadline) {
|
||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||
await followForegroundTab().catch(() => {});
|
||
const state = await this.evaluate<{ result: { ok: boolean; value?: ElementPickResult; reason?: string } | null; active: boolean }>(
|
||
"({ result: window.__rpaPickResult ?? null, active: window.__rpaPickerActive?.active === true })"
|
||
).catch(() => null);
|
||
if (!state) continue; // 页面跳转中:等待新文档自动注入
|
||
if (state.result) {
|
||
if (state.result.ok && state.result.value) return state.result.value;
|
||
throw new RuntimeError("cancelled", state.result.reason || "已取消元素拾取");
|
||
}
|
||
if (!state.active) await inject().catch(() => {}); // 注入未生效时兜底重注入
|
||
}
|
||
throw new RuntimeError("timeout", "拾取超时,请重新点击拾取");
|
||
} finally {
|
||
const cleanupExpr =
|
||
"(() => { try { window.__rpaElementPicker?.cancelHard?.(); window.__rpaPickerActive = undefined; delete window.__rpaPickResult; } catch {} })()";
|
||
await this.evaluate(cleanupExpr).catch(() => {});
|
||
for (const tabTransport of this.tabTransports.values()) {
|
||
await tabTransport.send("Runtime.evaluate", { expression: cleanupExpr }).catch(() => {});
|
||
}
|
||
for (const identifier of injectionIds) {
|
||
await this.cdp("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
async close(): Promise<void> {
|
||
if (this.win) {
|
||
if (!this.win.isDestroyed()) this.win.close();
|
||
globalThis.__rpaBrowserWindow = null;
|
||
return;
|
||
}
|
||
// 外部浏览器:断开全部标签页 ws 会话;本模块拉起的进程按 killOnClose 决定是否关闭
|
||
if (this.externalKey) externalSessionCache().delete(this.externalKey);
|
||
const all = new Set<CdpTransport>([this.transport, ...this.tabTransports.values()]);
|
||
await Promise.all([...all].map((transport) => transport.close().catch(() => {})));
|
||
this.tabTransports.clear();
|
||
const child = this.external?.child;
|
||
if (this.external?.killOnClose && child && child.exitCode === null) {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// 进程可能已自行退出
|
||
}
|
||
}
|
||
this.external = null;
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
// eslint-disable-next-line no-var
|
||
var __rpaBrowserWindow: BrowserWindow | null | undefined;
|
||
// eslint-disable-next-line no-var
|
||
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
|
||
}
|
||
|
||
const PICKER_BOOTSTRAP_SOURCE = `(() => {
|
||
if (window.__rpaPickerActive && window.__rpaPickerActive.active) return;
|
||
document.getElementById('__rpa_pick_host') && document.getElementById('__rpa_pick_host').remove();
|
||
window.__rpaPickResult = null;
|
||
|
||
let host = null;
|
||
let root = null;
|
||
let box = null;
|
||
let badge = null;
|
||
let target = null;
|
||
let locked = false;
|
||
let chain = [];
|
||
let chainIndex = 0;
|
||
let selected = null;
|
||
let finished = false;
|
||
|
||
const cssEscape = (value) => {
|
||
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
|
||
};
|
||
const isUnique = (candidate) => {
|
||
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
|
||
};
|
||
const cssPath = (node) => {
|
||
const localPath = (start, root) => {
|
||
const stable = (item) => {
|
||
if (item.id) {
|
||
const candidate = '#' + cssEscape(item.id);
|
||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||
}
|
||
const testId = item.getAttribute('data-testid');
|
||
if (testId) {
|
||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||
}
|
||
return null;
|
||
};
|
||
const parts = [];
|
||
let current = start;
|
||
while (current && current.nodeType === 1) {
|
||
const stableCandidate = stable(current);
|
||
if (stableCandidate) {
|
||
parts.unshift(stableCandidate);
|
||
return parts.join(' > ');
|
||
}
|
||
let part = current.tagName.toLowerCase();
|
||
const parent = current.parentElement;
|
||
if (parent) {
|
||
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||
}
|
||
parts.unshift(part);
|
||
current = parent;
|
||
}
|
||
return parts.join(' > ');
|
||
};
|
||
const trees = [];
|
||
let current = node;
|
||
while (current && current.nodeType === 1) {
|
||
const root = current.getRootNode();
|
||
trees.unshift(localPath(current, root));
|
||
current = root instanceof ShadowRoot ? root.host : null;
|
||
}
|
||
return trees.join(' >>> ');
|
||
};
|
||
const describe = (node) => {
|
||
const rect = node.getBoundingClientRect();
|
||
return {
|
||
selector: 'css:' + cssPath(node),
|
||
tag: node.tagName.toLowerCase(),
|
||
id: node.id || undefined,
|
||
className: node.getAttribute('class') || undefined,
|
||
name: node.getAttribute('name') || undefined,
|
||
type: node.getAttribute('type') || undefined,
|
||
placeholder: node.getAttribute('placeholder') || undefined,
|
||
label: node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || (node.innerText || '').trim().slice(0, 100) || undefined,
|
||
text: (node.innerText || node.textContent || '').trim().slice(0, 200) || undefined,
|
||
value: String(node.value ?? '') || undefined,
|
||
url: location.href
|
||
};
|
||
};
|
||
const nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
|
||
const updateBox = () => {
|
||
const node = locked ? selected : target;
|
||
if (!node || !box || !badge) return;
|
||
const rect = node.getBoundingClientRect();
|
||
box.style.left = Math.max(0, rect.left - 3) + 'px';
|
||
box.style.top = Math.max(0, rect.top - 3) + 'px';
|
||
box.style.width = Math.max(4, rect.width + 6) + 'px';
|
||
box.style.height = Math.max(4, rect.height + 6) + 'px';
|
||
const tag = node.tagName.toLowerCase();
|
||
const name = nodeName(node);
|
||
if (locked) {
|
||
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp/PageDown]调层级 [空格]确认 [Esc]取消';
|
||
} else {
|
||
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [空格]锁定 [Esc]取消 · 可直接翻页后继续拾取';
|
||
}
|
||
badge.style.left = Math.max(0, rect.left) + 'px';
|
||
badge.style.top = Math.max(0, rect.top - 34) + 'px';
|
||
};
|
||
const interactiveSelector = [
|
||
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
|
||
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
|
||
].join(',');
|
||
const closestInteractive = (start) => {
|
||
let node = start;
|
||
while (node && node.nodeType === 1) {
|
||
try { if (node.matches(interactiveSelector)) return node; } catch {}
|
||
if (node.parentElement) {
|
||
node = node.parentElement;
|
||
continue;
|
||
}
|
||
const root = node.getRootNode();
|
||
node = root instanceof ShadowRoot ? root.host : null;
|
||
}
|
||
return start;
|
||
};
|
||
const firstElement = (event) => {
|
||
let deepest = null;
|
||
for (const item of event.composedPath()) {
|
||
if (item instanceof Element) {
|
||
deepest = item;
|
||
break;
|
||
}
|
||
}
|
||
if (!deepest && event.target instanceof Element) deepest = event.target;
|
||
return closestInteractive(deepest);
|
||
};
|
||
const buildChain = (node) => {
|
||
const result = [];
|
||
let current = node;
|
||
while (current && current.nodeType === 1) {
|
||
result.unshift(current);
|
||
const root = current.getRootNode();
|
||
current = root instanceof ShadowRoot ? root.host : null;
|
||
}
|
||
return result;
|
||
};
|
||
const lock = () => {
|
||
if (locked || !target) return;
|
||
locked = true;
|
||
chain = buildChain(target);
|
||
chainIndex = Math.max(0, chain.length - 1);
|
||
selected = chain[chainIndex] || target;
|
||
updateBox();
|
||
};
|
||
const adjustLevel = (delta) => {
|
||
if (!locked || !chain.length) return;
|
||
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
|
||
if (next === chainIndex) return;
|
||
chainIndex = next;
|
||
selected = chain[chainIndex];
|
||
updateBox();
|
||
};
|
||
const confirm = () => {
|
||
if (!locked || !selected) return;
|
||
finish({ ok: true, value: describe(selected) });
|
||
};
|
||
const move = (event) => {
|
||
if (finished || locked) return;
|
||
target = firstElement(event);
|
||
updateBox();
|
||
};
|
||
const click = (event) => {
|
||
if (finished) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.stopImmediatePropagation();
|
||
};
|
||
const key = (event) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
finish({ ok: false, reason: '已取消元素拾取' });
|
||
return;
|
||
}
|
||
if (event.code === 'Space' && !event.repeat) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (locked) confirm();
|
||
else lock();
|
||
return;
|
||
}
|
||
if (locked && event.key === 'PageUp' && !event.repeat) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
adjustLevel(-1);
|
||
return;
|
||
}
|
||
if (locked && event.key === 'PageDown' && !event.repeat) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
adjustLevel(1);
|
||
}
|
||
};
|
||
const cleanup = () => {
|
||
if (finished) return;
|
||
finished = true;
|
||
window.removeEventListener('mousemove', move, true);
|
||
window.removeEventListener('mousedown', click, true);
|
||
window.removeEventListener('click', click, true);
|
||
window.removeEventListener('scroll', updateBox, true);
|
||
window.removeEventListener('resize', updateBox);
|
||
window.removeEventListener('keydown', key, true);
|
||
host && host.remove();
|
||
window.__rpaElementPicker = null;
|
||
window.__rpaPickerActive = { active: false };
|
||
};
|
||
const finish = (payload) => {
|
||
cleanup();
|
||
window.__rpaPickResult = payload;
|
||
};
|
||
|
||
host = document.createElement('div');
|
||
host.id = '__rpa_pick_host';
|
||
host.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none;';
|
||
root = host.attachShadow({ mode: 'closed' });
|
||
box = document.createElement('div');
|
||
badge = document.createElement('div');
|
||
box.style.cssText = 'position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.16);border-radius:3px;pointer-events:none;';
|
||
badge.style.cssText = 'position:fixed;z-index:2147483647;padding:4px 7px;border-radius:6px;background:#2563eb;color:#fff;font:600 12px/16px system-ui,sans-serif;pointer-events:none;white-space:nowrap;';
|
||
root.append(box, badge);
|
||
document.documentElement.append(host);
|
||
window.addEventListener('mousemove', move, true);
|
||
window.addEventListener('mousedown', click, true);
|
||
window.addEventListener('click', click, true);
|
||
window.addEventListener('scroll', updateBox, true);
|
||
window.addEventListener('resize', updateBox);
|
||
window.addEventListener('keydown', key, true);
|
||
window.__rpaElementPicker = {
|
||
cancel: () => { if (!finished) finish({ ok: false, reason: '已取消元素拾取' }); },
|
||
cancelHard: () => { cleanup(); }
|
||
};
|
||
window.__rpaPickerActive = { active: true };
|
||
})();`;
|
||
|
||
function externalSessionCache(): Map<string, BrowserSession> {
|
||
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
|
||
return globalThis.__rpaExternalSessions;
|
||
}
|
||
|
||
export async function browserSession(): Promise<BrowserSession> {
|
||
return BrowserSession.create();
|
||
}
|
||
|
||
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
|
||
return BrowserSession.createExternal(options);
|
||
}
|
||
|
||
export function listExternalSessions(): BrowserSession[] {
|
||
return [...externalSessionCache().values()]
|
||
.filter((session) => session.isExternal && !session.closed)
|
||
.sort((left, right) => right.sessionKey.localeCompare(left.sessionKey));
|
||
}
|
||
|
||
export function latestExternalSession(): BrowserSession | null {
|
||
return listExternalSessions()[0] || null;
|
||
}
|
||
|
||
export function hasBrowserSession(): boolean {
|
||
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
|
||
}
|
||
|
||
export function assertAllowedUrl(url: string): URL {
|
||
return assertUrl(url);
|
||
}
|