/** * Priority1 纯人工录制 — 仅打开页面 + 记录操作,不自动填表 */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium, type Page } from "playwright"; import { PRIORITY1_FORM_META } from "@/workers/rpa/priority1/form-logic-map"; import { buildAccessorialLabelPlaywright, drainHumanAssistEvents, formatHumanEventsAsPlaywright, type HumanAccessorialRecording, type HumanAssistEvent, } from "@/workers/rpa/priority1/human-assist-recorder"; import { extractPriority1VisibleQuotes } from "@/workers/rpa/priority1/quote-extract"; const BROWSER_RECORDER_JS = join( dirname(fileURLToPath(import.meta.url)), "human-assist-recorder.browser.js", ); export type ManualRecordingOptions = { pathId: string; title: string; outDir?: string; entryUrl?: string; checklist?: readonly string[]; maxWaitMs?: number; }; function loadRecorderScript(): string { return readFileSync(BROWSER_RECORDER_JS, "utf8"); } function attachQuoteNetworkCapture(page: Page, bodies: string[]): void { page.on("response", async (res) => { const url = res.url(); if (!PRIORITY1_FORM_META.quoteApiPattern.test(url)) return; try { const json = await res.json(); bodies.push(JSON.stringify(json)); } catch { // ignore } }); } function saveRecording( outDir: string, opts: ManualRecordingOptions, events: HumanAssistEvent[], quoteDetected: boolean, ): { jsonPath: string; jsPath: string } { const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15); const recordingsDir = join(process.cwd(), ".rpa", "recordings"); mkdirSync(recordingsDir, { recursive: true }); const base = `priority1-${opts.pathId}-manual-${ts}`; const checklist = opts.checklist ?? []; const usedChecklistFallback = events.length === 0 && quoteDetected && checklist.length > 0; const playwrightScript = usedChecklistFallback ? buildAccessorialLabelPlaywright(checklist) : formatHumanEventsAsPlaywright(events); const payload: HumanAccessorialRecording & { quoteDetected: boolean; mode: string; humanVerified?: boolean; note?: string; } = { pathId: opts.pathId, title: opts.title, recordedAt: new Date().toISOString(), expectedAccessorials: checklist, events, playwrightScript, quoteDetected, mode: "manual-only", ...(usedChecklistFallback ? { humanVerified: true, note: "人工实机勾选并出报价;DOM 事件未捕获,回放脚本由 11 项清单生成", } : {}), }; const jsonPath = join(recordingsDir, `${base}.json`); const jsPath = join(recordingsDir, `${base}.js`); writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); writeFileSync(jsPath, `${playwrightScript}\n`, "utf8"); writeFileSync(join(outDir, "human-recording.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8"); writeFileSync(join(outDir, "human-recording.js"), `${playwrightScript}\n`, "utf8"); return { jsonPath, jsPath }; } function printChecklist(opts: ManualRecordingOptions): void { console.log("\n════════ 纯人工操作 — 脚本不自动填表 ════════"); console.log(`[manual] 路径: ${opts.pathId} — ${opts.title}`); console.log("[manual] 请在浏览器中从头完成全部字段,直至出现报价。"); if (opts.checklist?.length) { console.log("\n[manual] 参考清单(P08 附加服务 11 项):"); for (let i = 0; i < opts.checklist.length; i += 1) { console.log(` ${i + 1}. ${opts.checklist[i]}`); } } console.log("\n[manual] 操作会被持续记录。"); console.log("[manual] 出现报价后可继续核对;按 Ctrl+C 结束并保存录制。\n"); } export async function runPriority1ManualRecordingSession( opts: ManualRecordingOptions, ): Promise { const outDir = opts.outDir ?? join(process.cwd(), ".rpa", "recordings", `${opts.pathId}-manual-session`); mkdirSync(outDir, { recursive: true }); const entryUrl = opts.entryUrl ?? PRIORITY1_FORM_META.entryUrl; const script = loadRecorderScript(); const networkBodies: string[] = []; const browser = await chromium.launch({ headless: false, channel: (process.env.RPA_BROWSER_CHANNEL?.trim() || "chrome") as "chrome", }); const context = await browser.newContext({ viewport: { width: 1400, height: 900 }, locale: "en-US", }); const page = await context.newPage(); page.setDefaultTimeout(120_000); attachQuoteNetworkCapture(page, networkBodies); await page.addInitScript({ content: script }); console.log(`[manual] 打开: ${entryUrl}`); await page.goto(entryUrl, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#container-mh", { timeout: 60_000 }).catch(() => undefined); await page.evaluate(script).catch(() => undefined); printChecklist(opts); const maxWait = opts.maxWaitMs ?? 2 * 60 * 60 * 1000; const pollMs = 2_000; const started = Date.now(); let lastEventCount = 0; let quoteDetected = false; let accumulatedEvents: HumanAssistEvent[] = []; let shuttingDown = false; const finish = async (reason: string) => { if (shuttingDown) return; shuttingDown = true; const drained = await drainHumanAssistEvents(page).catch(() => [] as HumanAssistEvent[]); const events = drained.length >= accumulatedEvents.length ? drained : accumulatedEvents; const { jsonPath, jsPath } = saveRecording(outDir, opts, events, quoteDetected); console.log(`\n[manual] ${reason}`); console.log(`[manual] 记录事件 ${events.length} 条`); console.log(`[manual] JSON: ${jsonPath}`); console.log(`[manual] JS: ${jsPath}`); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); process.exit(0); }; process.once("SIGINT", () => { void finish("用户 Ctrl+C — 已保存录制"); }); process.once("SIGTERM", () => { void finish("收到终止信号 — 已保存录制"); }); while (Date.now() - started < maxWait && !shuttingDown) { if (page.isClosed()) { await finish("浏览器已关闭 — 已保存已记录操作"); return; } const drained = await drainHumanAssistEvents(page).catch(() => [] as HumanAssistEvent[]); if (drained.length > accumulatedEvents.length) { accumulatedEvents = drained; } if (accumulatedEvents.length > lastEventCount) { const latest = accumulatedEvents[accumulatedEvents.length - 1]!; console.log( `[manual] 记录 #${accumulatedEvents.length}: ${latest.kind} ${latest.label || latest.selector || latest.text.slice(0, 40)}`, ); lastEventCount = accumulatedEvents.length; } const domText = await page.locator("body").innerText().catch(() => ""); const visible = extractPriority1VisibleQuotes(networkBodies, domText); if (visible.length > 0 && !quoteDetected) { quoteDetected = true; console.log(`\n[manual] ✓ 已检测到报价 ${visible.length} 条(可继续核对,Ctrl+C 保存)`); await page.screenshot({ path: join(outDir, "quote-result-full.png"), fullPage: true, }); } await page.waitForTimeout(pollMs).catch(async () => { await finish("浏览器已关闭 — 已保存已记录操作"); }); } await finish("等待超时 — 已保存录制"); }