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:
parent
fd507e4f62
commit
a968075100
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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 [];" +
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user