/** * 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 -- 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--<时间戳>.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(); }