- 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被拒, 环境阻塞)
232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
import { browserSession } from "../browser/session";
|
||
import { register } from "@rpa/core";
|
||
import { RuntimeError, type BrowserElementInfo, type BrowserLike, type RunContext } from "@rpa/core";
|
||
|
||
const GET_ELEMENT_IDS = ["Element_GetElement", "Element_GetElementV1", "Element_GetElementV2", "Element_GetElementNew"];
|
||
const GET_INFO_IDS = ["Element_GetElementInfo", "Element_GetElementInfo_V1"];
|
||
const PARENT_IDS = ["Element_Parent", "Element_Parent_V1", "Element_Parent_V2"];
|
||
const CHILDREN_IDS = ["Element_Children", "Element_ChildrenV1", "Element_Children_V1", "Element_Children_V2"];
|
||
const SIBLING_IDS = ["Element_Sibling", "Element_Sibling_V1", "Element_Sibling_V2"];
|
||
const SIMILAR_IDS = ["Element_SimilarElementList", "Element_SimilarElementList_V1", "Element_SimilarElementList_V2"];
|
||
|
||
export const BROWSER_COMMON_IDS = [
|
||
...GET_ELEMENT_IDS, ...GET_INFO_IDS, ...PARENT_IDS, ...CHILDREN_IDS, ...SIBLING_IDS,
|
||
"Element_GetSelect", "Element_SetSelect", "Element_GetTableData", ...SIMILAR_IDS
|
||
] as const;
|
||
|
||
async function session(ctx: RunContext): Promise<BrowserLike> {
|
||
if (!ctx.browser) ctx.browser = await browserSession();
|
||
return ctx.browser;
|
||
}
|
||
|
||
function enabled(value: unknown): boolean {
|
||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||
}
|
||
|
||
function assertUnifiedBrowser(args: Record<string, unknown>): void {
|
||
if (enabled(args.designated_info)) {
|
||
throw new RuntimeError("validation_error", "当前能力不支持指定其他网页或窗口对象", {
|
||
recommendation: "关闭指定网页/窗口对象,使用当前运行浏览器"
|
||
});
|
||
}
|
||
const browserType = String(args.browser_type ?? "").toLowerCase();
|
||
if (browserType.includes("ie") || browserType.includes("sap") || browserType.includes("ground")) {
|
||
throw new RuntimeError("validation_error", "当前能力仅支持标准浏览器 DOM");
|
||
}
|
||
}
|
||
|
||
function hasSelector(value: unknown): boolean {
|
||
if (typeof value === "string") return value.trim().length > 0;
|
||
return Boolean(value && typeof value === "object" && Object.keys(value as object).length > 0);
|
||
}
|
||
|
||
function selectorOf(args: Record<string, unknown>): unknown {
|
||
const position = String(args.element_position ?? "default").toLowerCase();
|
||
if (position.includes("xpath") && hasSelector(args.xpath)) return `xpath:${String(args.xpath)}`;
|
||
if (position.includes("css") && hasSelector(args.cssSelector)) return `css:${String(args.cssSelector)}`;
|
||
for (const key of ["uiSelector", "default", "selector", "target"]) {
|
||
if (hasSelector(args[key])) return args[key];
|
||
}
|
||
if (hasSelector(args.cssSelector)) return `css:${String(args.cssSelector)}`;
|
||
if (hasSelector(args.xpath)) return `xpath:${String(args.xpath)}`;
|
||
throw new RuntimeError("validation_error", "元素选择器为空", {
|
||
recommendation: "填写标准 CSS/XPath 选择器或使用已有元素引用"
|
||
});
|
||
}
|
||
|
||
function requireMethod<K extends keyof BrowserLike>(browser: BrowserLike, key: K): NonNullable<BrowserLike[K]> {
|
||
const method = browser[key];
|
||
if (typeof method !== "function") throw new RuntimeError("browser_error", `浏览器不支持 DOM 方法:${String(key)}`);
|
||
return method.bind(browser) as NonNullable<BrowserLike[K]>;
|
||
}
|
||
|
||
function positiveInteger(value: unknown, fallback: number): number {
|
||
const parsed = Math.trunc(Number(value));
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function optionalIndex(value: unknown): number | undefined {
|
||
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
||
return positiveInteger(value, 1);
|
||
}
|
||
|
||
function filterOf(args: Record<string, unknown>): string {
|
||
return enabled(args.use) ? String(args.filter ?? "").trim() : "";
|
||
}
|
||
|
||
function listValue(value: unknown): string[] {
|
||
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return [];
|
||
if (text.startsWith("[")) {
|
||
try {
|
||
const parsed = JSON.parse(text) as unknown;
|
||
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
|
||
} catch {
|
||
// Fall back to comma-separated input.
|
||
}
|
||
}
|
||
return text.split(",").map((item) => item.trim()).filter(Boolean);
|
||
}
|
||
|
||
async function completeUrl(browser: BrowserLike, value: string, shouldComplete: boolean): Promise<string> {
|
||
if (!shouldComplete || !value) return value;
|
||
try {
|
||
return new URL(value, await browser.currentUrl()).toString();
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
async function infoResult(browser: BrowserLike, info: BrowserElementInfo, args: Record<string, unknown>): Promise<unknown> {
|
||
const action = String(args.action ?? "gettext").toLowerCase();
|
||
if (["gettext", "text", "1"].includes(action)) return info.text;
|
||
if (["getvalue", "value", "2"].includes(action)) return info.elementValue;
|
||
if (["getposition", "position", "region", "3"].includes(action)) return info.region;
|
||
if (["gethref", "href", "link", "4"].includes(action)) return completeUrl(browser, info.href, enabled(args.attr_complate));
|
||
if (["gethtml", "html", "code", "5"].includes(action)) return info.html;
|
||
if (["getattribute", "attribute", "attr", "6"].includes(action)) {
|
||
const name = String(args.attr_name ?? "").trim();
|
||
if (!name) throw new RuntimeError("validation_error", "属性名为空");
|
||
return completeUrl(browser, info.attributes[name] ?? "", enabled(args.attr_complate));
|
||
}
|
||
if (["all", "info", "0"].includes(action)) return info;
|
||
throw new RuntimeError("validation_error", `不支持的元素信息类型:${action}`);
|
||
}
|
||
|
||
register(GET_ELEMENT_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const result = await requireMethod(await session(ctx), "getElement")(selectorOf(args));
|
||
ctx.log(`已获取元素对象:${result.tag}`, "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register(GET_INFO_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const browser = await session(ctx);
|
||
const info = await requireMethod(browser, "getElementInfo")(selectorOf(args));
|
||
const result = await infoResult(browser, info, args);
|
||
ctx.log("已获取元素信息", "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register(PARENT_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const result = await requireMethod(await session(ctx), "getParent")(
|
||
selectorOf(args), positiveInteger(args.level, 1), filterOf(args)
|
||
);
|
||
ctx.log("已获取父元素", "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register(CHILDREN_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const index = optionalIndex(args.num);
|
||
const result = await requireMethod(await session(ctx), "getChildren")(selectorOf(args), index, filterOf(args));
|
||
ctx.log(`已获取 ${result.length} 个子元素`, "info", node.id);
|
||
return index === undefined ? result : result[0];
|
||
});
|
||
|
||
register(SIBLING_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const position = String(args.position ?? "2").toLowerCase();
|
||
const direction = ["1", "previous", "prev", "up"].includes(position) ? "previous" : "next";
|
||
const result = await requireMethod(await session(ctx), "getSibling")(selectorOf(args), direction, filterOf(args));
|
||
ctx.log(`已获取${direction === "previous" ? "上一个" : "下一个"}兄弟元素`, "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register("Element_GetSelect", async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const state = await requireMethod(await session(ctx), "getSelectOptions")(selectorOf(args));
|
||
const choose = String(args.choose ?? "1").toLowerCase();
|
||
let result: unknown = state.values.length === 1 ? state.values[0] : state.values;
|
||
if (["2", "text", "texts"].includes(choose)) result = state.texts.length === 1 ? state.texts[0] : state.texts;
|
||
if (["3", "all", "options"].includes(choose)) result = state.options;
|
||
ctx.log(`已读取下拉框,共 ${state.options.length} 个选项`, "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register("Element_SetSelect", async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const values = listValue(args.itemsToSelect ?? args.value);
|
||
if (values.length === 0) throw new RuntimeError("validation_error", "下拉框选项为空");
|
||
const choose = String(args.choose ?? "1");
|
||
const mode = choose === "2" ? "add" : choose === "3" ? "remove" : "replace";
|
||
const result = await requireMethod(await session(ctx), "setSelectOptions")(selectorOf(args), values, mode);
|
||
ctx.log(`已设置下拉框:${result.texts.join(", ")}`, "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register("Element_GetTableData", async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
if (enabled(args.sap_mode) || String(args.mode ?? "").toLowerCase().includes("sap")) {
|
||
throw new RuntimeError("validation_error", "当前组件仅支持标准 HTML table,不支持 SAP 表格");
|
||
}
|
||
let rows = await requireMethod(await session(ctx), "getTableData")(selectorOf(args));
|
||
const includeHeader = !["0", "false"].includes(String(args.operation ?? "1").toLowerCase());
|
||
if (!includeHeader) rows = rows.slice(1);
|
||
const range = String(args.tabelRange ?? "0");
|
||
const row = positiveInteger(args.rowNo, 1) - 1;
|
||
const column = positiveInteger(args.columnNo, 1) - 1;
|
||
let result: unknown = rows;
|
||
if (range === "1") result = rows[row] ?? [];
|
||
else if (range === "2") result = rows.map((item) => item[column] ?? "");
|
||
else if (range === "3") result = rows[row]?.[column] ?? "";
|
||
ctx.log(`已读取 HTML 表格 ${rows.length} 行`, "info", node.id);
|
||
return result;
|
||
});
|
||
|
||
register(SIMILAR_IDS, async (ctx, node, args) => {
|
||
assertUnifiedBrowser(args);
|
||
const browser = await session(ctx);
|
||
const parent = enabled(args.relate_parent) ? args.parent_selector : undefined;
|
||
if (enabled(args.relate_parent) && !hasSelector(parent)) {
|
||
throw new RuntimeError("validation_error", "已启用关联父元素,但父元素选择器为空");
|
||
}
|
||
const references = await requireMethod(browser, "getSimilarElements")(selectorOf(args), {
|
||
parent,
|
||
start: positiveInteger(args.start_index, 1),
|
||
end: Number.isFinite(Number(args.end_index)) ? Math.trunc(Number(args.end_index)) : -1,
|
||
reverse: enabled(args.reverse)
|
||
});
|
||
const action = String(args.action ?? "selector").toLowerCase();
|
||
let result: unknown = references;
|
||
if (!["selector", "element", "elements"].includes(action)) {
|
||
const infos = await Promise.all(references.map((reference) => requireMethod(browser, "getElementInfo")(reference)));
|
||
if (["gettext", "text"].includes(action)) result = infos.map((info) => info.text);
|
||
else if (["getvalue", "value"].includes(action)) result = infos.map((info) => info.elementValue);
|
||
else if (["getattribute", "attribute", "attr"].includes(action)) {
|
||
const name = String(args.attr_name ?? "").trim();
|
||
if (!name) throw new RuntimeError("validation_error", "属性名为空");
|
||
result = await Promise.all(
|
||
infos.map((info) => completeUrl(browser, info.attributes[name] ?? "", enabled(args.attr_complate)))
|
||
);
|
||
} else {
|
||
throw new RuntimeError("validation_error", `不支持的相似元素操作:${action}`);
|
||
}
|
||
}
|
||
ctx.log(`已获取 ${references.length} 个相似元素`, "info", node.id);
|
||
return result;
|
||
});
|