- 点击可信验证增加两类生效旁证: 页面跳转/新开标签页(淘宝搜索按钮), 点击后元素整体消失(关闭登录弹窗); 此前均被误报为"目标未收到可信点击" 并在节点超时内反复重试, 造成"点击生效却等很久还报错" - 相似元素列表引用增加 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
189 lines
14 KiB
TypeScript
189 lines
14 KiB
TypeScript
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 ?? "") : []);
|
||
}
|