|
|
/**
|
|
|
* Playwright codegen headed 录制 Flock Freight
|
|
|
* 匿名 get-a-quote + Step 12.78 登录后查价(--logged-in)
|
|
|
*
|
|
|
* 用法:
|
|
|
* npm run record:flock
|
|
|
* npm run record:flock -- list
|
|
|
* npm run record:flock -- --logged-in
|
|
|
* npm run record:flock -- --logged-in https://app.flockfreight.com/login
|
|
|
*/
|
|
|
import { spawn } from "node:child_process";
|
|
|
import fs from "node:fs";
|
|
|
import path from "node:path";
|
|
|
import {
|
|
|
DEFAULT_FLOCK_DEMO,
|
|
|
DEFAULT_FLOCK_QUOTE_URL,
|
|
|
FLOCK_CANONICAL_PATHS,
|
|
|
formatFlockRecordingChecklist,
|
|
|
} from "@/workers/rpa/flock/demo-input";
|
|
|
|
|
|
const ROOT = process.cwd();
|
|
|
const OUTPUT_DIR = path.join(ROOT, ".rpa", "recordings");
|
|
|
|
|
|
export type FlockRecordMode = "anonymous" | "logged-in";
|
|
|
|
|
|
export type ParsedFlockRecordArgs = {
|
|
|
mode: FlockRecordMode;
|
|
|
startUrl?: string;
|
|
|
pathId?: string;
|
|
|
help: boolean;
|
|
|
list: boolean;
|
|
|
fresh: boolean;
|
|
|
reuseStorage: boolean;
|
|
|
};
|
|
|
|
|
|
export const ANON_FLOCK_STORAGE_DEFAULT = path.join(
|
|
|
ROOT,
|
|
|
".rpa",
|
|
|
"flock-storage.json",
|
|
|
);
|
|
|
export const LOGGED_IN_FLOCK_STORAGE_DEFAULT = path.join(
|
|
|
ROOT,
|
|
|
".rpa",
|
|
|
"flock-logged-in-storage.json",
|
|
|
);
|
|
|
|
|
|
export const DEFAULT_FLOCK_LOGIN_URL =
|
|
|
process.env.FLOCK_LOGIN_URL?.trim() ||
|
|
|
"https://app.flockfreight.com/login";
|
|
|
|
|
|
export function parseFlockRecordArgs(argv: string[]): ParsedFlockRecordArgs {
|
|
|
const tokens = argv.slice(2).filter(Boolean);
|
|
|
let mode: FlockRecordMode = "anonymous";
|
|
|
let help = false;
|
|
|
let list = false;
|
|
|
let fresh = false;
|
|
|
let reuseStorage = false;
|
|
|
let startUrl: string | undefined;
|
|
|
let pathId: string | undefined;
|
|
|
|
|
|
for (const token of tokens) {
|
|
|
if (token === "-h" || token === "--help") {
|
|
|
help = true;
|
|
|
continue;
|
|
|
}
|
|
|
if (token === "list") {
|
|
|
list = true;
|
|
|
continue;
|
|
|
}
|
|
|
if (token === "--logged-in" || token === "--login") {
|
|
|
mode = "logged-in";
|
|
|
continue;
|
|
|
}
|
|
|
if (token === "--fresh") {
|
|
|
fresh = true;
|
|
|
continue;
|
|
|
}
|
|
|
if (token === "--reuse-storage") {
|
|
|
reuseStorage = true;
|
|
|
continue;
|
|
|
}
|
|
|
if (token.startsWith("http://") || token.startsWith("https://")) {
|
|
|
startUrl = token;
|
|
|
continue;
|
|
|
}
|
|
|
if (!token.startsWith("-") && !pathId) {
|
|
|
pathId = token;
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (process.env.FLOCK_RECORD_FRESH === "true") {
|
|
|
fresh = true;
|
|
|
}
|
|
|
if (process.env.FLOCK_RECORD_REUSE_STORAGE === "true") {
|
|
|
reuseStorage = true;
|
|
|
}
|
|
|
|
|
|
return { mode, startUrl, pathId, help, list, fresh, reuseStorage };
|
|
|
}
|
|
|
|
|
|
export function resolveFlockStoragePath(mode: FlockRecordMode): string {
|
|
|
if (mode === "logged-in") {
|
|
|
return (
|
|
|
process.env.FLOCK_LOGGED_IN_STORAGE_STATE_PATH?.trim() ||
|
|
|
LOGGED_IN_FLOCK_STORAGE_DEFAULT
|
|
|
);
|
|
|
}
|
|
|
return (
|
|
|
process.env.FLOCK_STORAGE_STATE_PATH?.trim() || ANON_FLOCK_STORAGE_DEFAULT
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function resolveFlockOutputBasename(
|
|
|
mode: FlockRecordMode,
|
|
|
ts: string,
|
|
|
pathId?: string,
|
|
|
): string {
|
|
|
if (mode === "logged-in") {
|
|
|
return `flock-logged-in-${ts}.js`;
|
|
|
}
|
|
|
const suffix = pathId ?? "manual";
|
|
|
return `flock-${suffix}-${ts}.js`;
|
|
|
}
|
|
|
|
|
|
export function formatTimestamp(d = new Date()): string {
|
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
|
}
|
|
|
|
|
|
export function formatFlockLoggedInRecordingChecklist(): string {
|
|
|
const demo = DEFAULT_FLOCK_DEMO;
|
|
|
return [
|
|
|
"【Flock 登录后查价录制清单】",
|
|
|
"1. 打开登录页,手动粘贴 FLOCK_LOGIN_EMAIL / FLOCK_LOGIN_PASSWORD 并提交",
|
|
|
"2. 进入登录后查价入口(记录最终 URL;常见 Let's build a quote / Quick)",
|
|
|
`3. 标准样例:Pickup ZIP ${demo.pickupZip} → Delivery ZIP ${demo.deliveryZip}`,
|
|
|
`4. 托盘/重量/尺寸:${demo.palletCount} pallets / ${demo.totalWeightLb} lb / ${demo.lengthIn}×${demo.widthIn}×${demo.heightIn} in`,
|
|
|
"5. 填 Type / Packaging / Freight Class / Description 等登录后必填项",
|
|
|
"6. 点 Generate my quote(或等价提交),等到报价结果出现",
|
|
|
"7. 关闭 codegen,确认 flock-logged-in-*.js + flock-logged-in-storage.json 落盘",
|
|
|
].join("\n");
|
|
|
}
|
|
|
|
|
|
function hasFlockEnvCredentials(): boolean {
|
|
|
const email = process.env.FLOCK_LOGIN_EMAIL?.trim() ?? "";
|
|
|
const password = process.env.FLOCK_LOGIN_PASSWORD?.trim() ?? "";
|
|
|
return Boolean(email && password);
|
|
|
}
|
|
|
|
|
|
function printHelp(): void {
|
|
|
console.log(`
|
|
|
用法:
|
|
|
npm run record:flock
|
|
|
npm run record:flock -- list
|
|
|
npm run record:flock -- <path-id>
|
|
|
npm run record:flock -- <起始URL>
|
|
|
npm run record:flock -- --logged-in [起始URL]
|
|
|
npm run record:flock -- --login [起始URL]
|
|
|
|
|
|
模式:
|
|
|
匿名(默认) 录制 marketing get-a-quote;可用 --fresh 无痕
|
|
|
--logged-in 录制账号登录后查价完整路径;storage 与匿名隔离
|
|
|
|
|
|
默认入口:
|
|
|
匿名: ${DEFAULT_FLOCK_QUOTE_URL}
|
|
|
登录后: ${DEFAULT_FLOCK_LOGIN_URL}
|
|
|
|
|
|
产出(匿名):
|
|
|
.rpa/recordings/flock-<path|manual>-<时间戳>.js
|
|
|
.rpa/flock-storage.json
|
|
|
|
|
|
产出(登录后):
|
|
|
.rpa/recordings/flock-logged-in-<时间戳>.js
|
|
|
.rpa/flock-logged-in-storage.json
|
|
|
|
|
|
登录后录制说明:
|
|
|
- 请在 codegen 浏览器中手动粘贴账密(脚本不自动填)
|
|
|
- SOP: docs/rpa-flock-logged-in-sop.md
|
|
|
|
|
|
标准样例(匿名):
|
|
|
${formatFlockRecordingChecklist(DEFAULT_FLOCK_DEMO)}
|
|
|
|
|
|
canonical 路径:
|
|
|
${FLOCK_CANONICAL_PATHS.map((p) => ` - ${p.id}: ${p.title}`).join("\n")}
|
|
|
`);
|
|
|
}
|
|
|
|
|
|
function resolveAnonymousStart(
|
|
|
arg: string | undefined,
|
|
|
): { startUrl: string; pathId?: string; demo: typeof DEFAULT_FLOCK_DEMO } {
|
|
|
if (!arg) {
|
|
|
return { startUrl: DEFAULT_FLOCK_QUOTE_URL, demo: DEFAULT_FLOCK_DEMO };
|
|
|
}
|
|
|
if (arg.startsWith("http://") || arg.startsWith("https://")) {
|
|
|
return { startUrl: arg, demo: DEFAULT_FLOCK_DEMO };
|
|
|
}
|
|
|
const canonical = FLOCK_CANONICAL_PATHS.find((p) => p.id === arg);
|
|
|
if (canonical) {
|
|
|
console.log(`\n路径: ${canonical.id} — ${canonical.title}`);
|
|
|
console.log(`说明: ${canonical.notes}\n`);
|
|
|
console.log(formatFlockRecordingChecklist(canonical.input));
|
|
|
console.log("");
|
|
|
return {
|
|
|
startUrl: DEFAULT_FLOCK_QUOTE_URL,
|
|
|
pathId: canonical.id,
|
|
|
demo: canonical.input,
|
|
|
};
|
|
|
}
|
|
|
console.warn(`未知 path-id「${arg}」,使用默认样例`);
|
|
|
return { startUrl: DEFAULT_FLOCK_QUOTE_URL, demo: DEFAULT_FLOCK_DEMO };
|
|
|
}
|
|
|
|
|
|
function main(): void {
|
|
|
const parsed = parseFlockRecordArgs(process.argv);
|
|
|
|
|
|
if (parsed.help) {
|
|
|
printHelp();
|
|
|
process.exit(0);
|
|
|
}
|
|
|
|
|
|
if (parsed.list) {
|
|
|
console.log("Flock canonical 录制路径:\n");
|
|
|
for (const p of FLOCK_CANONICAL_PATHS) {
|
|
|
console.log(` ${p.id}`);
|
|
|
console.log(` ${p.title}`);
|
|
|
console.log(` ${p.notes}\n`);
|
|
|
}
|
|
|
process.exit(0);
|
|
|
}
|
|
|
|
|
|
const mode = parsed.mode;
|
|
|
const storagePath = resolveFlockStoragePath(mode);
|
|
|
const ts = formatTimestamp();
|
|
|
|
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
|
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
|
|
|
|
|
let startUrl: string;
|
|
|
let pathId: string | undefined;
|
|
|
let demo = DEFAULT_FLOCK_DEMO;
|
|
|
let checklistBody: string;
|
|
|
|
|
|
if (mode === "logged-in") {
|
|
|
startUrl = parsed.startUrl ?? DEFAULT_FLOCK_LOGIN_URL;
|
|
|
checklistBody = formatFlockLoggedInRecordingChecklist();
|
|
|
} else {
|
|
|
const resolved = resolveAnonymousStart(parsed.startUrl ?? parsed.pathId);
|
|
|
startUrl = resolved.startUrl;
|
|
|
pathId = resolved.pathId;
|
|
|
demo = resolved.demo;
|
|
|
checklistBody = formatFlockRecordingChecklist(demo);
|
|
|
}
|
|
|
|
|
|
const outputFile = path.join(
|
|
|
OUTPUT_DIR,
|
|
|
resolveFlockOutputBasename(mode, ts, pathId),
|
|
|
);
|
|
|
const checklistFile = outputFile.replace(/\.js$/, ".checklist.txt");
|
|
|
fs.writeFileSync(checklistFile, checklistBody, "utf8");
|
|
|
|
|
|
const args = [
|
|
|
"playwright",
|
|
|
"codegen",
|
|
|
"--target=javascript",
|
|
|
"-o",
|
|
|
outputFile,
|
|
|
];
|
|
|
|
|
|
if (mode === "logged-in") {
|
|
|
// 登录态必须持久 session,禁止 --fresh 跳过 save
|
|
|
args.push(`--save-storage=${storagePath}`);
|
|
|
if (fs.existsSync(storagePath)) {
|
|
|
args.push(`--load-storage=${storagePath}`);
|
|
|
console.log(`已加载已有 storageState: ${storagePath}`);
|
|
|
}
|
|
|
} else if (parsed.fresh) {
|
|
|
console.log("【无痕录制 --fresh】不加载/保存 storage");
|
|
|
} else if (parsed.reuseStorage) {
|
|
|
args.push(`--save-storage=${storagePath}`);
|
|
|
if (fs.existsSync(storagePath)) {
|
|
|
args.push(`--load-storage=${storagePath}`);
|
|
|
console.log(`已加载已有 storageState: ${storagePath}`);
|
|
|
}
|
|
|
} else {
|
|
|
args.push(`--save-storage=${storagePath}`);
|
|
|
console.log(`录制结束将保存 storage: ${storagePath}`);
|
|
|
console.log("若已达报价上限,请使用: npm run record:flock -- --fresh");
|
|
|
}
|
|
|
|
|
|
args.push(startUrl);
|
|
|
|
|
|
if (mode === "logged-in") {
|
|
|
console.log("=== Flock Freight 登录后查价录制(headed)===");
|
|
|
console.log("RPA_HEADLESS=false");
|
|
|
console.log("模式: --logged-in(storage 与匿名 flock-storage.json 隔离)");
|
|
|
if (hasFlockEnvCredentials()) {
|
|
|
console.log(
|
|
|
"已检测到 .env 中 FLOCK_LOGIN_EMAIL/PASSWORD:请在浏览器中手动粘贴登录(脚本不自动填)",
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"未检测到 FLOCK_LOGIN_EMAIL/PASSWORD:请用管理端客户账密或准备官网账号后手动登录",
|
|
|
);
|
|
|
}
|
|
|
console.log(`起始 URL: ${startUrl}`);
|
|
|
console.log(`脚本产出: ${outputFile}`);
|
|
|
console.log(`清单文件: ${checklistFile}`);
|
|
|
console.log(`storageState: ${storagePath}`);
|
|
|
console.log("SOP: docs/rpa-flock-logged-in-sop.md");
|
|
|
} else {
|
|
|
console.log("=== Flock Freight RPA 录制(headed / 匿名)===");
|
|
|
console.log("RPA_HEADLESS=false");
|
|
|
console.log(`起始 URL: ${startUrl}`);
|
|
|
console.log(`脚本产出: ${outputFile}`);
|
|
|
console.log(`清单文件: ${checklistFile}`);
|
|
|
console.log(`storageState: ${storagePath}`);
|
|
|
console.log("登录后请用: npm run record:flock -- --logged-in");
|
|
|
}
|
|
|
console.log("");
|
|
|
console.log(checklistBody);
|
|
|
console.log("");
|
|
|
|
|
|
const child = spawn("npx", args, {
|
|
|
stdio: "inherit",
|
|
|
shell: true,
|
|
|
cwd: ROOT,
|
|
|
env: { ...process.env, RPA_HEADLESS: "false" },
|
|
|
});
|
|
|
|
|
|
child.on("error", (error) => {
|
|
|
console.error("启动 playwright codegen 失败:", error);
|
|
|
process.exit(1);
|
|
|
});
|
|
|
|
|
|
child.on("exit", (code) => {
|
|
|
if (code === 0) {
|
|
|
if (mode === "logged-in") {
|
|
|
console.log(
|
|
|
"\n录制结束。请回填 docs/rpa-flock-logged-in-sop.md 附录 A;可运行: npx tsx scripts/finalize-flock-logged-in-recording.ts",
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"\n录制结束。请将 flock-*.js 中的 selector 填入 .env FLOCK_SELECTOR_*",
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
process.exit(code ?? 0);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// vitest 导入本模块时勿启动 codegen
|
|
|
const isRecordEntrypoint = /record-flock\.(ts|js|mjs|cjs)$/.test(
|
|
|
process.argv[1] ?? "",
|
|
|
);
|
|
|
if (isRecordEntrypoint && !process.env.VITEST && !process.env.VITEST_WORKER_ID) {
|
|
|
main();
|
|
|
}
|