/** * 地址 commit 事件对照录证(headed 人工 vs RPA 生产路径) * * 目标:HAR + axel event trace → 对比缺失事件(不修 click/delay/retry) * * 用法: * # 仅人工(headed + HAR) * $env:COMPARE_MODE="manual"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts * * # 仅 RPA(fillAddressStep 生产路径) * $env:COMPARE_MODE="rpa"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts * * # 先后执行人工 + RPA 并 diff * $env:COMPARE_MODE="both"; npx tsx scripts/record-address-event-compare.ts * * 人工检查点(非 TTY): * $env:COMPARE_USE_FILE_TRIGGER="1" * pickup 完成后: New-Item -Force .rpa\address-event-compare\trigger-pickup-done * delivery 完成后: New-Item -Force .rpa\address-event-compare\trigger-delivery-done */ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import readline from "node:readline"; import { stdin as input, stdout as output } from "node:process"; import { chromium } from "playwright"; import { loadDevEnv } from "@/lib/dev-env"; import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra"; import { hostFetchMothershipCandidates, } from "@/lib/frontend/api-client"; import { applyMothershipCandidate } from "@/lib/frontend/mothership-address"; import { getPrimaryMothershipQuoteUrl } from "@/lib/rpa/env"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import { ensureRedisReady, resetRedisClient } from "@/lib/redis"; import type { QuoteRequest } from "@/modules/providers/quote-provider"; import { AxelEventTracer, diffAxelTraces, normalizeAxelSequence, } from "@/scripts/lib/axel-event-tracer"; import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step"; import { RPAContext } from "@/workers/rpa/kernel/context"; import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager"; import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter"; import { drainSelectionTraces, installManualSelectionListeners, logManualInteractionGoldSample, pickSelectionGoldInteractions, recordManualCheckpoint, } from "@/workers/rpa/selection-trace"; import { stabilizeQuoteLandingPage, waitForQuoterWidgetHydrated, } from "@/workers/rpa/page-prep"; loadDevEnv(); const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000"; const TOKEN = "demo-host-token"; const CUSTOMER_ID = "CUST_001"; const OUT_ROOT = join(process.cwd(), ".rpa", "address-event-compare"); const TRIGGER_PICKUP = join(OUT_ROOT, "trigger-pickup-done"); const TRIGGER_DELIVERY = join(OUT_ROOT, "trigger-delivery-done"); const PAIRS: Record< string, { label: string; pickup: { street: string; city: string; state: string }; delivery: { street: string; city: string; state: string }; } > = { "99": { label: "Key West FL → Los Angeles CA(Duval / Hollywood)", pickup: { street: "Duval Street", city: "Key West", state: "FL" }, delivery: { street: "Hollywood Boulevard", city: "Los Angeles", state: "CA", }, }, "01": { label: "洛杉矶 CA → Farmers Branch TX", pickup: { street: "1234 Warehouse Street", city: "Los Angeles", state: "CA" }, delivery: { street: "5678 Distribution Way", city: "Farmers Branch", state: "TX" }, }, }; const PAIR_ID = process.env.COMPARE_PAIR_ID ?? "99"; const MODE = (process.env.COMPARE_MODE ?? "both").toLowerCase(); function draftAddress(d: { street: string; city: string; state: string }) { const street = d.street.trim(); const city = d.city.trim(); const state = d.state.trim(); return { street, city, state, zip: "", place_id: `draft_${street}_${city}_${state}`.replace(/\s+/g, "_").slice(0, 64), formatted_address: `${street}, ${city}, ${state}`, selected_from_suggestions: true, }; } function toQuoteRequest( pickup: QuoteRequest["pickup"], delivery: QuoteRequest["delivery"], ): QuoteRequest { return { cargoHash: "event-compare", pickup, delivery, weightLb: 503, dimsIn: { l: 48, w: 40, h: 48 }, palletCount: 2, cargoType: "general_freight", }; } async function fetchConfirmedAddresses(pairId: string): Promise<{ pair: (typeof PAIRS)[string]; pickup: QuoteRequest["pickup"]; delivery: QuoteRequest["delivery"]; pickupLabel: string; deliveryLabel: string; }> { const pair = PAIRS[pairId]; if (!pair) { throw new Error(`未知 COMPARE_PAIR_ID=${pairId}`); } const userPickup = draftAddress(pair.pickup); const userDelivery = draftAddress(pair.delivery); const candRes = await hostFetchMothershipCandidates( BASE, TOKEN, CUSTOMER_ID, userPickup, userDelivery, ); if (candRes.code !== 0 || !candRes.data) { throw new Error(`候选 API 失败: ${candRes.message}`); } const selectedPickup = candRes.data.pickup_candidates[0]; const selectedDelivery = candRes.data.delivery_candidates[0]; if (!selectedPickup || !selectedDelivery) { throw new Error("候选不足"); } const pickupApplied = applyMothershipCandidate(userPickup, selectedPickup); const deliveryApplied = applyMothershipCandidate(userDelivery, selectedDelivery); return { pair, pickupLabel: selectedPickup.display_label, deliveryLabel: selectedDelivery.display_label, pickup: { street: pickupApplied.street, city: pickupApplied.city, state: pickupApplied.state, zip: pickupApplied.zip ?? "", placeId: userPickup.place_id, formattedAddress: pickupApplied.formatted_address, selectedFromSuggestions: true, mothershipOptionId: pickupApplied.mothership_option_id ?? "", mothershipDisplayLabel: pickupApplied.mothership_display_label ?? "", selectedFromMothership: true, }, delivery: { street: deliveryApplied.street, city: deliveryApplied.city, state: deliveryApplied.state, zip: deliveryApplied.zip ?? "", placeId: userDelivery.place_id, formattedAddress: deliveryApplied.formatted_address, selectedFromSuggestions: true, mothershipOptionId: deliveryApplied.mothership_option_id ?? "", mothershipDisplayLabel: deliveryApplied.mothership_display_label ?? "", selectedFromMothership: true, }, }; } async function waitForCheckpoint(label: string, triggerPath: string): Promise { const useFile = process.env.COMPARE_USE_FILE_TRIGGER === "1" || !process.stdin.isTTY; console.log(`\n── ${label} ──`); if (useFile) { if (existsSync(triggerPath)) { rmSync(triggerPath, { force: true }); } console.log( `文件触发:完成后执行\n New-Item -Force ${triggerPath.replace(/\\/g, "\\\\")}`, ); while (!existsSync(triggerPath)) { await new Promise((r) => setTimeout(r, 400)); } rmSync(triggerPath, { force: true }); return; } const rl = readline.createInterface({ input, output, terminal: true }); await new Promise((resolve) => { rl.question("完成后按 Enter 继续… ", () => { rl.close(); resolve(); }); }); } async function runManualSession( runDir: string, pairId: string, ): Promise<{ events: ReturnType; harPath: string }> { const { pair, pickupLabel, deliveryLabel } = await fetchConfirmedAddresses(pairId); const entryUrl = getPrimaryMothershipQuoteUrl(); const harPath = join(runDir, "manual.har"); const tracer = new AxelEventTracer(); console.log(`\n=== 人工 headed 录证:组 ${pairId} ${pair.label} ===`); console.log(`入口:${entryUrl}`); console.log(` 操作说明(金样本:必须让脚本录到「点中了哪颗 DOM」): ① 提货框输入:${pair.pickup.street}, ${pair.pickup.city}, ${pair.pickup.state} (不要一上来打完整酒店名;与 RPA 区分,先打出街道级联想) ② 等联想列表出现 → 用鼠标点「${pickupLabel}」 或键盘:↓ 移到目标 → 浏览器里 Enter(不是终端 Enter) ③ 确认提货锁定后,终端检查点 pickup 按 Enter ④ 派送框输入:${pair.delivery.street}, ${pair.delivery.city}, ${pair.delivery.state} ⑤ 点选「${deliveryLabel}」或 ↓+Enter ⑥ 终端检查点 delivery 按 Enter 终端 Enter 仅用于检查点;浏览器 Enter 才用于选联想。 录证成功标志:检查点日志出现 selectionClicks>=1 或 selectionKeys 含 Enter@INPUT `); const browser = await chromium.launch({ headless: false, channel: process.env.RPA_BROWSER_CHANNEL as "chrome" | "msedge" | undefined, slowMo: Number(process.env.RPA_SLOW_MO_MS ?? 80) || 80, }); const context = await browser.newContext({ locale: process.env.RPA_BROWSER_LOCALE?.trim() || "en-US", recordHar: { path: harPath, mode: "full", content: "embed" }, }); const page = await context.newPage(); tracer.attach(page); page.setDefaultTimeout(120_000); tracer.setPhase("navigate"); await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: 90_000 }); await page.waitForTimeout(3_000); await installManualSelectionListeners(page); console.log( "[manual] 已注入 click/keydown 监听(capture 阶段);成功点选后检查点会导出 interaction 日志", ); tracer.setPhase("manual-pickup"); await waitForCheckpoint("检查点 pickup(人工点选完成)", TRIGGER_PICKUP); const pickupForensics = await recordManualCheckpoint(page, "manual-pickup-done"); logManualInteractionGoldSample("pickup", pickupForensics.interactions); const pickupGold = pickSelectionGoldInteractions(pickupForensics.interactions); writeFileSync( join(runDir, "manual-selection-pickup.json"), JSON.stringify({ ...pickupForensics, gold: pickupGold }, null, 2), "utf8", ); tracer.setPhase("manual-delivery"); await waitForCheckpoint("检查点 delivery(人工点选完成)", TRIGGER_DELIVERY); const deliveryForensics = await recordManualCheckpoint(page, "manual-delivery-done"); logManualInteractionGoldSample("delivery", deliveryForensics.interactions); const deliveryGold = pickSelectionGoldInteractions(deliveryForensics.interactions); writeFileSync( join(runDir, "manual-selection-delivery.json"), JSON.stringify({ ...deliveryForensics, gold: deliveryGold }, null, 2), "utf8", ); writeFileSync( join(runDir, "manual-interactions-gold.json"), JSON.stringify( { pairId, pickup: pickupGold, delivery: deliveryGold, }, null, 2, ), "utf8", ); await page .screenshot({ path: join(runDir, "manual-final.png") }) .catch(() => undefined); tracer.detach(); await context.close(); await browser.close(); if (!existsSync(harPath)) { throw new Error(`HAR 未生成: ${harPath}`); } const events = [...tracer.getEvents()]; writeFileSync( join(runDir, "manual-trace.json"), JSON.stringify( { mode: "manual", pairId, label: pair.label, pickupHint: pickupLabel, deliveryHint: deliveryLabel, events, sequence: normalizeAxelSequence(events), harPath, }, null, 2, ), "utf8", ); console.log(`[manual] HAR: ${harPath}`); console.log(`[manual] trace: ${join(runDir, "manual-trace.json")}`); console.log(`[manual] gold: ${join(runDir, "manual-interactions-gold.json")}`); console.log(`[manual] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId)); return { events, harPath }; } function writeSelectionTraceArtifact(runDir: string, mode: string): void { const traces = drainSelectionTraces(); if (!traces.length) { return; } const outPath = join(runDir, `${mode}-selection-trace.json`); writeFileSync(outPath, JSON.stringify({ mode, traces }, null, 2), "utf8"); console.log(`[${mode}] selection-trace: ${outPath} (${traces.length} 条快照)`); } async function runRpaSession( runDir: string, pairId: string, ): Promise> { const { pair, pickup, delivery } = await fetchConfirmedAddresses(pairId); const tracer = new AxelEventTracer(); console.log(`\n=== RPA 生产路径录证:组 ${pairId} ${pair.label} ===`); console.log(`fillAddressStep(与 Worker 询价相同)`); process.env.RPA_HEADLESS = process.env.COMPARE_RPA_HEADED === "true" ? "false" : "true"; const opened = await openQuotePage(quotePageAdapter, false, undefined, { freshCargoContext: true, }); const ctx = RPAContext.fromSession(opened.page, opened.context); tracer.attach(ctx.page); await stabilizeQuoteLandingPage(ctx); tracer.setPhase("hydrate"); await waitForQuoterWidgetHydrated(ctx.page, 60_000); tracer.setPhase("rpa-fillAddress"); try { await fillAddressStep(ctx, toQuoteRequest(pickup, delivery)); } catch (error) { writeSelectionTraceArtifact(runDir, "rpa"); const events = [...tracer.getEvents()]; writeFileSync( join(runDir, "rpa-trace.json"), JSON.stringify( { mode: "rpa", pairId, label: pair.label, error: error instanceof Error ? error.message : String(error), events, sequence: normalizeAxelSequence(events), }, null, 2, ), "utf8", ); await closeBrowser().catch(() => undefined); throw error; } writeSelectionTraceArtifact(runDir, "rpa"); const events = [...tracer.getEvents()]; writeFileSync( join(runDir, "rpa-trace.json"), JSON.stringify( { mode: "rpa", pairId, label: pair.label, events, sequence: normalizeAxelSequence(events), }, null, 2, ), "utf8", ); await ctx.page .screenshot({ path: join(runDir, "rpa-final.png") }) .catch(() => undefined); tracer.detach(); await closeBrowser(); console.log(`[rpa] trace: ${join(runDir, "rpa-trace.json")}`); console.log(`[rpa] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId)); return events; } async function main(): Promise { ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureRedisReady(12, 1_000); const res = await fetch(BASE, { method: "GET", redirect: "manual" }); if (res.status <= 0) { throw new Error("Next 未响应,请先 npm run dev:start"); } if (!(await isAnyRpaWorkerHealthy())) { throw new Error("RPA Worker 无心跳"); } const runId = randomUUID().slice(0, 8); const runDir = join(OUT_ROOT, `${PAIR_ID}-${runId}`); mkdirSync(runDir, { recursive: true }); mkdirSync(OUT_ROOT, { recursive: true }); console.log(`录证目录:${runDir}`); console.log(`COMPARE_MODE=${MODE} COMPARE_PAIR_ID=${PAIR_ID}`); let manualEvents: ReturnType | null = null; let rpaEvents: ReturnType | null = null; if (MODE === "manual" || MODE === "both") { const manual = await runManualSession(runDir, PAIR_ID); manualEvents = [...manual.events]; } if (MODE === "rpa" || MODE === "both") { try { rpaEvents = [...(await runRpaSession(runDir, PAIR_ID))]; } catch (error) { console.error("[rpa] fillAddress 失败(trace 已写入):", error); if (MODE === "rpa") { process.exitCode = 1; } } } if (manualEvents && rpaEvents) { const diff = diffAxelTraces(manualEvents, rpaEvents); const comparePath = join(runDir, "compare.json"); writeFileSync( comparePath, JSON.stringify( { runId, pairId: PAIR_ID, finishedAt: new Date().toISOString(), ...diff, interpretation: interpretDiff(diff), }, null, 2, ), "utf8", ); console.log(`\n对照报告:${comparePath}`); console.log(JSON.stringify(interpretDiff(diff), null, 2)); } const summaryPath = join(runDir, "README.txt"); writeFileSync( summaryPath, [ `runId=${runId}`, `pair=${PAIR_ID}`, `mode=${MODE}`, "", "产物:", "- manual.har + manual-trace.json(人工)", "- rpa-trace.json(RPA fillAddressStep)", "- compare.json(both 模式)", "", "下一步:仅实现 compare.json 中 missingInRpa 对应的事件,禁止 patch click/delay/retry。", ].join("\n"), "utf8", ); } function interpretDiff( diff: ReturnType, ): Record { return { verdict: diff.verdict, manualPlaceCommits: diff.manualPlaceCommits, rpaPlaceCommits: diff.rpaPlaceCommits, missingInRpaCount: diff.missingInRpa.length, missingInRpa: diff.missingInRpa.slice(0, 20), extraInRpa: diff.extraInRpa.slice(0, 20), nextAction: diff.verdict === "RPA_MISSING_PLACE_COMMIT" ? "RPA 未触发 GET place/{id};对照 manual trace 中 pickup/delivery 的 place 事件,补缺失 selection 事件" : diff.verdict === "MANUAL_NO_PLACE_COMMIT" ? "人工录证未捕获 place commit,请重跑 manual 并确认真实点选联想项" : "查看 missingInRpa 逐行 diff", }; } main().catch((error) => { console.error(error); process.exit(1); });