|
|
/**
|
|
|
* 双地址对照录证(仅观测,不改 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<ReturnType<typeof captureAddressCommitSnapshot>>;
|
|
|
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<typeof draftAddress>,
|
|
|
candidate: ReturnType<typeof applyMothershipCandidate>,
|
|
|
): 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<void> {
|
|
|
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<Checkpoint> {
|
|
|
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<Record<string, unknown>> {
|
|
|
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<void> {
|
|
|
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<string, unknown>[] = [];
|
|
|
|
|
|
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<string, unknown>[],
|
|
|
): Record<string, unknown> {
|
|
|
const summaries = results
|
|
|
.map((r) => ({
|
|
|
pairId: r.pairId,
|
|
|
error: r.error,
|
|
|
summary: r.summary as Record<string, unknown> | 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);
|
|
|
});
|