You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/scripts/record-dual-address-compare.ts

482 lines
14 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 双地址对照录证(仅观测,不改 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 CADuval / 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);
});