feat: 完善RPA编排与本地能力接入

这个提交包含在:
yu 于 2026-10-27 11:10:38 +08:00 提交
1 父节点 6a2cc24898
当前提交 c03380168d
32 个文件已更改
+2763 -185

没有与搜索条件相匹配的文件

+14 -1
查看文件
@@ -99,13 +99,26 @@ register(["Element_SelectOperation_v1", "Element_SelectOperation"], async (ctx,
return result;
});
register(["WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle"], async (ctx, node, args) => {
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`);
+162
查看文件
@@ -0,0 +1,162 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { describe, expect, it, afterEach } from "vitest";
import { PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import "./desktop";
import "./office";
import "./pdf";
import "./message";
import "./word";
import { findImage } from "./vision";
const tempDirs: string[] = [];
const node = (componentId: string): FlowNode => ({
id: `smoke_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
const component = {} as CatalogComponent;
const context = {} as RunContext;
async function tempDir(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-capability-smoke-"));
tempDirs.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("capability registration smoke", () => {
it("creates, writes, reads, and saves an Excel workbook", async () => {
const directory = await tempDir();
const create = getHandler("Excel_Cs_NewV5");
const write = getHandler("Excel_Cs_WriteCell");
const read = getHandler("Excel_Cs_ReadRangeV2");
const insert = getHandler("Excel_Cs_Insert");
const sheets = getHandler("Excel_GetSheetsName");
expect(create && write && read && insert && sheets).toBeTruthy();
const handle = await create!(context, node("Excel_Cs_NewV5"), { path: directory, name: "smoke.xlsx", override: "1" }, component);
await write!(context, node("Excel_Cs_WriteCell"), { workbook: handle, sheet: "Sheet1", row: "1", column: "A", content: "RPA", save: "1" }, component);
const table = await read!(context, node("Excel_Cs_ReadRangeV2"), { workbook: handle, sheet: "Sheet1", range: "A1:A1" }, component);
await insert!(context, node("Excel_Cs_Insert"), { workbook: handle, name: "结果", save: "1" }, component);
expect(table).toEqual([["RPA"]]);
await expect(sheets!(context, node("Excel_GetSheetsName"), { workbook: handle }, component)).resolves.toEqual(["Sheet1", "结果"]);
await expect(readFile(path.join(directory, "smoke.xlsx"))).resolves.toBeTruthy();
});
it("counts and concatenates PDF files", async () => {
const directory = await tempDir();
const first = await PDFDocument.create();
first.addPage([120, 120]);
const second = await PDFDocument.create();
second.addPage([120, 120]);
const firstPath = path.join(directory, "one.pdf");
const secondPath = path.join(directory, "two.pdf");
await writeFile(firstPath, await first.save());
await writeFile(secondPath, await second.save());
const count = getHandler("PDF_GetPdfPageNum");
const concat = getHandler("PDF_ConcatPdf");
expect(count && concat).toBeTruthy();
await expect(count!(context, node("PDF_GetPdfPageNum"), { fileInPath: firstPath }, component)).resolves.toBe(1);
const output = await concat!(context, node("PDF_ConcatPdf"), { newPdfPath: directory, newPdfName: "merged.pdf", pdfList: JSON.stringify([firstPath, secondPath]) }, component);
expect(output).toBe(path.join(directory, "merged.pdf"));
const merged = await PDFDocument.load(await readFile(output as string));
expect(merged.getPageCount()).toBe(2);
});
it("executes a local desktop command through the registered bridge", async () => {
const execute = getHandler("System_ExecuteCommand");
expect(execute).toBeTruthy();
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
});
it("finds an exact PNG template inside a larger screenshot", async () => {
const directory = await tempDir();
const screen = new PNG({ width: 48, height: 36 });
const template = new PNG({ width: 8, height: 8 });
screen.data.fill(255);
for (let y = 0; y < template.height; y += 1) {
for (let x = 0; x < template.width; x += 1) {
const templateIndex = (y * template.width + x) * 4;
template.data[templateIndex] = 220;
template.data[templateIndex + 1] = 20;
template.data[templateIndex + 2] = 30;
template.data[templateIndex + 3] = 255;
const screenIndex = ((12 + y) * screen.width + 17 + x) * 4;
template.data.copy(screen.data, screenIndex, templateIndex, templateIndex + 4);
}
}
const screenPath = path.join(directory, "screen.png");
const templatePath = path.join(directory, "template.png");
await writeFile(screenPath, PNG.sync.write(screen));
await writeFile(templatePath, PNG.sync.write(template));
const match = await findImage(templatePath, screenPath, 0.02);
expect(match).toMatchObject({ x: 17, y: 12, width: 8, height: 8, score: 1 });
});
it("sends mail to a local SMTP server", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += `${line}\n`;
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
try {
const send = getHandler("Mail_Send");
expect(send).toBeTruthy();
await send!(context, node("Mail_Send"), {
host: "127.0.0.1", port: String(port), account: "robot@example.com",
to: "owner@example.com", subject: "RPA smoke", content: "mail delivered", is_ssl: "False"
}, component);
expect(received).toContain("RPA smoke");
expect(received).toContain("mail delivered");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("creates and writes a Word document through COM", async () => {
if (process.platform !== "win32") return;
const directory = await tempDir();
const create = getHandler("Word_New");
const write = getHandler("Word_Write");
expect(create && write).toBeTruthy();
const handle = await create!(context, node("Word_New"), { filePath: directory, name: "smoke.docx" }, component);
await write!(context, node("Word_Write"), { objWord: handle, text: "RPA Word smoke" }, component);
const file = await readFile(path.join(directory, "smoke.docx"));
expect(file.byteLength).toBeGreaterThan(1000);
}, 30000);
});
@@ -0,0 +1,39 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type { Catalog } from "../../shared/types";
import { hasCapability } from "../engine/registry";
import "./index";
const planned = [
"logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_while",
"logic_control_try_catch", "logic_control_var", "logic_control_break_for", "logic_control_next_for",
"logic_control_stop", "Trigger.Manual", "Trigger.FileArrival",
"WebBrowser_CreateV3", "WebBrowser_GoUrl", "WebBrowser_Switch", "Element_Click_V6", "Element_SetValue_V6",
"Element_GetText_V5", "Element_Exists", "Element_SelectOperation_v1", "WebBrowser_RunJsInIeByTitle", "WebBrowser_SaveAs",
"File_Read", "File_Write", "File_Copy", "File_File_Move", "File_DeleteFile", "File_Rename",
"File_FileExists", "File_GetFileList", "File_MakeDir", "File_CompressFile", "File_DecompressFile", "File_GetFileInfo",
"Excel_Cs_NewV5", "Excel_Cs_OpenV4", "Excel_Cs_ReadContentV2", "Excel_Cs_WriteCell", "Excel_Cs_WriteRange",
"Excel_Cs_ReadRangeV2", "Excel_Save", "Excel_Cs_SaveAs", "Excel_GetSheetsName", "Excel_Cs_Insert", "Excel_Cs_Rename", "CSV_Read", "CSV_Write",
"Keyboard_Send_V1", "Keyboard_PressNew", "Mouse_Click", "Mouse_MoveTo", "Mouse_DragTo", "Mouse_RollTo",
"Window_SetActive", "Window_GetActive", "Window_WindowExists", "Window_CloseWindow", "App_Run",
"Clipboard_GetText", "Clipboard_SetText", "Dialog_Notice", "Dialog_MsgBox", "System_ExecuteCommand",
"Http_Get", "Http_Post", "Mail_Send", "Mail_UnionSend",
"PDF_GetPdfPageNum", "PDF_GetPdfText", "PDF_ConcatPdf", "Word_New", "Word_Write",
"Image_Screenshot", "Image_Find", "Image_Click"
];
const runtimeContainers = new Set([
"logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_dictionary_for_v1",
"logic_control_while", "infinite_loop", "logic_control_try_catch"
]);
describe("planned capability audit", () => {
it("keeps every planned representative capability executable", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const missing = planned.filter((id) => {
const component = catalog.components.find((item) => item.id === id);
return !component || (!hasCapability(component.id) && !hasCapability(component.functionName) && !runtimeContainers.has(component.id));
});
expect(missing).toEqual([]);
});
});
+163
查看文件
@@ -0,0 +1,163 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
const execFileAsync = promisify(execFile);
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@`;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
}
return fallback;
}
function number(args: Record<string, unknown>, keys: string[], fallback = 0): number {
const value = Number(text(args, keys, String(fallback)));
return Number.isFinite(value) ? value : fallback;
}
function point(value: unknown): [number, number] {
if (Array.isArray(value)) return [Number(value[0]) || 0, Number(value[1]) || 0];
try {
const parsed = JSON.parse(String(value || "[0,0]"));
return point(parsed);
} catch {
const values = String(value || "").split(/[ ,,]+/).map(Number);
return [values[0] || 0, values[1] || 0];
}
}
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024
});
return String(result.stdout || "").trim();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const keys = text(args, ["keys", "key", "text", "value"]);
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`);
return keys;
};
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
return amount;
};
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`);
return result;
};
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
};
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {
const executable = text(args, ["executeable", "executable", "path"]);
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
const params = text(args, ["params", "arguments"]);
const cwd = text(args, ["cwd"]);
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)} -ArgumentList ${psString(params)} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
return Number(result) || result;
};
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
const content = text(args, ["text", "value"]);
await powershell(`Set-Clipboard -Value ${psString(content)}`);
return content;
};
const notice: CapabilityHandler = async (_ctx, _node, args) => {
const title = text(args, ["title"], "提醒");
const content = text(args, ["text", "message"]);
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`);
return true;
};
const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
const command = text(args, ["action", "command"]);
if (!command) throw new RuntimeError("validation_error", "命令行为空", { nodeId: node.id, componentId: node.componentId });
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
};
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag);
register(["Mouse_RollTo", "Mouse_Scroll"], mouseRoll);
register(["Window_SetActive", "Window_Activate"], setActiveWindow);
register(["Window_GetActive", "Window_GetActiveWindow"], getActiveWindow);
register(["Window_WindowExists", "Window_Exists"], windowExists);
register(["Window_CloseWindow", "Window_Close"], closeWindow);
register(["App_Run", "App_Start"], runApp);
register("Clipboard_GetText", clipboardGet);
register("Clipboard_SetText", clipboardSet);
register("Dialog_Notice", notice);
register("Dialog_MsgBox", notice);
register("System_ExecuteCommand", executeCommand);
+107
查看文件
@@ -0,0 +1,107 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./extended";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (componentId: string): FlowNode => ({
id: `extended_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-extended-"));
directories.push(directory);
return directory;
}
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTruthy();
return handler!(context, node(componentId), args, component);
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("extended local capabilities", () => {
it("registers every audited component id", () => {
for (const id of [
"File_GetFileList_V1", "File_GetFolderList", "File_GetFolderList_V1", "File_MoveFolder",
"File_Write_V1", "File_Copy_V1", "File_Rename_V1", "File_ReadXml", "File_EmptyFolder",
"Bivartable_ReadDfShape", "Bivartable_SortDf", "Bivartable_DoSort", "Bivartable_DelDfColRow",
"Bivartable_ReadTable", "Excel_Cs_ReadCell", "Excel_GetRowsCount", "Excel_GetColumnsCount",
"Excel_Cs_GetEditRange"
]) expect(getHandler(id), id).toBeTruthy();
});
it("performs file listing, copy, rename, XML read, move, and empty operations", async () => {
const root = await temporaryDirectory();
const source = path.join(root, "source");
const nested = path.join(source, "nested");
await run("File_Write_V1", { path: path.join(source, "alpha.xml"), content: "<root>RPA</root>" });
await run("File_Write_V1", { path: path.join(nested, "beta.txt"), content: "beta" });
await expect(run("File_GetFileList_V1", { folder_path: source, recursive: "1", is_full_path: "0" }))
.resolves.toEqual(["alpha.xml", "beta.txt"]);
await expect(run("File_GetFolderList", { folder_path: source, recursive: "1" }))
.resolves.toEqual([nested]);
await expect(run("File_ReadXml", { path: path.join(source, "alpha.xml") }))
.resolves.toBe("<root>RPA</root>");
const copied = await run("File_Copy_V1", {
file_type: "file", file_path: path.join(source, "alpha.xml"), target_path: root,
rename: "1", name: "copied.xml", action: "1"
}) as string;
await expect(readFile(copied, "utf8")).resolves.toContain("RPA");
const renamed = await run("File_Rename_V1", { file_type: "file", file_path: copied, name: "renamed.xml", action: "1" }) as string;
await expect(readFile(renamed, "utf8")).resolves.toContain("RPA");
const moved = path.join(root, "moved");
await run("File_MoveFolder", { pathSrc: nested, pathDest: moved, overwrite: "True" });
await expect(readFile(path.join(moved, "beta.txt"), "utf8")).resolves.toBe("beta");
await run("File_EmptyFolder", { folder_path: moved });
await expect(readdir(moved)).resolves.toEqual([]);
});
it("sorts, measures, deletes, and selects two-dimensional data", async () => {
const input = [["name", "score"], ["B", 2], ["A", 3], ["C", 1]];
await expect(run("Bivartable_ReadDfShape", { df: input })).resolves.toEqual([4, 2]);
await expect(run("Bivartable_SortDf", { df: input, name: "score", sort: "True" }))
.resolves.toEqual([["name", "score"], ["C", 1], ["B", 2], ["A", 3]]);
await expect(run("Bivartable_DoSort", { df: [[3], [1], [2]], sortby: "1", basceeding: "False" }))
.resolves.toEqual([[3], [2], [1]]);
await expect(run("Bivartable_DelDfColRow", { df: input, delRange: "col", delType: "name", del_col_name_list: ["score"] }))
.resolves.toEqual([["name"], ["B"], ["A"], ["C"]]);
await expect(run("Bivartable_ReadTable", { df: input, start_row: "2", end_row: "3", start_column: "A", end_column: "B" }))
.resolves.toEqual([["B", 2], ["A", 3]]);
});
it("reads Excel cells, dimensions, and the edited range", async () => {
const root = await temporaryDirectory();
const filePath = path.join(root, "extended.xlsx");
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Data");
sheet.getCell("B2").value = "RPA";
sheet.getCell("D5").value = 42;
await workbook.xlsx.writeFile(filePath);
await expect(run("Excel_Cs_ReadCell", { workbook: filePath, sheet: "Data", column: "B", row: "2" })).resolves.toBe("RPA");
await expect(run("Excel_GetRowsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(5);
await expect(run("Excel_GetColumnsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(4);
await expect(run("Excel_Cs_GetEditRange", { workbook: { path: filePath }, sheet: "Data" })).resolves.toEqual({
startRow: 2, startColumn: 2, endRow: 5, endColumn: 4, range: "B2:D5"
});
});
});
+284
查看文件
@@ -0,0 +1,284 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
type Table = unknown[][];
function value(args: Record<string, unknown>, keys: string[], fallback?: unknown): unknown {
for (const key of keys) {
const candidate = args[key];
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(candidate: unknown, fallback = false): boolean {
if (candidate === undefined || candidate === null || candidate === "") return fallback;
return [true, 1, "1", "true", "True", "是", "开启"].includes(candidate as never);
}
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(candidate ?? "").trim();
if (!result) {
throw new RuntimeError("validation_error", `${label}为空`, {
nodeId,
componentId,
recommendation: `请配置${label}`
});
}
return path.resolve(result);
}
function list(candidate: unknown): unknown[] {
if (Array.isArray(candidate)) return candidate;
if (candidate === undefined || candidate === null || candidate === "") return [];
if (typeof candidate === "string") {
try {
const parsed = JSON.parse(candidate);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return candidate.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
}
}
return [candidate];
}
function table(candidate: unknown): Table {
const parsed = typeof candidate === "string"
? (() => {
try { return JSON.parse(candidate); } catch { return candidate; }
})()
: candidate;
if (!Array.isArray(parsed)) return parsed === undefined || parsed === null ? [] : [[parsed]];
return parsed.map((row) => Array.isArray(row) ? [...row] : [row]);
}
function columnNumber(candidate: unknown): number {
const source = String(candidate ?? "A").trim().toUpperCase();
if (/^\d+$/.test(source)) return Math.max(1, Number(source));
let result = 0;
for (const character of source) result = result * 26 + character.charCodeAt(0) - 64;
return Math.max(1, result);
}
function compare(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true });
}
async function ensureAbsentOrRemove(target: string, action: unknown): Promise<boolean> {
try {
await fs.access(target);
} catch {
return true;
}
const mode = String(action ?? "1");
if (mode === "2" || mode.toLowerCase() === "skip") return false;
if (mode === "3" || mode.toLowerCase() === "error") throw new Error(`目标已存在:${target}`);
await fs.rm(target, { recursive: true, force: true });
return true;
}
async function collectEntries(
root: string,
kind: "file" | "directory",
recursive: boolean,
keyword: string,
ignore: string
): Promise<string[]> {
const results: string[] = [];
async function walk(folder: string): Promise<void> {
const entries = await fs.readdir(folder, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(folder, entry.name);
if (entry.isDirectory()) {
if (kind === "directory" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
if (recursive) await walk(absolute);
} else if (kind === "file" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
}
}
await walk(root);
return results;
}
register("File_GetFileList_V1", async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const fullPath = bool(args.is_full_path, true);
const results = await collectEntries(root, "file", bool(args.recursive), text(args, ["key_word"]), text(args, ["ignore"]));
const sortDescending = ["2", "desc", "descending"].includes(String(args.sort_type ?? "1").toLowerCase());
results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }) * (sortDescending ? -1 : 1));
return fullPath ? results : results.map((item) => path.basename(item));
});
const getFolderList: CapabilityHandler = async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const results = await collectEntries(root, "directory", bool(args.recursive), "", text(args, ["ignore"]));
return results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }));
};
register(["File_GetFolderList", "File_GetFolderList_V1"], getFolderList);
register("File_MoveFolder", async (_ctx, node, args) => {
const source = requiredPath(value(args, ["pathSrc"]), "文件夹路径", node.id, node.componentId);
const target = requiredPath(value(args, ["pathDest"]), "目的地路径", node.id, node.componentId);
if (bool(args.overwrite)) await fs.rm(target, { recursive: true, force: true });
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch {
await fs.cp(source, target, { recursive: true, force: bool(args.overwrite) });
await fs.rm(source, { recursive: true, force: true });
}
return target;
});
register("File_Write_V1", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "文件路径", node.id, node.componentId);
await fs.mkdir(path.dirname(target), { recursive: true });
const content = String(args.content ?? "");
if (bool(args.append)) await fs.appendFile(target, content, "utf8");
else await fs.writeFile(target, content, "utf8");
return target;
});
register("File_Copy_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), isFolder ? "待复制文件夹" : "待复制文件", node.id, node.componentId);
const destination = requiredPath(value(args, ["target_path"]), "复制目标", node.id, node.componentId);
const newName = bool(args.rename) && text(args, ["name"]) ? text(args, ["name"]) : path.basename(source);
const target = path.join(destination, newName);
if (!await ensureAbsentOrRemove(target, args.action)) return target;
await fs.mkdir(destination, { recursive: true });
if (isFolder) await fs.cp(source, target, { recursive: true, force: true });
else await fs.copyFile(source, target);
return target;
});
register("File_Rename_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), "待重命名路径", node.id, node.componentId);
const name = text(args, ["name"]).trim();
if (!name || path.basename(name) !== name) throw new Error("新名称必须是单独的文件或文件夹名称");
const target = path.join(path.dirname(source), name);
if (!await ensureAbsentOrRemove(target, args.action)) return source;
await fs.rename(source, target);
return target;
});
register("File_ReadXml", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "XML文件路径", node.id, node.componentId);
const buffer = await fs.readFile(target);
const encoding = text(args, ["encode"], "utf-8").toLowerCase().replace("_", "-");
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
return new TextDecoder(encoding).decode(buffer);
});
register("File_EmptyFolder", async (_ctx, node, args) => {
const folder = requiredPath(value(args, ["folder_path"]), "待清空文件夹", node.id, node.componentId);
const entries = await fs.readdir(folder);
await Promise.all(entries.map((entry) => fs.rm(path.join(folder, entry), { recursive: true, force: true })));
return true;
});
register("Bivartable_ReadDfShape", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
return [rows.length, rows.reduce((maximum, row) => Math.max(maximum, row.length), 0)];
});
function sortTable(args: Record<string, unknown>): Table {
const rows = table(value(args, ["df"]));
if (rows.length < 2) return rows;
const requested = text(args, ["name", "sortby"]).split(/[,,]/)[0].trim();
const headerIndex = requested ? rows[0].findIndex((cell) => String(cell) === requested) : -1;
const index = headerIndex >= 0 ? headerIndex : columnNumber(requested || "1") - 1;
const ascending = bool(value(args, ["sort", "basceeding"]), true);
const hasHeader = headerIndex >= 0;
const body = (hasHeader ? rows.slice(1) : rows).sort((left, right) => compare(left[index], right[index]) * (ascending ? 1 : -1));
return hasHeader ? [rows[0], ...body] : body;
}
register(["Bivartable_SortDf", "Bivartable_DoSort"], (_ctx, _node, args) => sortTable(args));
register("Bivartable_DelDfColRow", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const deletingColumns = text(args, ["delRange"], "col").toLowerCase().includes("col");
const raw = deletingColumns
? value(args, [text(args, ["delType"]) === "name" ? "del_col_name_list" : "del_col_num_list"])
: value(args, [text(args, ["delType"]) === "name" ? "del_row_name_list" : "del_row_num_list"]);
const requested = list(raw);
if (deletingColumns) {
const indexes = new Set(requested.map((item) => {
const byName = rows[0]?.findIndex((cell) => String(cell) === String(item)) ?? -1;
return text(args, ["delType"]) === "name" ? byName : columnNumber(item) - 1;
}).filter((index) => index >= 0));
return rows.map((row) => row.filter((_cell, index) => !indexes.has(index)));
}
const indexes = new Set(requested.map((item) => Math.max(0, Number(item) - 1)));
return rows.filter((_row, index) => !indexes.has(index));
});
register("Bivartable_ReadTable", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const startRow = Math.max(1, Number(args.start_row ?? args.row ?? 1)) - 1;
const endRow = Math.max(startRow + 1, Number(args.end_row ?? args.row ?? rows.length));
const startColumn = columnNumber(args.start_column ?? args.column ?? "A") - 1;
const endColumn = Math.max(startColumn + 1, columnNumber(args.end_column ?? args.column ?? String(rows[0]?.length || 1)));
const selection = rows.slice(startRow, endRow).map((row) => row.slice(startColumn, endColumn));
if (selection.length === 1 && selection[0].length === 1) return selection[0][0];
return selection;
});
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "path", "file"]);
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
return requiredPath(candidate, "Excel文件路径", nodeId, componentId);
}
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(workbookPath(args, nodeId, componentId));
const name = text(args, ["sheet", "sheetName"]);
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function excelValue(candidate: ExcelJS.CellValue): unknown {
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result;
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
return candidate ?? "";
}
register("Excel_Cs_ReadCell", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
return excelValue(sheet.getCell(`${text(args, ["column"], "A")}${text(args, ["row"], "1")}`).value);
});
register("Excel_GetRowsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).rowCount);
register("Excel_GetColumnsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).columnCount);
register("Excel_Cs_GetEditRange", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
let startRow = Number.POSITIVE_INFINITY;
let startColumn = Number.POSITIVE_INFINITY;
let endRow = 0;
let endColumn = 0;
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
row.eachCell({ includeEmpty: false }, (_cell, columnNumberValue) => {
startRow = Math.min(startRow, rowNumber);
startColumn = Math.min(startColumn, columnNumberValue);
endRow = Math.max(endRow, rowNumber);
endColumn = Math.max(endColumn, columnNumberValue);
});
});
if (!endRow) return { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, range: "" };
const range = `${sheet.getColumn(startColumn).letter}${startRow}:${sheet.getColumn(endColumn).letter}${endRow}`;
return { startRow, startColumn, endRow, endColumn, range };
});
+7
查看文件
@@ -3,5 +3,12 @@ import "./files";
import "./http";
import "./data";
import "./browser";
import "./office";
import "./desktop";
import "./pdf";
import "./word";
import "./message";
import "./vision";
import "./extended";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
+31
查看文件
@@ -0,0 +1,31 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function list(value: string): string[] {
return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean);
}
register(["Mail_Send", "Mail_UnionSend"], async (_ctx, node, args) => {
const account = text(args, ["account", "from", "account_obj"]);
const password = text(args, ["password", "pass"]);
const host = text(args, ["host", "smtp"]);
if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`);
const port = Number(text(args, ["port"], "25"));
const secure = ["true", "1", "是", "True"].includes(text(args, ["is_ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({
from: account,
to: list(text(args, ["to", "receiver"])),
cc: list(text(args, ["Cc", "cc"])),
bcc: list(text(args, ["Bcc", "bcc"])),
subject: text(args, ["subject"]),
text: text(args, ["content", "body"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
});
+222
查看文件
@@ -0,0 +1,222 @@
import ExcelJS from "exceljs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
interface ExcelHandle {
id: string;
path: string;
}
const workbooks = new Map<string, ExcelJS.Workbook>();
function value(args: Record<string, unknown>, keys: string[], fallback = ""): unknown {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(valueToRead: unknown, fallback = false): boolean {
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
}
function required(valueToRead: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(valueToRead || "").trim();
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
return result;
}
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
workbooks.set(handle.id, workbook);
return handle;
}
async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<{ handle: ExcelHandle; workbook: ExcelJS.Workbook }> {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
const existing = id ? workbooks.get(id) : undefined;
if (existing) return { handle: { id, path: String((raw as Partial<ExcelHandle>).path || "") }, workbook: existing };
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
return { handle: newHandle(filePath, workbook), workbook };
}
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function parseRange(range: string, sheet: ExcelJS.Worksheet): { start: string; end?: string } {
if (range.trim()) {
const [start, end] = range.split(":").map((item) => item.trim());
return { start, end };
}
const lastRow = Math.max(1, sheet.rowCount);
const lastColumn = Math.max(1, sheet.columnCount);
return { start: "A1", end: `${sheet.getColumn(lastColumn).letter}${lastRow}` };
}
function cellValue(valueToRead: ExcelJS.CellValue): unknown {
if (valueToRead && typeof valueToRead === "object" && "text" in valueToRead) return valueToRead.text;
if (valueToRead && typeof valueToRead === "object" && "result" in valueToRead) return valueToRead.result;
return valueToRead ?? "";
}
function tableFromSheet(sheet: ExcelJS.Worksheet, range: string): unknown[][] {
const target = parseRange(range, sheet);
const startRow = Number(sheet.getCell(target.start).row);
const endRow = target.end ? Number(sheet.getCell(target.end).row) : sheet.rowCount;
const rows = sheet.getRows(startRow, Math.max(1, endRow - startRow + 1)) || [];
const startColumn = Number(sheet.getCell(target.start).col);
const endColumn = target.end ? Number(sheet.getCell(target.end).col) : sheet.columnCount;
return rows.map((row) => {
const values: unknown[] = [];
for (let column = startColumn; column <= endColumn; column += 1) values.push(cellValue(row.getCell(column).value));
return values;
});
}
function rowsFrom(valueToRead: unknown): unknown[][] {
if (Array.isArray(valueToRead)) {
return valueToRead.map((row) => Array.isArray(row) ? row : [row]);
}
if (typeof valueToRead === "string") {
try {
return rowsFrom(JSON.parse(valueToRead));
} catch {
return [[valueToRead]];
}
}
return [[valueToRead]];
}
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(value(args, ["path", "folder"]), "Excel目录", node.id, node.componentId);
const name = text(args, ["name"], "新建工作簿.xlsx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
if (!bool(value(args, ["override"]), true)) {
try {
await fs.access(filePath);
throw new RuntimeError("validation_error", `文件已存在:${filePath}`, { nodeId: node.id, componentId: node.componentId, recommendation: "开启覆盖同名文件,或修改文件名称" });
} catch (error) {
if (error instanceof RuntimeError) throw error;
}
}
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("Sheet1");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await workbook.xlsx.writeFile(filePath);
return newHandle(filePath, workbook);
};
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(value(args, ["file", "path"]), "Excel文件", node.id, node.componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(path.resolve(filePath));
return newHandle(path.resolve(filePath), workbook);
};
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
return tableFromSheet(sheet, text(args, ["range", "excelRng"]));
};
const writeCell: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const address = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
sheet.getCell(address).value = value(args, ["content", "value"]) as ExcelJS.CellValue;
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const start = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
const startCell = sheet.getCell(start);
const startRow = startCell.row;
const startColumn = startCell.col;
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "table"])).entries()) {
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
}
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
await workbook.xlsx.writeFile(handle.path);
return handle.path;
};
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId);
handle.path = path.resolve(target);
await fs.mkdir(path.dirname(handle.path), { recursive: true });
await workbook.xlsx.writeFile(handle.path);
return handle;
};
register(["Excel_Cs_NewV5", "Excel_Cs_New", "Excel_New"], newExcel);
register(["Excel_Cs_OpenV4", "Excel_Cs_Open", "Excel_Open"], openExcel);
register(["Excel_Cs_ReadContentV2", "Excel_Cs_ReadRangeV2", "Excel_ReadRange"], readExcel);
register(["Excel_Cs_WriteCell", "Excel_WriteCell"], writeCell);
register(["Excel_Cs_WriteRange", "Excel_WriteRange"], writeRange);
register(["Excel_Save", "Excel_Cs_Save", "Excel_SaveV2"], saveExcel);
register(["Excel_Cs_SaveAs", "Excel_SaveAs"], saveAsExcel);
register(["Excel_GetSheetsName", "Excel_GetSheetNames"], async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
return workbook.worksheets.map((sheet) => sheet.name);
});
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
const newName = required(value(args, ["newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
const sheet = sheetOf(workbook, oldName);
sheet.name = newName;
await workbook.xlsx.writeFile(handle.path);
return handle;
});
register(["Excel_Cs_Insert", "Excel_InsertSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const name = required(value(args, ["name", "sheetName"]), "Sheet名称", node.id, node.componentId);
if (workbook.getWorksheet(name)) throw new RuntimeError("validation_error", `Sheet 已存在:${name}`, { nodeId: node.id, componentId: node.componentId });
workbook.addWorksheet(name);
if (bool(value(args, ["save"]), false)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertRow", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const rows = rowsFrom(value(args, ["contents"]));
const inserts = Array.from({ length: count }, (_, rowIndex) => rows[rowIndex] || []);
sheet.spliceRows(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertColumn", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const values = rowsFrom(value(args, ["contents"])).flat();
const inserts = Array.from({ length: count }, (_, columnIndex) => [values[columnIndex] ?? ""]);
sheet.spliceColumns(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
+62
查看文件
@@ -0,0 +1,62 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function required(value: string, label: string, nodeId: string, componentId: string): string {
if (!value.trim()) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId });
return path.resolve(value);
}
const pageCount: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
return document.getPageCount();
};
const concat: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(text(args, ["newPdfPath", "folder", "path"]), "输出目录", node.id, node.componentId);
const name = text(args, ["newPdfName", "name"], "合并结果.pdf");
const output = path.join(folder, path.extname(name) ? name : `${name}.pdf`);
const rawList = text(args, ["pdfList", "files"]);
let files: string[] = [];
try {
const parsed = JSON.parse(rawList);
files = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
} catch {
files = rawList.split(/[\r\n;,]+/).map((item) => item.trim()).filter(Boolean);
}
if (!files.length) throw new RuntimeError("validation_error", "待合并 PDF 列表为空", { nodeId: node.id, componentId: node.componentId });
const target = await PDFDocument.create();
for (const file of files) {
const source = await PDFDocument.load(await fs.readFile(path.resolve(file)), { ignoreEncryption: true });
const pages = await target.copyPages(source, source.getPageIndices());
pages.forEach((page) => target.addPage(page));
}
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.writeFile(output, await target.save());
return output;
};
register(["PDF_GetPdfPageNum", "PDF_GetPageCount"], pageCount);
register("PDF_ConcatPdf", concat);
register("PDF_GetPdfText", async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const document = await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)) }).promise;
const pages = text(args, ["pdfNum", "page"]);
const indices = pages ? pages.split(/[ ,,;;]+/).map((item) => Number(item)).filter((item) => item > 0) : Array.from({ length: document.numPages }, (_, index) => index + 1);
const chunks: string[] = [];
for (const index of indices) {
const page = await document.getPage(index);
const content = await page.getTextContent();
chunks.push(content.items.map((item) => ("str" in item ? item.str : "")).join(""));
}
return chunks.join("\n");
});
+97
查看文件
@@ -0,0 +1,97 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
register("Image_Screenshot", async (_ctx, node, args) => {
const folder = text(args, ["outImagePath", "folder"]);
const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`);
const output = path.resolve(text(args, ["image_path"], folder ? path.join(folder, name) : name));
await fs.mkdir(path.dirname(output), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(output)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return output;
});
interface MatchResult { x: number; y: number; width: number; height: number; score: number; }
async function captureScreen(target: string): Promise<string> {
await fs.mkdir(path.dirname(target), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return target;
}
function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number {
let difference = 0;
let samples = 0;
for (let y = 0; y < template.height; y += sampleStep) {
for (let x = 0; x < template.width; x += sampleStep) {
const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4;
const templateIndex = (y * template.width + x) * 4;
difference += Math.abs(screen.data[sourceIndex] - template.data[templateIndex]);
difference += Math.abs(screen.data[sourceIndex + 1] - template.data[templateIndex + 1]);
difference += Math.abs(screen.data[sourceIndex + 2] - template.data[templateIndex + 2]);
samples += 3;
if (difference > cutoff * samples) return difference / (samples * 255);
}
}
return samples ? difference / (samples * 255) : 1;
}
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number): Promise<MatchResult | null> {
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]);
const screen = PNG.sync.read(screenBuffer);
const template = PNG.sync.read(templateBuffer);
if (template.width > screen.width || template.height > screen.height) return null;
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18));
const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));
let best: MatchResult | null = null;
for (let y = 0; y <= screen.height - template.height; y += scanStep) {
for (let x = 0; x <= screen.width - template.width; x += scanStep) {
const difference = pixelDifference(screen, template, x, y, sampleStep, best ? 1 - best.score : maximumDifference);
const score = 1 - difference;
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
if (!best || 1 - best.score > maximumDifference) return null;
const refine = Math.max(1, scanStep);
const minX = Math.max(0, best.x - refine);
const maxX = Math.min(screen.width - template.width, best.x + refine);
const minY = Math.max(0, best.y - refine);
const maxY = Math.min(screen.height - template.height, best.y + refine);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const difference = pixelDifference(screen, template, x, y, sampleStep, 1 - best.score);
const score = 1 - difference;
if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
return best;
}
async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> {
const rawTemplate = text(args, ["image", "image_path", "template"]);
if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId });
const template = path.resolve(rawTemplate);
const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`);
await captureScreen(screenshot);
const result = await findImage(template, screenshot, Number(text(args, ["accuracy"], "0.2")));
if (!result) throw new RuntimeError("not_found", "屏幕中未找到目标图像", { nodeId, recommendation: "重新截取模板,或调低匹配精度" });
return result;
}
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => {
const result = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(result.x + result.width / 2);
const y = Math.round(result.y + result.height / 2);
await powershell(`Add-Type @'\nusing System; using System.Runtime.InteropServices; public static class Clicker { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); }\n'@; [Clicker]::SetCursorPos(${x},${y}); [Clicker]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Clicker]::mouse_event(4,0,0,0,[UIntPtr]::Zero)`);
return { ...result, click: { x, y } };
});
+44
查看文件
@@ -0,0 +1,44 @@
import path from "node:path";
import { register } from "../engine/registry";
import { powershell, psString } from "./desktop";
interface WordHandle { path: string; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function handlePath(args: Record<string, unknown>): string {
const raw = args.objWord ?? args.filePath ?? args.path ?? args.file;
if (typeof raw === "object" && raw !== null && "path" in raw) return path.resolve(String((raw as WordHandle).path));
return path.resolve(String(raw || ""));
}
function ensure(value: string, label: string): string {
if (!value.trim()) throw new Error(`${label}为空`);
return value;
}
function wordScript(body: string): string {
return `$word = $null; $doc = $null; try { $word = New-Object -ComObject Word.Application; $word.Visible = $false; $word.DisplayAlerts = 0; ${body} } finally { if ($doc -ne $null) { try { $doc.Close() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc) } catch {} }; if ($word -ne $null) { try { $word.Quit() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($word) } catch {} }; [GC]::Collect(); [GC]::WaitForPendingFinalizers() }`;
}
register("Word_New", async (_ctx, _node, args) => {
const folder = ensure(text(args, ["filePath", "folder", "path"]), "Word目录");
const name = text(args, ["name"], "新建文档.docx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.docx`);
await powershell(wordScript(`$doc = $word.Documents.Add(); $doc.SaveAs2(${psString(filePath)}); ${psString(filePath)}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Open", async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
register("Word_Write", async (_ctx, _node, args) => {
const filePath = ensure(handlePath(args), "Word文件路径");
const content = text(args, ["text", "content"]);
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $range = $doc.Content; $range.Collapse(0); $range.InsertAfter(${psString(content)}); $doc.Save()`));
return { path: filePath } satisfies WordHandle;
});
register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));