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 全通过
This commit is contained in:
yu 2026-10-30 11:56:08 +08:00
parent fd507e4f62
commit a968075100
7 changed files with 309 additions and 54 deletions

View File

@ -40,6 +40,10 @@ export interface ExternalConnection {
sessionKey: string;
/** 人类可读标识,用于日志(如 "Edge (127.0.0.1:9222)") */
label: string;
/** 调试 HTTP 端点根地址(如 http://127.0.0.1:9222),用于 /json/list、/json/new 多标签管理 */
httpBase: string;
/** 初始接管的页面 targetId */
targetId: string;
/** 由本模块拉起的浏览器进程;远程连接时为空 */
child?: ChildProcess;
/** close 时是否结束浏览器进程(本模块拉起的默认关闭,远程连接绝不关闭) */
@ -197,6 +201,8 @@ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Prom
transport,
sessionKey: `remote|${host}|${port}`,
label: `远程浏览器 ${host}:${port} (${version.Browser ?? "Chromium"})`,
httpBase: base,
targetId: String(target.id ?? ""),
killOnClose: false
};
}
@ -268,11 +274,36 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
transport,
sessionKey: `launch|${browserType}|${port}`,
label: `${label} (127.0.0.1:${port})`,
httpBase: base,
targetId: String(target.id ?? ""),
child,
killOnClose: true
};
}
/** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */
export async function listPageTargets(httpBase: string): Promise<Array<{ id: string; title: string; url: string; webSocketDebuggerUrl: string }>> {
const targets = await fetchJson<CdpTargetInfo[]>(`${httpBase}/json/list`, 5000);
return targets
.filter((t) => t.type === "page" && t.webSocketDebuggerUrl)
.map((t) => ({ id: String(t.id ?? ""), title: String(t.title ?? ""), url: String(t.url ?? ""), webSocketDebuggerUrl: String(t.webSocketDebuggerUrl) }))
.filter((t) => !/^chrome:\/\/|^edge:\/\/|^devtools:\/\//.test(t.url) || /^chrome:\/\/newtab|^edge:\/\/newtab/.test(t.url));
}
/** 在外部浏览器上新建标签页并返回其调试端点(PUT /json/new) */
export async function openNewPageTarget(httpBase: string, url = "about:blank"): Promise<{ id: string; webSocketDebuggerUrl: string; url: string }> {
const endpoint = `${httpBase}/json/new?${encodeURIComponent(url)}`;
const target = await fetchJson<{ id?: string; webSocketDebuggerUrl?: string; url?: string }>(endpoint, 5000, "PUT");
if (!target.webSocketDebuggerUrl) {
throw new RuntimeError("browser_error", "新建标签页未返回调试端点", { detail: JSON.stringify(target) });
}
return {
id: String(target.id ?? ""),
webSocketDebuggerUrl: target.webSocketDebuggerUrl,
url: String(target.url ?? url)
};
}
export async function openExternalBrowser(options: ExternalBrowserOptions): Promise<ExternalConnection> {
return options.mode === "remote" ? connectRemoteBrowser(options) : launchExternalBrowser(options);
}

View File

@ -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 {

View File

@ -26,13 +26,25 @@ register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx,
await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
});
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx) => {
const raw = String(await (await session(ctx)).runJs("document.cookie")); const result: Record<string, string> = {}; for (const part of raw.split(";")) { const [name, ...rest] = part.trim().split("="); if (name) result[name] = decodeURIComponent(rest.join("=")); } return result;
/** 全量 Cookie:优先走 CDP Network.getCookies(含 HttpOnly),不支持时退回 document.cookie */
async function getCookies(ctx: RunContext): Promise<Record<string, string>> {
const browser = await session(ctx);
if (browser.getCookies) {
const cookies = await browser.getCookies();
return Object.fromEntries(cookies.map((cookie) => [cookie.name, cookie.value]));
}
const raw = String(await browser.runJs("document.cookie"));
return Object.fromEntries(raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const [name, ...rest] = part.split("="); return [name, decodeURIComponent(rest.join("="))]; }));
}
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx, node) => {
const cookies = await getCookies(ctx);
ctx.log(`已获取 ${Object.keys(cookies).length} 个 Cookie(含 HttpOnly)`, "info", node.id);
return cookies;
});
register("WebBrowser_GetCookie", async (ctx, _node, args) => {
const name = text(args, ["name"]); const cookies = await getCookies(ctx); return cookies[name] ?? "";
});
async function getCookies(ctx: RunContext): Promise<Record<string, string>> { const raw = String(await (await session(ctx)).runJs("document.cookie")); return Object.fromEntries(raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const [name, ...rest] = part.split("="); return [name, decodeURIComponent(rest.join("="))]; })); }
register("WebBrowser_SetCookies", async (ctx, node, args) => {
let cookies: unknown = args.cookies; if (typeof cookies === "string") { try { cookies = JSON.parse(cookies); } catch { throw new RuntimeError("validation_error", "Cookie JSON无效", { nodeId: node.id, componentId: node.componentId }); } }
if (!cookies || typeof cookies !== "object") throw new RuntimeError("validation_error", "Cookie对象为空", { nodeId: node.id, componentId: node.componentId }); const browser = await session(ctx);

View File

@ -50,6 +50,11 @@ async function resolveSession(ctx: RunContext, args: Record<string, unknown>): P
register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
const { browser, label } = await resolveSession(ctx, args);
const url = String(args.url ?? "");
if (url && String(args.openNewTab ?? "0") === "1" && browser.openNewTab) {
const tab = await browser.openNewTab(url);
ctx.log(`已使用${label}在新标签页打开 ${tab.url}`, "info", node.id);
return { url: tab.url, title: tab.title };
}
if (url) {
const result = await browser.navigate(url);
ctx.log(`已使用${label}打开 ${result.url}`, "info", node.id);
@ -83,8 +88,15 @@ register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) =>
return result;
});
register("WebBrowser_Switch", async (ctx, node) => {
await session(ctx);
register("WebBrowser_Switch", async (ctx, node, args) => {
const browser = await session(ctx);
const title = String(args.title ?? "").trim();
const url = String(args.url ?? "").trim();
if (browser.switchTab && (title || url)) {
const tab = await browser.switchTab({ title: title || undefined, url: url || undefined });
ctx.log(`已切换到标签页:${tab.title || tab.url}`, "info", node.id);
return { switched: true, targetId: tab.targetId, title: tab.title, url: tab.url };
}
ctx.log("网页切换已由统一运行浏览器接管", "info", node.id);
return true;
});

View File

@ -1,6 +1,22 @@
import { browserSession } from "../browser/session";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { RuntimeError, type BrowserLike, type BrowserTabInfo, type RunContext } from "../engine/types";
/** 优先使用流程中「打开网页」已建立的浏览器会话(含外部 Edge/Chrome),否则退回内置 */
async function currentSession(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser ?? (await browserSession());
}
/** 标题匹配:优先按正则解释,非法正则退化为字面包含 */
function titleMatcher(title: string): (tab: BrowserTabInfo) => boolean {
let pattern: RegExp;
try {
pattern = new RegExp(title);
} catch {
pattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
}
return (tab) => pattern.test(tab.title) || tab.title.includes(title);
}
function cssValue(selector: unknown): string {
const raw = typeof selector === "object" && selector !== null
@ -21,77 +37,100 @@ function requireUploadPath(args: Record<string, unknown>): string {
}
register(["Element_FileUploader", "WebBrowser_Upload", "WebBrowser_UploadDialog"], async (ctx, node, args) => {
const browser = await browserSession();
const browser = await currentSession(ctx);
const selector = cssValue(args.uiSelector ?? "css:input[type=file]") || "input[type=file]";
const filePath = requireUploadPath(args);
const nodeId = await browser.uploadFileToInput(selector, filePath);
const nodeId = await browser.uploadFileToInput?.(selector, filePath);
ctx.log("已设置上传文件:" + filePath, "info", node.id);
return { uploaded: true, selector, filePath, nodeId };
});
register(["WebBrowser_GetTAB", "WebBrowser_GetTAB_V1", "WebBrowser_GetTAB_V2"], async (ctx, node) => {
const browser = await browserSession();
const url = await browser.currentUrl();
ctx.log("已获取标签页列表(单窗口模式)", "info", node.id);
return [{ index: 0, url, active: true }];
register(["WebBrowser_GetTAB", "WebBrowser_GetTAB_V1", "WebBrowser_GetTAB_V2"], async (ctx, node, args) => {
const browser = await currentSession(ctx);
const tabs = (await browser.listTabs?.()) ?? [{ index: 0, targetId: "", title: "", url: await browser.currentUrl(), active: true }];
const title = String(args.title ?? "").trim();
const url = String(args.url ?? "").trim();
const matchType = String(args.match_type ?? "0"); // 0=按标题 1=按网址
let matched = tabs;
if (title && (matchType !== "1" || !url)) matched = matched.filter(titleMatcher(title));
if (url && (matchType === "1" || !title)) matched = matched.filter((tab) => tab.url.includes(url));
if (matched.length === 0) {
throw new RuntimeError("tab_not_found", "没有匹配的标签页", {
detail: JSON.stringify(tabs.map((tab) => ({ title: tab.title, url: tab.url }))),
recommendation: "用「获取标签页」不加条件查看当前打开的全部页面"
});
}
// 激活第一个匹配的标签页
const isActive = String(args.isActive ?? args.is_active ?? "0") === "1";
if (isActive && browser.switchTab) {
const first = matched[0];
await browser.switchTab(first.targetId ? { targetId: first.targetId } : { index: first.index });
}
ctx.log(`标签页匹配 ${matched.length}/${tabs.length} 个`, "info", node.id);
return matched;
});
register("WebBrowser_Switch_V1", async (ctx, node, args) => {
const browser = await browserSession();
const url = await browser.currentUrl();
ctx.log("运行浏览器为单窗口模式,当前即唯一标签页", "info", node.id);
return { switched: true, index: 0, url, active: String(args.is_active ?? "1") === "1" };
const browser = await currentSession(ctx);
const tabs = (await browser.listTabs?.()) ?? [{ index: 0, targetId: "", title: "", url: await browser.currentUrl(), active: true }];
const active = tabs.find((tab) => tab.active) ?? tabs[0];
ctx.log(
tabs.length <= 1 ? "运行浏览器为单窗口模式,当前即唯一标签页" : `已定位当前标签页(共 ${tabs.length} 个,可用「获取标签页」+匹配条件切换)`,
"info",
node.id
);
return { switched: true, index: active?.index ?? 0, url: active?.url ?? "", active: String(args.is_active ?? "1") === "1" };
});
register("WebBrowser_Maximize", async (ctx, node) => {
const browser = await browserSession();
browser.maximize();
const browser = await currentSession(ctx);
browser.maximize?.();
return true;
});
register("WebBrowser_Minimize", async (ctx, node) => {
const browser = await browserSession();
browser.minimize();
const browser = await currentSession(ctx);
browser.minimize?.();
return true;
});
register("WebBrowser_Stop", async (ctx, node) => {
const browser = await browserSession();
browser.stopLoading();
const browser = await currentSession(ctx);
browser.stopLoading?.();
return true;
});
register(["Element_ElementAarray", "Element_ElementCaptureSimilar"], async (ctx, node, args) => {
const browser = await browserSession();
const browser = await currentSession(ctx);
const selector = args.uiSelector ?? args.selector ?? args.target;
if (!selector) {
throw new RuntimeError("validation_error", "元素选择器为空", { nodeId: node.id, componentId: node.componentId, recommendation: "请拾取或填写相似元素" });
}
const references = await browser.getSimilarElements(selector, {
const references = (await browser.getSimilarElements?.(selector, {
start: Number.isFinite(Number(args.start_index)) ? Math.trunc(Number(args.start_index)) : 1,
end: Number.isFinite(Number(args.end_index)) ? Math.trunc(Number(args.end_index)) : -1,
reverse: String(args.reverse ?? "") === "1" || String(args.reverse ?? "") === "true"
});
})) ?? [];
ctx.log("已获取 " + references.length + " 个相似元素(数组)", "info", node.id);
return references;
});
register("Element_StartAutoClosePopup", async (ctx, node) => {
const browser = await browserSession();
const started = browser.startAutoCloseDialogs();
const browser = await currentSession(ctx);
const started = browser.startAutoCloseDialogs?.();
ctx.log(started ? "弹窗自动关闭已开启" : "弹窗自动关闭已在运行中", "info", node.id);
return { active: true, started };
});
register("Element_StopAutoClosePopup", async (ctx, node) => {
const browser = await browserSession();
const stopped = browser.stopAutoCloseDialogs();
const browser = await currentSession(ctx);
const stopped = browser.stopAutoCloseDialogs?.();
ctx.log(stopped ? "弹窗自动关闭已停止" : "弹窗自动关闭未在运行", "info", node.id);
return { active: false, stopped };
});
register(["Element_GetLineBarChartData", "Element_GetPieChartData"], async (ctx, node, args) => {
const browser = await browserSession();
const browser = await currentSession(ctx);
const selector = String((typeof args.uiSelector === "object" && args.uiSelector !== null ? (args.uiSelector as { value?: string }).value : args.uiSelector) ?? "body").replace(/^css:/, "").trim() || "body";
const script = "(function(){" +
"if (!window.echarts) return [];" +

View File

@ -1,6 +1,21 @@
import { browserSession } from "../browser/session";
import { browserSession, externalBrowserSession } from "../browser/session";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
/**
* 采集/监听组件的浏览器会话解析:
* 1. 流程中「打开网页」已建立的会话优先(内置或外部);
* 2. 否则若节点 browserType 填 chrome/edge → 拉起对应外部浏览器;
* 3. 其余情况退回内置运行浏览器。
*/
async function crawlSession(ctx: RunContext, args: Record<string, unknown> = {}): Promise<BrowserLike> {
if (ctx.browser) return ctx.browser;
const browserType = String(args.browserType ?? "").trim().toLowerCase();
if (browserType === "chrome" || browserType === "edge") {
return externalBrowserSession({ mode: "launch", browserType, port: 9222 });
}
return browserSession();
}
const CRAWL_IDS = [
"WebBrowser_DataCollection", "WebBrowser_DataCollectionV1", "WebBrowser_DataCollectionV2",
@ -33,7 +48,7 @@ function firstOf(args: Record<string, unknown>, keys: string[]): unknown {
}
register(CRAWL_IDS, async (ctx, node, args) => {
const browser = await browserSession();
const browser = await crawlSession(ctx, args);
const rowSelector = firstOf(args, ["selector_page", "page_element", "uiSelector_page", "row_selector", "selectorCode", "rowSelector"]);
if (!rowSelector) {
throw new RuntimeError("validation_error", "行选择器为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写每一行数据的选择器,例如 css:table tbody tr" });
@ -76,26 +91,26 @@ register(CRAWL_IDS, async (ctx, node, args) => {
return rows ?? [];
});
register("WebBrowser_StartWeblistening", async (ctx, node) => {
const browser = await browserSession();
const started = browser.startNetworkListening();
register("WebBrowser_StartWeblistening", async (ctx, node, args) => {
const browser = await crawlSession(ctx, args);
const started = browser.startNetworkListening?.();
ctx.log(started ? "网页监听已开启" : "网页监听已在运行中", "info", node.id);
return { listening: true, started };
});
register("WebBrowser_GetWeblistening", async (ctx, node, args) => {
const browser = await browserSession();
const browser = await crawlSession(ctx, args);
const url = String(args.url ?? "").trim();
const wildcard = String(args.iswildcard ?? "") === "1" || String(args.iswildcard ?? "") === "true";
const resourceType = String(args.web_resource ?? "").trim();
const captures = await browser.getNetworkCaptures({ url, wildcard, resourceType });
const captures = (await browser.getNetworkCaptures?.({ url, wildcard, resourceType })) ?? [];
ctx.log("已获取 " + captures.length + " 条监听记录", "info", node.id);
return captures;
});
register("WebBrowser_StopWeblistening", async (ctx, node) => {
const browser = await browserSession();
const stopped = browser.stopNetworkListening();
register("WebBrowser_StopWeblistening", async (ctx, node, args) => {
const browser = await crawlSession(ctx, args);
const stopped = browser.stopNetworkListening?.();
ctx.log(stopped ? "网页监听已停止" : "网页监听未在运行", "info", node.id);
return { listening: false, stopped };
});

View File

@ -30,9 +30,37 @@ export interface BrowserLike {
getTableData?(selector: unknown): Promise<string[][]>;
getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise<BrowserElementReference[]>;
currentUrl(): Promise<string>;
/** 设置文件上传 input 的本地文件路径 */
uploadFileToInput?(selector: string, filePath: string): Promise<number>;
maximize?(): void;
minimize?(): void;
stopLoading?(): void;
/** 自动关闭页面弹窗(alert/confirm),返回是否新开启 */
startAutoCloseDialogs?(): boolean;
stopAutoCloseDialogs?(): boolean;
/** 开启网络响应监听,返回是否新开启 */
startNetworkListening?(): boolean;
getNetworkCaptures?(options?: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean }): Promise<Array<Record<string, unknown>>>;
stopNetworkListening?(): boolean;
/** 列出当前浏览器的标签页(内置浏览器返回单页;外部浏览器走 Target 枚举) */
listTabs?(): Promise<BrowserTabInfo[]>;
/** 切换当前操作的标签页(按 targetId / 索引 / 标题 / 网址匹配,标题支持正则) */
switchTab?(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise<BrowserTabInfo>;
/** 在浏览器中新建标签页并切换过去(内置浏览器退化为当前页导航) */
openNewTab?(url: string): Promise<BrowserTabInfo>;
/** 全量 Cookie(含 HttpOnly;走 Network.getCookies) */
getCookies?(): Promise<Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }>>;
close(): Promise<void>;
}
export interface BrowserTabInfo {
index: number;
targetId: string;
title: string;
url: string;
active: boolean;
}
export interface BrowserElementReference {
kind: "css" | "xpath";
value: string;