feat: 外接浏览器支持阶段三+四(多标签管理 + 拾取与高级组件适配)
- external.ts: listPageTargets 标签枚举(过滤 chrome://edge:// 内部页)、openNewPageTarget(PUT /json/new)、ExternalConnection 增加 httpBase/targetId - session.ts: listTabs/switchTab/openNewTab/getCookies 实现;外部按 targetId 缓存多 ws 会话,切换时迁移弹窗/网络监听;close 关闭全部会话;pickElement 放开外部浏览器(拾取脚本经 Runtime.evaluate 注入,行为与内置一致) - engine/types.ts: BrowserLike 增加 listTabs/switchTab/openNewTab/getCookies 及窗口/监听可选方法 - browser.ts: Create 支持 openNewTab;WebBrowser_Switch 按标题(正则)/网址真实切换 - web-batch2.ts: GetTAB/V1/V2 真实枚举+匹配+激活;全部组件优先使用流程已选浏览器会话 - web-crawl.ts: 采集/监听组件优先流程会话;BatchDataCrawlNew browserType=chrome/edge 拉起外部浏览器 - browser-control.ts: GetCookieNew 升级 Network.getCookies(含 HttpOnly) - 冒烟: Edge 双 Tab(ws 会话独立)、/json/new、页面枚举、Network.getCookies 全通过
这个提交包含在:
1 父节点
fd507e4f62
当前提交
a968075100
7 个文件已更改
+309
-54
没有与搜索条件相匹配的文件
+130
-12
@@ -2,9 +2,11 @@ import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ElementPickResult } from "../../shared/types";
|
||||
import { EmbeddedTransport, type CdpTransport, type CdpEventHandler } from "./transport";
|
||||
import { EmbeddedTransport, ExternalTransport, type CdpTransport, type CdpEventHandler } from "./transport";
|
||||
import {
|
||||
listPageTargets,
|
||||
openExternalBrowser,
|
||||
openNewPageTarget,
|
||||
sessionKeyOf,
|
||||
type ExternalBrowserOptions,
|
||||
type ExternalConnection
|
||||
@@ -26,7 +28,8 @@ import {
|
||||
type BrowserElementRegion,
|
||||
type BrowserLike,
|
||||
type BrowserSelectState,
|
||||
type BrowserSimilarElementOptions
|
||||
type BrowserSimilarElementOptions,
|
||||
type BrowserTabInfo
|
||||
} from "../engine/types";
|
||||
|
||||
function assertUrl(raw: string): URL {
|
||||
@@ -95,7 +98,12 @@ export class BrowserSession implements BrowserLike {
|
||||
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;
|
||||
@@ -107,6 +115,11 @@ export class BrowserSession implements BrowserLike {
|
||||
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> {
|
||||
@@ -995,18 +1008,121 @@ export class BrowserSession implements BrowserLike {
|
||||
return this.evaluate<string>("location.href");
|
||||
}
|
||||
|
||||
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
|
||||
if (!this.win) {
|
||||
throw new RuntimeError("not_supported", "元素拾取目前仅支持内置运行浏览器", {
|
||||
recommendation: "外部浏览器场景请直接填写 css:/text:/xpath: 选择器,或先在内置浏览器中拾取后再切换浏览器类型"
|
||||
/** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 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: "用「获取标签页」查看当前打开的页面标题与网址"
|
||||
});
|
||||
}
|
||||
const url = this.win.webContents.getURL();
|
||||
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> {
|
||||
// 拾取脚本经 Runtime.evaluate 注入并等待用户点击,内置/外部浏览器行为一致
|
||||
const url = await this.currentUrl();
|
||||
if (!url || url === "about:blank") {
|
||||
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
|
||||
}
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
if (this.win) {
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
}
|
||||
const result = await this.evaluate<ElementPickResult>(`(() => new Promise((resolve, reject) => {
|
||||
const timeout = ${JSON.stringify(timeoutMs)};
|
||||
if (window.__rpaElementPicker?.cancel) window.__rpaElementPicker.cancel();
|
||||
@@ -1155,9 +1271,11 @@ export class BrowserSession implements BrowserLike {
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
return;
|
||||
}
|
||||
// 外部浏览器:断开 CDP 连接;本模块拉起的进程按 killOnClose 决定是否关闭
|
||||
// 外部浏览器:断开全部标签页 ws 会话;本模块拉起的进程按 killOnClose 决定是否关闭
|
||||
if (this.externalKey) externalSessionCache().delete(this.externalKey);
|
||||
await this.transport.close().catch(() => {});
|
||||
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 {
|
||||
|
||||
在新工单中引用
屏蔽一个用户