Files
PRA-only-demo/src/main/capabilities/browser.ts
T
yu e25121660d 修复点击验证误报与相似元素列表全重复等运行时问题
- 点击可信验证增加两类生效旁证: 页面跳转/新开标签页(淘宝搜索按钮),
  点击后元素整体消失(关闭登录弹窗); 此前均被误报为"目标未收到可信点击"
  并在节点超时内反复重试, 造成"点击生效却等很久还报错"
- 相似元素列表引用增加 index 序号: 动态id泛化后各卡片选择器相同,
  46 条引用全部命中第一张卡导致抓取结果全重复;
  修复 NodeList 无 indexOf 的 TypeError 被静默吞掉导致序号从未写入
- 元素定位改为两段式: 先在当前附着页查找, 找不到才跟随前台标签页,
  避免多标签环境下被无条件切到 pages[0] 导致找错页面
- 新开标签页优先复用启动残留的 about:blank 空白页, 不再堆积空标签
- AI 能力输入识别增加 hint_word 字段(此前提示词读到的是空)
- try_catch 捕获子步骤失败时记录 warn 日志, 不再静默
- Excel 落盘遇 EBUSY(文件被打开占用)时自动另存为带时间戳的文件
- 新增 similar-list.test.ts 端到端回归(46 卡片动态 id 真实浏览器验证)
- 新增 export-last-run.js: 从运行记录导出比价 Excel
2026-11-05 14:57:33 +08:00

185 lines
7.9 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));
const navigated = Boolean((result as { navigated?: boolean }).navigated);
const dismissed = Boolean((result as { dismissed?: boolean }).dismissed);
const note = navigated ? "(页面已跳转,点击生效)" : dismissed ? "(弹窗已关闭,点击生效)" : "";
ctx.log(`已点击 ${(result as { selector?: string }).selector || ""}${note}`, "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 实现(屏幕/文件来源 + 区域裁剪),此处不再重复注册