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 全通过
这个提交包含在:
yu 于 2026-10-30 11:56:08 +08:00 提交
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 {