/** * 双地址对照录证(仅观测,不改 workers/rpa 业务逻辑) * * 对每组美国街道地址:开全新页 → 填 pickup → 快照 → 填 delivery → 快照 * 记录 widget 输入、全文、下拉状态、axel/location 网络事件 * * 用法:npx tsx scripts/record-dual-address-compare.ts * 可选:COMPARE_PAIR_IDS=01,02,06 */ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; 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 type { QuoteRequestBody } from "@/lib/frontend/types"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import { ensureRedisReady, resetRedisClient } from "@/lib/redis"; import type { Page } from "playwright"; import type { QuoteRequest } from "@/modules/providers/quote-provider"; import { openAddressSide, visibleStreetInput } from "@/workers/rpa/address-side"; import { clickSuggestionByLabel, ensureAddressSuggestions, findAddressSuggestionLocator, isAddressSuggestionDropdownOpen, resolveDomSuggestionLabel, } from "@/workers/rpa/address-suggestions"; import { captureAddressCommitSnapshot, isAddressPresentInWidget, readWidgetCombinedText, } from "@/workers/rpa/address-commit"; import { RPAContext } from "@/workers/rpa/kernel/context"; import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager"; import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter"; 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_DIR = join(process.cwd(), ".rpa"); const PAIRS: Record< string, { label: string; pickup: { street: string; city: string; state: string }; delivery: { street: string; city: string; state: string }; } > = { "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" }, }, "02": { label: "西雅图 WA → 迈阿密 FL", pickup: { street: "3131 Western Ave", city: "Seattle", state: "WA" }, delivery: { street: "7000 NW 52nd St", city: "Miami", state: "FL" }, }, "06": { label: "丹佛 CO → 夏洛特 NC", pickup: { street: "4800 Race St", city: "Denver", state: "CO" }, delivery: { street: "9300 South Blvd", city: "Charlotte", state: "NC" }, }, "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" }, }, }; type NetworkHit = { phase: string; url: string; status: number }; type Checkpoint = { phase: string; snapshot: Awaited>; pickupInWidget: boolean; deliveryInWidget: boolean; surface: string; fullWidget: string; networkSinceLast: NetworkHit[]; }; 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 toAddressFields( base: ReturnType, candidate: ReturnType, ): QuoteRequest["pickup"] { return { street: candidate.street, city: candidate.city, state: candidate.state, zip: candidate.zip ?? "", placeId: base.place_id, formattedAddress: candidate.formatted_address, selectedFromSuggestions: true, mothershipOptionId: candidate.mothership_option_id ?? "", mothershipDisplayLabel: candidate.mothership_display_label ?? "", selectedFromMothership: true, }; } function attachNetworkLogger(page: Page, bucket: NetworkHit[], phaseRef: { current: string }) { page.on("response", (response) => { const url = response.url(); if (!url.includes("services.mothership.com/axel/location")) { return; } bucket.push({ phase: phaseRef.current, url, status: response.status(), }); }); } async function selectSide( ctx: RPAContext, side: "pickup" | "delivery", address: QuoteRequest["pickup"], widgetSelector: string, ): Promise { const page = ctx.page; const label = address.mothershipDisplayLabel?.trim(); if (!label) { throw new Error(`${side} 缺少 mothershipDisplayLabel`); } await openAddressSide(ctx, side); const street = await visibleStreetInput(ctx, side, { deliverySectionOpened: side === "delivery", }); if (!street) { throw new Error(`${side} 街道输入框不可见`); } const queryHint = { street: address.street, city: address.city ?? "", state: address.state ?? "", zip: address.zip ?? "", }; await ensureAddressSuggestions(page, street, queryHint, { mothershipLabel: label, }); const resolved = (await resolveDomSuggestionLabel(page, label, queryHint)) ?? label; const loc = await findAddressSuggestionLocator(page, resolved, queryHint); if (loc) { await loc.click(); } else { const clicked = await clickSuggestionByLabel(page, resolved, queryHint); if (!clicked) { throw new Error(`${side} 联想点选失败: ${label}`); } } await page.keyboard.press("Enter").catch(() => undefined); await page.waitForTimeout(400); } async function takeCheckpoint( ctx: RPAContext, widgetSelector: string, phase: string, pickup: QuoteRequest["pickup"], delivery: QuoteRequest["pickup"], networkDrain: NetworkHit[], ): Promise { const drained = networkDrain.splice(0, networkDrain.length); const snapshot = await captureAddressCommitSnapshot( ctx, phase.includes("delivery") ? "delivery" : "pickup", widgetSelector, ); const surface = snapshot.surfaceText || snapshot.inputValues.join(" "); const fullWidget = await readWidgetCombinedText(ctx.page, widgetSelector); return { phase, snapshot, pickupInWidget: await isAddressPresentInWidget( ctx.page, widgetSelector, pickup, ), deliveryInWidget: await isAddressPresentInWidget( ctx.page, widgetSelector, delivery, ), surface, fullWidget: fullWidget.slice(0, 400), networkSinceLast: drained, }; } async function recordPair(pairId: string): Promise> { const pair = PAIRS[pairId]; if (!pair) { throw new Error(`未知组 ${pairId}`); } console.log(`\n=== 录证组 ${pairId}: ${pair.label} ===`); 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 pickup = toAddressFields( userPickup, applyMothershipCandidate(userPickup, selectedPickup), ); const delivery = toAddressFields( userDelivery, applyMothershipCandidate(userDelivery, selectedDelivery), ); const opened = await openQuotePage(quotePageAdapter, false, undefined, { freshCargoContext: true, }); const ctx = RPAContext.fromSession(opened.page, opened.context); const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET"); const networkHits: NetworkHit[] = []; const phaseRef = { current: "init" }; attachNetworkLogger(ctx.page, networkHits, phaseRef); await stabilizeQuoteLandingPage(ctx); await waitForQuoterWidgetHydrated(ctx.page, 60_000); const checkpoints: Checkpoint[] = []; phaseRef.current = "initial"; checkpoints.push( await takeCheckpoint(ctx, widgetSelector, "initial", pickup, delivery, networkHits), ); phaseRef.current = "pickup-select"; await selectSide(ctx, "pickup", pickup, widgetSelector); checkpoints.push( await takeCheckpoint( ctx, widgetSelector, "after-pickup-select", pickup, delivery, networkHits, ), ); phaseRef.current = "pre-delivery"; await ctx.page.keyboard.press("Escape").catch(() => undefined); await openAddressSide(ctx, "delivery"); checkpoints.push( await takeCheckpoint( ctx, widgetSelector, "after-open-delivery-side", pickup, delivery, networkHits, ), ); phaseRef.current = "delivery-select"; await selectSide(ctx, "delivery", delivery, widgetSelector); checkpoints.push( await takeCheckpoint( ctx, widgetSelector, "after-delivery-select", pickup, delivery, networkHits, ), ); const finalSurface = checkpoints.at(-1)?.surface ?? ""; const finalFull = await readWidgetCombinedText(ctx.page, widgetSelector); const dropdownOpen = await isAddressSuggestionDropdownOpen(ctx.page); const pickupLostAfterDelivery = checkpoints.find((c) => c.phase === "after-pickup-select")?.pickupInWidget === true && checkpoints.find((c) => c.phase === "after-delivery-select")?.pickupInWidget === false; const placeApiHits = networkHits.filter((h) => h.url.includes("/axel/location/place/"), ); const pickupPlaceAfterSelect = placeApiHits.some( (h) => h.phase === "pickup-select" || h.phase === "after-pickup-select", ); await ctx.page .screenshot({ path: join(OUT_DIR, `compare-${pairId}-final.png`), fullPage: false, }) .catch(() => undefined); await closeBrowser(); return { pairId, label: pair.label, confirmedPickup: selectedPickup.display_label, confirmedDelivery: selectedDelivery.display_label, checkpoints, summary: { pickupPresentAfterPickup: checkpoints.find( (c) => c.phase === "after-pickup-select", )?.pickupInWidget, pickupPresentAfterOpenDelivery: checkpoints.find( (c) => c.phase === "after-open-delivery-side", )?.pickupInWidget, pickupPresentAfterDelivery: checkpoints.find( (c) => c.phase === "after-delivery-select", )?.pickupInWidget, deliveryPresentAfterDelivery: checkpoints.find( (c) => c.phase === "after-delivery-select", )?.deliveryInWidget, pickupLostAfterDelivery, dropdownOpenAtEnd: dropdownOpen, hasDefaultLosAngeles: /Los Angeles,\s*CA/i.test(finalFull), pickupPlaceApiAfterPickupSelect: pickupPlaceAfterSelect, placeApiHitCount: placeApiHits.length, allNetworkHits: networkHits, }, finalSurface, finalFullPreview: finalFull.slice(0, 300), }; } 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 未响应"); } if (!(await isAnyRpaWorkerHealthy())) { throw new Error("RPA Worker 无心跳"); } const pairIds = (process.env.COMPARE_PAIR_IDS ?? "01,02,06") .split(",") .map((s) => s.trim()) .filter(Boolean); const runId = randomUUID().slice(0, 8); const results: Record[] = []; for (const pairId of pairIds) { try { results.push(await recordPair(pairId)); } catch (error) { results.push({ pairId, error: error instanceof Error ? error.message : String(error), }); await closeBrowser().catch(() => undefined); } } mkdirSync(OUT_DIR, { recursive: true }); const outPath = join(OUT_DIR, `dual-address-compare-${runId}.json`); writeFileSync( outPath, JSON.stringify( { runId, finishedAt: new Date().toISOString(), pairIds, results, interpretation: buildInterpretation(results), }, null, 2, ), "utf8", ); console.log(`\n录证报告:${outPath}`); console.log(JSON.stringify(buildInterpretation(results), null, 2)); } function buildInterpretation( results: Record[], ): Record { const summaries = results .map((r) => ({ pairId: r.pairId, error: r.error, summary: r.summary as Record | undefined, })) .filter((r) => r.summary || r.error); const passDual = summaries.filter( (s) => s.summary?.pickupPresentAfterDelivery === true && s.summary?.deliveryPresentAfterDelivery === true, ); const pickupLost = summaries.filter( (s) => s.summary?.pickupLostAfterDelivery === true, ); const neverLockedPickup = summaries.filter( (s) => s.summary?.pickupPresentAfterPickup === false || s.summary?.hasDefaultLosAngeles === true, ); let verdict: string; if (passDual.length === summaries.length && summaries.length > 0) { verdict = "ALL_PAIRS_DUAL_OK"; } else if (pickupLost.length > 0 && passDual.length > 0) { verdict = "PAIR_SPECIFIC_PICKUP_ROLLBACK"; } else if (pickupLost.length === summaries.length) { verdict = "ALL_PAIRS_PICKUP_ROLLBACK"; } else if (neverLockedPickup.length === summaries.length) { verdict = "PICKUP_NEVER_LOCKED"; } else { verdict = "MIXED_OR_INCONCLUSIVE"; } return { verdict, passDualPairIds: passDual.map((s) => s.pairId), pickupLostPairIds: pickupLost.map((s) => s.pairId), neverLockedPickupPairIds: neverLockedPickup.map((s) => s.pairId), errors: summaries.filter((s) => s.error).map((s) => ({ pairId: s.pairId, error: s.error, })), }; } main().catch((error) => { console.error(error); process.exit(1); });