PRA-only-demo/packages/rpa-core/src/capabilities/data-table-common.ts
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

189 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ExcelJS from "exceljs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
type Table = unknown[][];
const BOOK = "__rpaDatabook";
const META = "__rpaDatabookColumns";
function parse(value: unknown): unknown {
if (typeof value !== "string") return value;
try { return JSON.parse(value); } catch { return value; }
}
function table(value: unknown): Table {
const source = parse(value);
if (!Array.isArray(source)) return source == null || source === "" ? [] : [[source]];
return source.map((row) => Array.isArray(row) ? [...row] : [row]);
}
function list(value: unknown): unknown[] {
const source = parse(value);
if (Array.isArray(source)) return source;
if (source == null || source === "") return [];
return String(source).split(/[,,;;\n]/).map((item) => item.trim()).filter(Boolean);
}
function index(headers: unknown[], value: unknown): number {
const source = String(value ?? "A").trim();
const named = headers.findIndex((header) => String(header) === source);
if (named >= 0) return named;
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
if (/^[A-Za-z]+$/.test(source)) {
let result = 0; for (const char of source.toUpperCase()) result = result * 26 + char.charCodeAt(0) - 64;
return Math.max(0, result - 1);
}
return -1;
}
function number(value: unknown): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
function required(value: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(value ?? "").trim(); if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId }); return result;
}
function book(ctx: { vars: Record<string, unknown> }): Table {
const current = table(ctx.vars[BOOK]); ctx.vars[BOOK] = current; return current;
}
function calculate(left: unknown, right: unknown, operator: string): unknown {
if (operator === "+" && (typeof left === "string" || typeof right === "string")) return `${left ?? ""}${right ?? ""}`;
const a = number(left); const b = number(right);
if (operator === "-") return a - b; if (operator === "*") return a * b; if (operator === "/") return b === 0 ? null : a / b; if (operator === "%") return b === 0 ? null : a % b;
return a + b;
}
const readData = (_ctx: unknown, _node: unknown, args: Record<string, unknown>) => {
const source = table(args.df); if (!source.length) return null;
const headers = source[0]; const mode = String(args.readRange ?? "cell").toLowerCase();
const row = Math.max(1, Number(args.row_num ?? args.row_name ?? 1)); const column = index(headers, args.col_name ?? args.col_num ?? "A");
if (mode.includes("row")) return source[row - 1] ?? [];
if (mode.includes("col")) return source.map((item) => item[column]);
if (mode.includes("range")) {
const endRow = Math.max(row, Number(args.appoint_row_num ?? row)); const endColumn = index(headers, args.appoint_col_name ?? args.appoint_col_num ?? column + 1);
return source.slice(row - 1, endRow).map((item) => item.slice(column, endColumn + 1));
}
return source[row - 1]?.[column];
};
register(["Bivartable_ReadDfData", "Bivartable_ReadDfData_V1"], readData);
register(["Bivartable_WriteDfData", "Bivartable_WriteTable"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) source.push([]);
const headers = source[0]; const row = Math.max(1, Number(args.row_num ?? args.row ?? 1)); const column = index(headers, args.col_name ?? args.column ?? "A");
while (source.length < row) source.push([]); while (source[row - 1].length <= column) source[row - 1].push(null);
source[row - 1][column] = parse(args.data);
return source;
});
register("Bivartable_InsertDfData", (_ctx, _node, args) => {
const source = table(args.df); const direction = String(args.writeRange ?? "row").toLowerCase(); const data = list(args.data);
if (direction.includes("col")) {
const at = Math.max(0, Number(args.col_num ?? 1) - 1); for (let row = 0; row < Math.max(source.length, data.length); row += 1) { if (!source[row]) source[row] = []; source[row].splice(at, 0, data[row] ?? null); }
} else source.splice(Math.max(0, Number(args.row_num ?? 1) - 1), 0, data);
return source;
});
register("Bivartable_HandleDfMissValue", (_ctx, _node, args) => {
const source = table(args.df); const fill = args.value ?? ""; const remove = String(args.handleType ?? "1") === "2";
if (remove) return source.filter((row, rowIndex) => rowIndex === 0 || !row.some((cell) => cell === null || cell === undefined || cell === ""));
return source.map((row, rowIndex) => rowIndex === 0 ? row : row.map((cell) => cell === null || cell === undefined || cell === "" ? fill : cell));
});
register(["Bivartable_ColumnOperationDf", "Bivartable_DoCal"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const headers = source[0];
const left = index(headers, args.name1 ?? args.cola); const right = index(headers, args.name2 ?? args.colb); const name = String(args.name ?? args.colnew ?? "结果"); const operator = String(args.operator ?? args.cal ?? "+");
const result: Table = [[...headers, name]]; for (const row of source.slice(1)) result.push([...row, calculate(row[left], row[right], operator)]); return result;
});
function columnIndexOf(candidate: unknown): number {
const text = String(candidate ?? "").trim();
if (/^\d+$/.test(text)) return Number(text) - 1;
let result = 0;
for (const char of text.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
return result - 1;
}
register(["Bivartable_SpliceColData", "Bivartable_DoJoint"], (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const headers = source[0];
// catalog 字段:change_type(name/num) + col_name_1/2 或 col_num_1/2;兼容旧键 col1/col2
const byName = String(args.change_type ?? "name") !== "num";
const left = byName ? index(headers, args.col_name_1 ?? args.col1 ?? args.cola) : columnIndexOf(args.col_num_1);
const right = byName ? index(headers, args.col_name_2 ?? args.col2 ?? args.colb) : columnIndexOf(args.col_num_2);
const name = String(args.new_col_name ?? args.name ?? args.colnew ?? "拼接结果"); const separator = String(args.connector ?? args.separator ?? "");
return [[...headers, name], ...source.slice(1).map((row) => [...row, `${row[left] ?? ""}${separator}${row[right] ?? ""}`])];
});
register("Bivartable_ModifyColName", (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source; const at = index(source[0], args.srcname); if (at < 0) return source; source[0][at] = args.newname; return source;
});
register("Bivartable_ModifyDfName", (_ctx, _node, args) => {
const source = table(args.df); const names = list(args.new_col_name_list); if (source.length && names.length) source[0] = names; return source;
});
register("Bivartable_ReadDfIndexColumns", (_ctx, _node, args) => ({ columns: table(args.df)[0] || [], index: table(args.df).slice(1).map((_row, indexValue) => indexValue + 1) }));
register("Bivartable_Dtype", (_ctx, _node, args) => {
const source = table(args.df); if (!source.length) return source;
const columns = (String(args.change_type ?? "num") === "name" ? list(args.col_name) : list(args.col_num)).map((item) => index(source[0], item)).filter((item) => item >= 0);
const dtype = String(args.dtype ?? "str").toLowerCase();
const convert = (current: unknown) => {
if (dtype.includes("int")) return Math.trunc(number(current));
if (dtype.includes("float") || dtype.includes("number")) return number(current);
if (dtype.includes("bool")) return [true, 1, "1", "true", "True", "是"].includes(current as never);
return current == null ? "" : String(current);
};
return [source[0], ...source.slice(1).map((row) => row.map((cell, column) => columns.includes(column) ? convert(cell) : cell))];
});
register("Bivartable_PivotDf", (_ctx, _node, args) => {
const source = table(args.df); if (source.length < 2) return source;
const headers = source[0]; const rowNames = list(args.row_list).map(String); const columnNames = list(args.col_list).map(String);
let operator = parse(args.operator); let valueName = ""; let aggregation = "sum";
if (operator && typeof operator === "object" && !Array.isArray(operator)) {
const entries = Object.entries(operator as Record<string, unknown>); valueName = entries[0]?.[0] || ""; aggregation = String(entries[0]?.[1] || "sum");
} else valueName = String(operator || "");
if (!valueName) valueName = String(headers.find((header) => !rowNames.includes(String(header)) && !columnNames.includes(String(header))) || headers.at(-1));
const rowIndexes = rowNames.map((name) => index(headers, name)); const columnIndexes = columnNames.map((name) => index(headers, name)); const valueIndex = index(headers, valueName);
const columnKeys = [...new Set(source.slice(1).map((row) => columnIndexes.map((column) => String(row[column] ?? "")).join(" / ")))].filter(Boolean);
const groups = new Map<string, { rows: unknown[]; values: Map<string, number[]> }>();
for (const row of source.slice(1)) {
const rowValues = rowIndexes.map((column) => row[column]); const rowKey = JSON.stringify(rowValues); const columnKey = columnIndexes.map((column) => String(row[column] ?? "")).join(" / ");
const group = groups.get(rowKey) || { rows: rowValues, values: new Map() }; const values = group.values.get(columnKey) || []; values.push(number(row[valueIndex])); group.values.set(columnKey, values); groups.set(rowKey, group);
}
const aggregate = (values: number[]) => aggregation.includes("avg") || aggregation.includes("mean") ? values.reduce((sum, item) => sum + item, 0) / Math.max(1, values.length) : aggregation.includes("count") ? values.length : aggregation.includes("max") ? Math.max(...values) : aggregation.includes("min") ? Math.min(...values) : values.reduce((sum, item) => sum + item, 0);
if (!columnIndexes.length) return [[...rowNames, `${valueName}_${aggregation}`], ...[...groups.values()].map((group) => [...group.rows, aggregate([...group.values.values()].flat())])];
return [[...rowNames, ...columnKeys], ...[...groups.values()].map((group) => [...group.rows, ...columnKeys.map((key) => group.values.has(key) ? aggregate(group.values.get(key)!) : args.value ?? "")])];
});
async function exportExcel(source: Table, filePath: string): Promise<string> {
const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("Sheet1"); sheet.addRows(source as ExcelJS.CellValue[][]); await fs.mkdir(path.dirname(filePath), { recursive: true }); await workbook.xlsx.writeFile(filePath); return filePath;
}
register(["Bivartable_DfWriteToFile", "Bivartable_DoSave"], async (ctx, node, args) => {
const filePath = path.resolve(required(args.filePath ?? args.path ?? args.savePath, "Excel路径", node.id, node.componentId));
try {
return await exportExcel(table(args.df), filePath);
} catch (error) {
// 目标文件被占用(正被 WPS/Excel 打开等)时,自动另存为带时间戳的文件,不让流程失败
const code = (error as NodeJS.ErrnoException | undefined)?.code;
const message = error instanceof Error ? error.message : String(error);
if (code === "EBUSY" || code === "EPERM" || /EBUSY|resource busy|being used by another/i.test(message)) {
const info = path.parse(filePath);
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "").replace(/^(\d{8})(\d{6})$/, "$1_$2");
const fallback = path.join(info.dir, `${info.name}_${stamp}${info.ext}`);
await exportExcel(table(args.df), fallback);
ctx.log(`目标文件被占用(可能正被打开),已另存为:${fallback}`, "warn", node.id);
return fallback;
}
throw error;
}
});
register("Bivartable_DoOpenExcel", async (_ctx, node, args) => {
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
});
register("Databook_Import", async (ctx, node, args) => { const result = await getHandlerResult("Bivartable_DoOpenExcel", ctx, node, { filePath: args.excel_path, sheetName: args.sheet_name }); ctx.vars[BOOK] = result; return result; });
register("Databook_Export", async (ctx, node, args) => {
const folder = path.resolve(required(args.export_path, "导出目录", node.id, node.componentId)); const name = String(args.export_name || "数据表格.xlsx"); return exportExcel(book(ctx), path.join(folder, path.extname(name) ? name : `${name}.xlsx`));
});
register("Databook_SetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] ||= {}) as Record<string, unknown>; meta[String(args.column_name)] = args.column_description; return meta; });
register("Databook_GetColumnInfo", (ctx, _node, args) => { const meta = (ctx.vars[META] || {}) as Record<string, unknown>; return meta[String(args.column_name)] ?? ""; });
async function getHandlerResult(id: string, ctx: any, node: any, args: Record<string, unknown>): Promise<unknown> {
if (id !== "Bivartable_DoOpenExcel") return null;
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
}