From a96807510020a44684f71dbec991d16e3a8176ec Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 11:56:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=96=E6=8E=A5=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=94=AF=E6=8C=81=E9=98=B6=E6=AE=B5=E4=B8=89+?= =?UTF-8?q?=E5=9B=9B=EF=BC=88=E5=A4=9A=E6=A0=87=E7=AD=BE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20+=20=E6=8B=BE=E5=8F=96=E4=B8=8E=E9=AB=98=E7=BA=A7=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E9=80=82=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 全通过 --- src/main/browser/external.ts | 31 +++++ src/main/browser/session.ts | 142 +++++++++++++++++++++-- src/main/capabilities/browser-control.ts | 18 ++- src/main/capabilities/browser.ts | 16 ++- src/main/capabilities/web-batch2.ts | 91 ++++++++++----- src/main/capabilities/web-crawl.ts | 37 ++++-- src/main/engine/types.ts | 28 +++++ 7 files changed, 309 insertions(+), 54 deletions(-) diff --git a/src/main/browser/external.ts b/src/main/browser/external.ts index 8d93f1a..cf892f9 100644 --- a/src/main/browser/external.ts +++ b/src/main/browser/external.ts @@ -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> { + const targets = await fetchJson(`${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 { return options.mode === "remote" ? connectRemoteBrowser(options) : launchExternalBrowser(options); } diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index df6d6ae..eabc071 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -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(); + 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 { @@ -995,18 +1008,121 @@ export class BrowserSession implements BrowserLike { return this.evaluate("location.href"); } - async pickElement(timeoutMs = 90000): Promise { - if (!this.win) { - throw new RuntimeError("not_supported", "元素拾取目前仅支持内置运行浏览器", { - recommendation: "外部浏览器场景请直接填写 css:/text:/xpath: 选择器,或先在内置浏览器中拾取后再切换浏览器类型" + /** 列出标签页:内置浏览器返回当前单页;外部浏览器枚举全部页面 target */ + async listTabs(): Promise { + 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 { + 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 { + 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 { + 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> { + 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 { + // 拾取脚本经 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(`(() => 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([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 { diff --git a/src/main/capabilities/browser-control.ts b/src/main/capabilities/browser-control.ts index ab33ca6..2efc477 100644 --- a/src/main/capabilities/browser-control.ts +++ b/src/main/capabilities/browser-control.ts @@ -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 = {}; 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> { + 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> { 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); diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts index 3bf169c..a210376 100644 --- a/src/main/capabilities/browser.ts +++ b/src/main/capabilities/browser.ts @@ -50,6 +50,11 @@ async function resolveSession(ctx: RunContext, args: Record): 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; }); diff --git a/src/main/capabilities/web-batch2.ts b/src/main/capabilities/web-batch2.ts index be5553b..4bec738 100644 --- a/src/main/capabilities/web-batch2.ts +++ b/src/main/capabilities/web-batch2.ts @@ -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 { + 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 { } 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 [];" + diff --git a/src/main/capabilities/web-crawl.ts b/src/main/capabilities/web-crawl.ts index 344d4c5..18e8894 100644 --- a/src/main/capabilities/web-crawl.ts +++ b/src/main/capabilities/web-crawl.ts @@ -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 = {}): Promise { + 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, 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 }; }); diff --git a/src/main/engine/types.ts b/src/main/engine/types.ts index 09ddff6..7d607df 100644 --- a/src/main/engine/types.ts +++ b/src/main/engine/types.ts @@ -30,9 +30,37 @@ export interface BrowserLike { getTableData?(selector: unknown): Promise; getSimilarElements?(selector: unknown, options?: BrowserSimilarElementOptions): Promise; currentUrl(): Promise; + /** 设置文件上传 input 的本地文件路径 */ + uploadFileToInput?(selector: string, filePath: string): Promise; + 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>>; + stopNetworkListening?(): boolean; + /** 列出当前浏览器的标签页(内置浏览器返回单页;外部浏览器走 Target 枚举) */ + listTabs?(): Promise; + /** 切换当前操作的标签页(按 targetId / 索引 / 标题 / 网址匹配,标题支持正则) */ + switchTab?(match: { targetId?: string; index?: number; title?: string; url?: string }): Promise; + /** 在浏览器中新建标签页并切换过去(内置浏览器退化为当前页导航) */ + openNewTab?(url: string): Promise; + /** 全量 Cookie(含 HttpOnly;走 Network.getCookies) */ + getCookies?(): Promise>; close(): Promise; } +export interface BrowserTabInfo { + index: number; + targetId: string; + title: string; + url: string; + active: boolean; +} + export interface BrowserElementReference { kind: "css" | "xpath"; value: string;