|
|
#!/usr/bin/env tsx
|
|
|
/**
|
|
|
* 真人 vs RPA 网络差分(同一地址对,不新增 fix-place 策略)
|
|
|
*
|
|
|
* 默认对齐录证 5519e0ea:293 Pike St Seattle → 826 Ocean Dr Miami Beach
|
|
|
*
|
|
|
* 用法:
|
|
|
* npm run compare:human-rpa-network
|
|
|
* $env:HUMAN_RUN_ID="5519e0ea"; $env:RPA_HEADLESS="false"; npm run compare:human-rpa-network
|
|
|
*/
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import { loadDevEnv } from "@/lib/dev-env";
|
|
|
import { shouldKeepBrowserOpen } from "@/lib/rpa/env";
|
|
|
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
|
|
|
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
|
|
|
import { MotherShipAddressAdapter } from "@/workers/rpa/address-adapter/mothership-address-adapter";
|
|
|
import { openAddressSide } from "@/workers/rpa/address-side";
|
|
|
import { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
|
|
|
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
|
|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import {
|
|
|
diffHumanRpaNetwork,
|
|
|
loadNetworkNdjson,
|
|
|
} from "./human-commit-baseline/network-diff";
|
|
|
import { NetworkLogger } from "./human-commit-baseline/network-logger";
|
|
|
|
|
|
process.env.RPA_MOCK_MODE = "false";
|
|
|
process.env.RPA_ADDRESS_MODE = "real";
|
|
|
|
|
|
loadDevEnv();
|
|
|
|
|
|
const HUMAN_RUN_ID = process.env.HUMAN_RUN_ID ?? "5519e0ea";
|
|
|
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
|
|
|
const TOKEN = "demo-host-token";
|
|
|
const CUSTOMER_ID = "CUST_001";
|
|
|
|
|
|
/** 与真人录证 5519e0ea 一致的用户输入(候选 API 会解析为具体 display_label) */
|
|
|
const BASELINE_PAIR = {
|
|
|
label: "5519e0ea 基准:Seattle Pike St → Miami Beach Ocean Dr",
|
|
|
pickup: { street: "293 Pike St", city: "Seattle", state: "WA" },
|
|
|
delivery: { street: "826 Ocean Dr", city: "Miami Beach", state: "FL" },
|
|
|
};
|
|
|
|
|
|
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,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function selectSide(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
address: QuoteRequest["pickup"],
|
|
|
logger: NetworkLogger,
|
|
|
pickupAnchor?: QuoteRequest["pickup"],
|
|
|
): Promise<{ ok: boolean; error?: string; commitState?: string }> {
|
|
|
const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET");
|
|
|
logger.setCheckpoint(side === "pickup" ? "rpa_pickup" : "rpa_delivery");
|
|
|
|
|
|
const street = await openAddressSide(ctx, side);
|
|
|
if (!street) {
|
|
|
return { ok: false, error: `${side} 输入框不可见` };
|
|
|
}
|
|
|
|
|
|
const adapter = new MotherShipAddressAdapter();
|
|
|
try {
|
|
|
await adapter.selectAddress({
|
|
|
ctx,
|
|
|
side,
|
|
|
address,
|
|
|
street,
|
|
|
widgetSelector,
|
|
|
pickupAnchor: side === "delivery" ? pickupAnchor : undefined,
|
|
|
});
|
|
|
await adapter.waitForCommit();
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: e instanceof Error ? e.message : String(e),
|
|
|
commitState: adapter.getCommitState(),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const diag = adapter.getDiagnostics();
|
|
|
return {
|
|
|
ok: diag.placeRequestObserved && adapter.getCommitState() === "COMMITTED",
|
|
|
commitState: adapter.getCommitState(),
|
|
|
error: diag.placeRequestObserved ? undefined : "placeRequestObserved=false",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
const humanNetworkPath = join(
|
|
|
process.cwd(),
|
|
|
".rpa",
|
|
|
"human-commit-baseline",
|
|
|
HUMAN_RUN_ID,
|
|
|
"network.ndjson",
|
|
|
);
|
|
|
if (!existsSync(humanNetworkPath)) {
|
|
|
throw new Error(`找不到真人 network: ${humanNetworkPath}`);
|
|
|
}
|
|
|
|
|
|
const humanEvents = loadNetworkNdjson(humanNetworkPath);
|
|
|
const rpaRunId = randomUUID().slice(0, 8);
|
|
|
const outDir = join(
|
|
|
process.cwd(),
|
|
|
".rpa",
|
|
|
"human-commit-baseline",
|
|
|
HUMAN_RUN_ID,
|
|
|
`rpa-diff-${rpaRunId}`,
|
|
|
);
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
const rpaNetworkPath = join(outDir, "rpa-network.ndjson");
|
|
|
|
|
|
console.log(`[diff] 真人基准: ${HUMAN_RUN_ID}`);
|
|
|
console.log(`[diff] RPA 录网: ${BASELINE_PAIR.label}`);
|
|
|
console.log(`[diff] 产出: ${outDir}`);
|
|
|
|
|
|
const userPickup = draftAddress(BASELINE_PAIR.pickup);
|
|
|
const userDelivery = draftAddress(BASELINE_PAIR.delivery);
|
|
|
|
|
|
const cand = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
userPickup,
|
|
|
userDelivery,
|
|
|
);
|
|
|
if (cand.code !== 0 || !cand.data?.pickup_candidates[0] || !cand.data.delivery_candidates[0]) {
|
|
|
throw new Error(cand.message ?? "候选 API 失败");
|
|
|
}
|
|
|
|
|
|
const pc = cand.data.pickup_candidates[0];
|
|
|
const dc = cand.data.delivery_candidates[0];
|
|
|
const pickupApplied = applyMothershipCandidate(userPickup, pc);
|
|
|
const deliveryApplied = applyMothershipCandidate(userDelivery, dc);
|
|
|
|
|
|
const pickup: QuoteRequest["pickup"] = {
|
|
|
street: pickupApplied.street,
|
|
|
city: pickupApplied.city,
|
|
|
state: pickupApplied.state,
|
|
|
zip: pickupApplied.zip ?? "",
|
|
|
placeId: pc.option_id,
|
|
|
formattedAddress: pickupApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: pc.option_id,
|
|
|
mothershipDisplayLabel: pc.display_label,
|
|
|
selectedFromMothership: true,
|
|
|
};
|
|
|
const delivery: QuoteRequest["delivery"] = {
|
|
|
street: deliveryApplied.street,
|
|
|
city: deliveryApplied.city,
|
|
|
state: deliveryApplied.state,
|
|
|
zip: deliveryApplied.zip ?? "",
|
|
|
placeId: dc.option_id,
|
|
|
formattedAddress: deliveryApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: dc.option_id,
|
|
|
mothershipDisplayLabel: dc.display_label,
|
|
|
selectedFromMothership: true,
|
|
|
};
|
|
|
|
|
|
console.log(`[diff] 确认提货: ${pc.display_label}`);
|
|
|
console.log(`[diff] 确认派送: ${dc.display_label}`);
|
|
|
|
|
|
const logger = new NetworkLogger(rpaNetworkPath);
|
|
|
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
|
|
|
freshCargoContext: true,
|
|
|
});
|
|
|
logger.setCheckpoint("rpa_page_loaded");
|
|
|
logger.attach(opened.page);
|
|
|
|
|
|
const ctx = RPAContext.fromSession(opened.page, opened.context);
|
|
|
console.log("[diff] 页面已打开,正在处理 Cookie 并滚动到报价区…");
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
console.log(
|
|
|
"[diff] 等待 MotherShip 报价 widget 加载(含地图脚本,界面可能数十秒无输入,属正常)…",
|
|
|
);
|
|
|
const hydration = await waitForQuoterWidgetHydrated(ctx.page, 90_000);
|
|
|
if (hydration.hydrated) {
|
|
|
console.log(
|
|
|
`[diff] widget 已就绪(可编辑地址框 ${hydration.editableSearchInputs} 个)`,
|
|
|
);
|
|
|
} else {
|
|
|
console.warn(
|
|
|
`[diff] widget 未完全就绪,继续尝试:${hydration.widgetTextPreview.slice(0, 80)}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
console.log("[diff] 开始填写提货地址…");
|
|
|
const rpaResults: Record<string, unknown> = {};
|
|
|
|
|
|
const pickupResult = await selectSide(ctx, "pickup", pickup, logger);
|
|
|
rpaResults.pickup = pickupResult;
|
|
|
console.log("[diff] RPA pickup:", pickupResult);
|
|
|
|
|
|
let deliveryResult: Awaited<ReturnType<typeof selectSide>> | null = null;
|
|
|
if (pickupResult.ok) {
|
|
|
console.log("[diff] 提货完成,开始填写派送地址…");
|
|
|
deliveryResult = await selectSide(ctx, "delivery", delivery, logger, pickup);
|
|
|
rpaResults.delivery = deliveryResult;
|
|
|
console.log("[diff] RPA delivery:", deliveryResult);
|
|
|
} else {
|
|
|
rpaResults.delivery = { skipped: true, reason: "pickup failed" };
|
|
|
}
|
|
|
|
|
|
logger.flush();
|
|
|
|
|
|
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 0);
|
|
|
if (dwellMs > 0) {
|
|
|
console.log(`[diff] 完成后停留 ${dwellMs}ms 便于观察…`);
|
|
|
await opened.page.waitForTimeout(dwellMs).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
if (!shouldKeepBrowserOpen()) {
|
|
|
await opened.page.close().catch(() => undefined);
|
|
|
await opened.context.close().catch(() => undefined);
|
|
|
await closeBrowser();
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[diff] 浏览器保持打开(RPA_HEADLESS=false 默认行为;关闭窗口或 Ctrl+C 结束)",
|
|
|
);
|
|
|
}
|
|
|
const rpaEvents = loadNetworkNdjson(rpaNetworkPath);
|
|
|
const diff = diffHumanRpaNetwork(humanEvents, rpaEvents, HUMAN_RUN_ID, rpaRunId);
|
|
|
|
|
|
const humanPlaceIds = readFileSync(
|
|
|
join(process.cwd(), ".rpa", "human-commit-baseline", HUMAN_RUN_ID, "commit-moments.json"),
|
|
|
"utf8",
|
|
|
);
|
|
|
const report = {
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
humanRunId: HUMAN_RUN_ID,
|
|
|
rpaRunId,
|
|
|
baselinePair: BASELINE_PAIR,
|
|
|
confirmedPickup: pc.display_label,
|
|
|
confirmedDelivery: dc.display_label,
|
|
|
humanCommitMoments: JSON.parse(humanPlaceIds),
|
|
|
rpaSelectResults: rpaResults,
|
|
|
diff,
|
|
|
artifacts: {
|
|
|
humanNetwork: humanNetworkPath,
|
|
|
rpaNetwork: rpaNetworkPath,
|
|
|
report: join(outDir, "diff-report.json"),
|
|
|
},
|
|
|
};
|
|
|
|
|
|
writeFileSync(
|
|
|
join(outDir, "diff-report.json"),
|
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
console.log("\n════════ 差分摘要 ════════");
|
|
|
console.log(`verdict: ${diff.verdict}`);
|
|
|
console.log(
|
|
|
`place GET 200 — 真人: ${diff.human.placeGet200} | RPA: ${diff.rpa.placeGet200}`,
|
|
|
);
|
|
|
console.log(
|
|
|
`search→place 完整链 — 真人: ${diff.human.chains.filter((c) => c.complete).length}/${diff.human.chains.length} | RPA: ${diff.rpa.chains.filter((c) => c.complete).length}/${diff.rpa.chains.length}`,
|
|
|
);
|
|
|
if (diff.gaps.length) {
|
|
|
console.log("\n差异:");
|
|
|
for (const g of diff.gaps) {
|
|
|
console.log(` - ${g}`);
|
|
|
}
|
|
|
}
|
|
|
if (diff.recommendations.length) {
|
|
|
console.log("\n建议:");
|
|
|
for (const r of diff.recommendations) {
|
|
|
console.log(` → ${r}`);
|
|
|
}
|
|
|
}
|
|
|
console.log(`\n完整报告: ${join(outDir, "diff-report.json")}`);
|
|
|
|
|
|
const exitCode = diff.verdict === "MATCH" ? 0 : 1;
|
|
|
if (!shouldKeepBrowserOpen()) {
|
|
|
process.exit(exitCode);
|
|
|
}
|
|
|
|
|
|
console.log(
|
|
|
"[diff] 填址已完成。按 Ctrl+C 结束会话(headed 模式默认保持浏览器窗口)",
|
|
|
);
|
|
|
await new Promise<void>((resolve) => {
|
|
|
const done = () => resolve();
|
|
|
process.once("SIGINT", done);
|
|
|
process.once("SIGTERM", done);
|
|
|
});
|
|
|
await opened.page.close().catch(() => undefined);
|
|
|
await opened.context.close().catch(() => undefined);
|
|
|
await closeBrowser();
|
|
|
process.exit(exitCode);
|
|
|
}
|
|
|
|
|
|
main().catch((e) => {
|
|
|
console.error(e);
|
|
|
process.exit(1);
|
|
|
});
|