PRA-only-demo/src/main/capabilities/browser.ts
yu caa8e19332 阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export)
- 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起
- 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例
- remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows
- 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名
- 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
2026-11-04 12:36:03 +08:00

182 lines
7.6 KiB
TypeScript

import { register } from "@rpa/core";
import { RuntimeError } from "@rpa/core";
import { browserSession, externalBrowserSession } from "../browser/session";
import type { BrowserLike, RunContext } from "@rpa/core";
async function session(ctx: RunContext): Promise<BrowserLike> {
if (!ctx.browser) ctx.browser = await browserSession();
return ctx.browser;
}
function isTruthyFlag(value: unknown): boolean {
const text = String(value ?? "").trim().toLowerCase();
return text === "1" || text === "true" || text === "yes";
}
/**
* 「打开网页」浏览器选择:
* - type 填 chrome / edge → 拉起本机对应浏览器(带独立 profile + 调试端口)并接管
* - isRemote 填 1/true → 直连 ipAddress:port 上已监听调试端口的实例(不拉起进程)
* - type 缺省或其他值 → 内置运行浏览器(现状行为,兜底)
*/
async function resolveSession(ctx: RunContext, args: Record<string, unknown>): Promise<{ browser: BrowserLike; label: string }> {
if (ctx.browser) return { browser: ctx.browser, label: "" };
const type = String(args.type ?? "").trim().toLowerCase();
const isRemote = isTruthyFlag(args.isRemote);
if (isRemote) {
const host = String(args.ipAddress ?? "127.0.0.1");
const port = Number(args.port ?? 9222);
const browser = await externalBrowserSession({ mode: "remote", host, port });
ctx.browser = browser;
return { browser, label: browser.label };
}
if (type === "chrome" || type === "edge") {
const extraArgs = String(args.arguments ?? "")
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
const browser = await externalBrowserSession({
mode: "launch",
browserType: type === "edge" ? "edge" : "chrome",
port: Number(args.port ?? 9222) || 9222,
extraArgs
});
ctx.browser = browser;
return { browser, label: browser.label };
}
return { browser: await session(ctx), label: "内置运行浏览器" };
}
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);
return result;
}
return { url: await browser.currentUrl(), title: "" };
});
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
const browser = await session(ctx);
const url = String(args.url ?? "");
if (!url) {
throw new RuntimeError("validation_error", "跳转地址为空", {
nodeId: node.id,
componentId: node.componentId,
recommendation: "填写要访问的 URL"
});
}
const result = await browser.navigate(url);
ctx.log(`已跳转 ${result.url}`, "info", node.id);
return result;
});
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;
});
register("WebBrowser_Close", async (ctx) => {
if (ctx.browser) await ctx.browser.close();
ctx.browser = undefined;
return true;
});
register(["Element_Click_V6", "Element_Click_V3", "Element_Click"], async (ctx, node, args) => {
const browser = await session(ctx);
const result = await browser.click(selectorOf(args));
ctx.log(`已点击 ${(result as { selector?: string }).selector || ""}`, "info", node.id);
return result;
});
register(["Element_SetValue_V6", "Element_SetValue_V4", "Element_SetValue"], async (ctx, node, args) => {
const browser = await session(ctx);
const text = String(args.text ?? args.value ?? "");
const result = await browser.type(selectorOf(args), text, true);
ctx.log(`已输入 ${text.length > 40 ? `${text.slice(0, 40)}…` : text}`, "info", node.id);
if (String(args.isEnter ?? "0") === "1" || String(args.isEnter) === "true") {
await browser.runJs("document.activeElement && document.activeElement.blur()");
}
return result;
});
register(["Element_GetText_V5", "Element_GetText"], async (ctx, node, args) => {
const browser = await session(ctx);
const attrType = String(args.attr_type ?? "");
const attrName = String(args.attr_name ?? "");
const attribute = attrType && attrType !== "default" ? attrName || attrType : undefined;
const text = await browser.getText(selectorOf(args), attribute);
ctx.log(`读取到 ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`, "info", node.id);
return text;
});
register(["Element_Exists", "Element_ElementExists"], async (ctx, node, args) => {
const browser = await session(ctx);
const exists = await browser.exists(selectorOf(args));
ctx.log(exists ? "元素存在" : "元素不存在", "info", node.id);
return exists;
});
register(["Element_SelectOperation_v1", "Element_SelectOperation"], async (ctx, node, args) => {
const browser = await session(ctx);
const value = String(args.itemsToSelect ?? args.choose ?? args.value ?? "");
const result = await browser.select(selectorOf(args), value);
ctx.log(`已选择 ${value}`, "info", node.id);
return result;
});
register([
"WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle", "WebBrowser_RunJsInIeBySelector",
"WebBrowser_RunJsInIeBySelector_v1", "WebBrowser_RunJsInIeBySelector_v2", "WebBrowser_RunJsInIeBySelector_v3",
"WebBrowser_RunJsInIeBySelectorGround"
], async (ctx, node, args) => {
const browser = await session(ctx);
const script = String(args.jsCode ?? args.script ?? args.code ?? "");
if (!script) throw new RuntimeError("validation_error", "JS 代码为空", { nodeId: node.id, componentId: node.componentId });
return browser.runJs(script);
});
register(["WebBrowser_SaveAs", "WebBrowser_SaveAs_V1"], async (ctx, node, args) => {
const browser = await session(ctx);
const folder = String(args.path ?? "");
if (!folder) throw new RuntimeError("validation_error", "下载保存目录为空", { nodeId: node.id, componentId: node.componentId });
const file = await browser.download(selectorOf(args), folder, String(args.filename ?? ""), Number(args.timeout ?? 10000));
ctx.log(`文件已下载 ${file}`, "info", node.id);
return file;
});
register("WebBrowser_Screenshot", async (ctx, node, args) => {
const browser = await session(ctx);
const target = String(args.file_path ?? args.image_path ?? `${ctx.evidenceDir}/${node.id}.png`);
const file = await browser.screenshot(target);
ctx.log(`截图已保存 ${file}`, "info", node.id);
return file;
});
// Image_Screenshot 统一在 vision.ts 实现(屏幕/文件来源 + 区域裁剪),此处不再重复注册