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.

1327 lines
40 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.

import { RpaError } from "@/modules/rpa/errors";
import { pageLocator } from "@/lib/rpa/locator";
import {
captureSelectionTrace,
drainSelectionTraces,
formatSelectionTraceEvent,
logSelectionTrace,
} from "@/workers/rpa/selection-trace";
import {
AxelSelectionBridge,
formatPlaceCommitFailure,
normalizeAxelPlaceId,
} from "@/workers/rpa/axel-selection-bridge";
import { commitPlaceWithStrategy } from "@/workers/rpa/fix-place";
import {
clickMothershipStyledSuggestion,
reopenStyledSuggestionDropdown,
tryStyledKeyboardCommit,
waitForStyledSuggestionRows,
} from "@/workers/rpa/mothership-styled-suggestion-click";
import {
dismissCookieConsent,
scrollQuoteWidgetIntoView,
} from "@/workers/rpa/page-prep";
import { isRpaAddressRushMode, isRpaFastQuoteMode, isBlindAddressFlowActive, isDualAddressPatchEnabled, resolveAxelSearchWaitMs } from "@/lib/rpa/env";
import { commitViaAxelPlacePrefetch, fetchAxelPlaceLocation } from "@/workers/rpa/axel-place-prefetch";
import {
applyDeliveryDualAddressPatch,
applyPickupDualAddressPatch,
preservePickupInWidget,
releasePickupInputInWidget,
} from "@/workers/rpa/inject/dual-address-patch";
import {
dismissAddressSuggestionDropdown,
isAddressPresentInWidget,
isPickupReadyForDeliveryInput,
lockPickupIntoChip,
readWidgetCombinedText,
releasePickupFieldForDelivery,
widgetContainsConfirmedAddress,
} from "@/workers/rpa/address-commit";
import {
blindClickAddressSuggestion,
isAddressCommittedVisually,
mouseClickSuggestionUntilVisualConfirmed,
} from "@/workers/rpa/address-suggestion-mouse";
import {
buildAddressBindingLabels,
clickSuggestionByLabel,
ensureAddressSuggestions,
ensureSuggestionDropdownOpen,
resolveAddressAutocompleteInput,
selectAddressWithPlaceBinding,
} from "@/workers/rpa/address-suggestions";
import { AddressCommitStateMachine } from "@/workers/rpa/address-adapter/commit-state-machine";
import {
createEmptyDiagnostics,
buildNetworkDiagnostics,
formatDiagnosticsFailure,
logAddressSelectDiagnostics,
resolvePlaceFailureStatus,
} from "@/workers/rpa/address-adapter/diagnostics";
import {
isAddressSuggestionDropdownOpen,
waitForAddressSuggestionLocked,
} from "@/workers/rpa/address-suggestions";
import type {
AddressAdapter,
AddressCommitResult,
AddressSelectDiagnostics,
SelectAddressParams,
} from "@/workers/rpa/address-adapter/types";
export class MotherShipAddressAdapter implements AddressAdapter {
private readonly stateMachine = new AddressCommitStateMachine();
private bridge: AxelSelectionBridge | null = null;
private result: AddressCommitResult | null = null;
private diagnostics: AddressSelectDiagnostics = createEmptyDiagnostics("");
getCommitState() {
return this.stateMachine.getState();
}
getCommittedAddress(): AddressCommitResult | null {
return this.result;
}
getDiagnostics(): AddressSelectDiagnostics {
return {
...this.diagnostics,
commitState: this.stateMachine.getState(),
};
}
async selectAddress(params: SelectAddressParams): Promise<AddressCommitResult> {
const { ctx, side, address, street } = params;
const page = ctx.page;
const label = address.mothershipDisplayLabel?.trim();
if (!label) {
this.fail(
params,
"selection_failed",
"须在 MotherShip 多候选地址中由用户确认后再询价",
);
}
const queryHint = resolveAddressAutocompleteInput(
{
street: address.street,
city: address.city ?? "",
state: address.state ?? "",
zip: address.zip ?? "",
},
{ mothershipLabel: label! },
);
this.diagnostics = createEmptyDiagnostics(label!, "EMPTY");
this.diagnostics.selectionTraces = [];
this.stateMachine.transition("TYPING");
this.bridge = new AxelSelectionBridge();
this.bridge.attach(page);
this.bridge.clearCandidates();
this.diagnostics.events.push("bridge-attached");
try {
await dismissCookieConsent(page);
await scrollQuoteWidgetIntoView(page);
const isPreknown = Boolean(
params.address.selectedFromMothership &&
(params.address.mothershipOptionId ?? params.address.placeId)?.trim(),
);
await ensureAddressSuggestions(page, street, queryHint, {
mothershipLabel: label!,
preferConfirmedLabel: true,
maxQueries:
isPreknown || isRpaFastQuoteMode()
? 1
: params.side === "delivery" && params.pickupAnchor
? 1
: undefined,
});
this.diagnostics.events.push("suggestions-typed");
await this.recordSelectionTrace(page, params.side, "after-suggestions-typed");
if (!this.bridge.hasSearchResults()) {
this.fail(
params,
"selection_failed",
"axel/location/search 未返回 googlePlacesResults",
);
}
await this.bridge.waitForSearchResults(
resolveAxelSearchWaitMs(params.side, isPreknown),
);
this.stateMachine.transition("SUGGESTIONS_READY");
const preknownPlaceId = params.address.selectedFromMothership
? (params.address.mothershipOptionId ?? params.address.placeId)?.trim()
: "";
const preknownLabel =
params.address.mothershipDisplayLabel?.trim() || label!;
let binding = null;
if (preknownPlaceId) {
binding = {
label: preknownLabel,
placeId: preknownPlaceId,
description: preknownLabel,
};
this.diagnostics.events.push(
`binding-preknown:${preknownPlaceId.slice(0, 16)}`,
);
} else {
for (const probeLabel of buildAddressBindingLabels(queryHint, label!)) {
binding = this.bridge.resolveSelection(probeLabel, queryHint);
if (binding?.placeId?.trim()) {
break;
}
}
}
if (!binding?.placeId?.trim()) {
this.fail(
params,
"selection_failed",
`search 响应中未找到与确认项绑定的 placeId${label}`,
);
}
this.diagnostics.placeId = binding!.placeId;
this.diagnostics.address = label!;
this.diagnostics.events.push(
`binding-resolved:${binding!.placeId.slice(0, 16)}`,
);
await this.recordSelectionTrace(page, params.side, "after-binding-resolved");
console.log(
`[rpa] axel-bridge:${side} placeId=${binding!.placeId.slice(0, 16)}… desc=${binding!.description.slice(0, 48)}`,
);
this.stateMachine.transition("SELECTING");
this.diagnostics.method = "mothership-place-binding";
if (isBlindAddressFlowActive(params.ctx)) {
await blindClickAddressSuggestion(
page,
street,
params.address,
`${params.side}-blind`,
);
if (preknownPlaceId) {
const fetched = await fetchAxelPlaceLocation(page, binding!.placeId);
if (fetched.ok && fetched.location) {
const normalized = normalizeAxelPlaceId(binding!.placeId);
this.bridge!.setPlaceDetails(normalized, fetched.location);
this.bridge!.recordSyntheticPlaceCommit(normalized, fetched.status);
params.ctx.state.axelPlaceLocations[params.side] = fetched.location;
}
}
if (params.side === "pickup") {
await pageLocator(page, params.widgetSelector)
.getByText(/^Deliver to$/i)
.first()
.click({ force: true, timeout: 2_000 })
.catch(() => undefined);
await page.waitForTimeout(200);
}
return this.succeed(
params,
binding!.placeId,
binding!.description,
"blind-flow",
);
}
const rush = isRpaAddressRushMode();
// preknown + rushGET place 预热 + recording 点选(跳过 8 轮 query / styled
if (preknownPlaceId && rush) {
const fetched = await fetchAxelPlaceLocation(page, binding!.placeId);
if (fetched.ok && fetched.location) {
const normalized = normalizeAxelPlaceId(binding!.placeId);
this.bridge!.setPlaceDetails(normalized, fetched.location);
this.bridge!.recordSyntheticPlaceCommit(normalized, fetched.status);
params.ctx.state.axelPlaceLocations[params.side] = fetched.location;
this.diagnostics.events.push(
`preknown-rush-primed:${normalized.slice(0, 16)}`,
);
}
const rushMouseOk = await mouseClickSuggestionUntilVisualConfirmed(
page,
street,
params.widgetSelector,
params.address,
`${params.side}-rush-recording`,
{ reopenSuggestions: true },
);
if (rushMouseOk) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"preknown-rush-recording-visual",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"preknown-rush-recording",
);
}
const prefetch = await commitViaAxelPlacePrefetch(
page,
this.bridge!,
binding!.placeId,
binding!.description,
street,
{
query: queryHint,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
},
);
const commitWaitMs = isRpaFastQuoteMode() ? 1_500 : 4_000;
if (prefetch.ok) {
const committed = await this.bridge!.waitForPlaceCommit(
binding!.placeId,
commitWaitMs,
);
if (!committed && prefetch.location) {
const normalized = normalizeAxelPlaceId(binding!.placeId);
this.bridge!.recordSyntheticPlaceCommit(normalized, 200);
params.ctx.state.axelPlaceLocations[params.side] = prefetch.location;
}
if (committed || prefetch.location) {
const visualAfterPrefetch = await isAddressCommittedVisually(
page,
params.widgetSelector,
params.address,
street,
);
if (!visualAfterPrefetch) {
console.log(
`[rpa] ${params.side}: prefetch 无 chip 视觉确认,继续点选 fallback`,
);
} else {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"axel-place-prefetch-rush-early",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"axel-place-prefetch-rush-early",
);
}
}
}
}
const mouseVisualOk = await mouseClickSuggestionUntilVisualConfirmed(
page,
street,
params.widgetSelector,
params.address,
`${params.side}-mouse-suggestion`,
);
if (mouseVisualOk) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mouse-suggestion-visual",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mouse-suggestion-visual",
);
}
this.stateMachine.transition("PLACE_LOADING");
const deliveryFast =
params.side === "delivery" && Boolean(params.pickupAnchor?.placeId);
if (preknownPlaceId && !rush) {
const syntheticFirst = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown-first",
);
if (syntheticFirst) {
return syntheticFirst;
}
const deliveryPickupInfoEarly =
params.side === "delivery" && params.pickupAnchor?.placeId
? {
placeId: params.pickupAnchor.placeId,
description: params.pickupAnchor.mothershipDisplayLabel ?? "",
location:
params.ctx.state.axelPlaceLocations.pickup ??
this.bridge!.getPlaceDetails(params.pickupAnchor.placeId) ??
{},
}
: undefined;
const earlyFix = await commitPlaceWithStrategy(
page,
this.bridge!,
binding!.placeId,
binding!.description,
params.side,
deliveryPickupInfoEarly,
{ input: street, query: queryHint },
);
if (earlyFix.ok) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
earlyFix.method,
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
earlyFix.method,
);
}
console.log(
"[rpa] preknown-place: fix-place 未锁定,继续 styled 主路径",
);
const syntheticEarly = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown",
);
if (syntheticEarly) {
return syntheticEarly;
}
const keyboardPreknown = await tryStyledKeyboardCommit(
page,
street,
this.bridge!,
binding!.placeId,
this.bridge!.getCandidateIndex(binding!.placeId),
);
if (keyboardPreknown) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"preknown-keyboard",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"preknown-keyboard",
);
}
const finalSynthetic = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown-final",
);
if (finalSynthetic) {
return finalSynthetic;
}
this.fail(
params,
"selection_failed",
`preknown 地址 commit 失败(已跳过误点路径):${label}`,
);
}
const styledRowMs = deliveryFast ? 350 : 2_500;
const styledRowRetryMs = deliveryFast ? 1_000 : 8_000;
const styledRowFinalMs = deliveryFast ? 1_500 : 10_000;
let rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowMs,
);
if (!rowsReady) {
await ensureSuggestionDropdownOpen(
page,
street,
queryHint,
binding!.description,
);
rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowRetryMs,
);
}
if (!rowsReady && !deliveryFast) {
console.log("[rpa] styled-rows-miss: retry reopen before click");
await reopenStyledSuggestionDropdown(
page,
street,
queryHint,
binding!.description,
);
rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowFinalMs,
);
}
if (!rowsReady) {
console.log("[rpa] styled-rows-miss: dropdown still empty before click");
}
let styledClick: {
clicked: boolean;
reason?: string;
matchedText?: string;
method?: string;
};
if (preknownPlaceId && !rush) {
console.log(
"[rpa] preknown-place: 跳过 legacyOnly 鼠标点选,走 keyboard/prefetch",
);
styledClick = { clicked: false, reason: "preknown-skip-legacy-click" };
} else {
styledClick = await clickMothershipStyledSuggestion(
page,
binding!.description,
queryHint,
{
input: street,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
skipReopen: true,
side: params.side,
bridge: this.bridge!,
placeId: binding!.placeId,
legacyOnly: true,
fastClick: true,
placeCommitTimeoutMs: deliveryFast ? 2_000 : 8_000,
},
);
}
if (rush && styledClick.clicked) {
return this.succeed(
params,
binding!.placeId,
binding!.description,
styledClick.method ?? "mothership-styled-click-rush",
);
}
if (!styledClick.clicked) {
console.log(
`[rpa] mothership-styled-click: 未点选 reason=${styledClick.reason ?? "unknown"}`,
);
if (rush) {
const prefetch = await commitViaAxelPlacePrefetch(
page,
this.bridge!,
binding!.placeId,
binding!.description,
street,
{
query: queryHint,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
},
);
if (
prefetch.ok &&
(await this.bridge!.waitForPlaceCommit(binding!.placeId, 8_000))
) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"axel-place-prefetch-rush",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"axel-place-prefetch-rush",
);
}
const labelClicked = await clickSuggestionByLabel(
page,
binding!.description,
queryHint,
);
if (labelClicked) {
return this.succeed(
params,
binding!.placeId,
binding!.description,
"click-suggestion-by-label-rush",
);
}
this.fail(
params,
"selection_failed",
"visual-rush地址联想未点中",
);
}
await dismissCookieConsent(page);
await ensureSuggestionDropdownOpen(
page,
street,
queryHint,
binding!.description,
);
const keyboardCommitted = await tryStyledKeyboardCommit(
page,
street,
this.bridge!,
binding!.placeId,
this.bridge!.getCandidateIndex(binding!.placeId),
);
if (!keyboardCommitted) {
console.log("[rpa] mothership-styled-keyboard:miss");
// 「随便选一个」降级ArrowDown+Enter 后视觉确认
const anySelected = await selectFirstSuggestionVisual(
page,
street,
params.widgetSelector,
params.address,
);
if (anySelected) {
console.log("[rpa] any-suggestion-visual: 联想已点选并视觉确认,跳过 placeId 等待");
return this.succeed(
params,
binding!.placeId,
binding!.description,
"any-suggestion-visual",
);
}
const prefetch = await commitViaAxelPlacePrefetch(
page,
this.bridge!,
binding!.placeId,
binding!.description,
street,
{
query: queryHint,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
},
);
if (
prefetch.ok &&
(await this.bridge!.waitForPlaceCommit(binding!.placeId, 8_000))
) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"axel-place-prefetch",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"axel-place-prefetch",
);
}
}
if (keyboardCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-keyboard",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-keyboard",
);
}
const labelClicked = await clickSuggestionByLabel(
page,
binding!.description,
queryHint,
);
if (labelClicked) {
const labelCommitted = await this.bridge!.waitForPlaceCommit(
binding!.placeId,
8_000,
);
if (labelCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"click-suggestion-by-label",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"click-suggestion-by-label",
);
}
}
}
if (styledClick.clicked) {
const styledCommitted = await this.bridge!.waitForPlaceCommit(
binding!.placeId,
10_000,
);
if (styledCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-click",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-click",
);
}
console.log(
`[rpa] mothership-styled-click: place 请求未观测到,检查视觉状态…`,
);
// 视觉确认:无论是否 preknown只要 widget 中已出现地址文本即可放行
const allowVisualBypass = true;
if (allowVisualBypass) {
const visuallyConfirmed = await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
);
if (visuallyConfirmed) {
console.log(
`[rpa] mothership-styled-click: 视觉已确认「${params.address.mothershipDisplayLabel?.slice(0, 32)}」,跳过网络等待`,
);
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-click-visual",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-click-visual",
);
}
}
console.log(
`[rpa] mothership-styled-click: 视觉也未确认,降级 fix-place/legacy`,
);
}
const deliveryPickupInfo =
params.side === "delivery" && params.pickupAnchor?.placeId
? {
placeId: params.pickupAnchor.placeId,
description: params.pickupAnchor.mothershipDisplayLabel ?? "",
location:
params.ctx.state.axelPlaceLocations.pickup ??
this.bridge!.getPlaceDetails(params.pickupAnchor.placeId) ??
{},
}
: undefined;
const fixResult = await commitPlaceWithStrategy(
page,
this.bridge!,
binding!.placeId,
binding!.description,
params.side,
deliveryPickupInfo,
{ input: street, query: queryHint },
);
if (fixResult.ok) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
fixResult.method,
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
fixResult.method,
);
}
console.log(
`[rpa] fix-place: ${fixResult.method} 未通过 widget 锁定闸门,继续 legacy`,
);
try {
await selectAddressWithPlaceBinding(
page,
this.bridge,
binding!,
queryHint,
street,
{
side: params.side,
widgetSelector: params.widgetSelector,
pickupAnchor:
params.side === "delivery" ? params.pickupAnchor : undefined,
confirmedAddress: params.address,
ctx: params.ctx,
},
);
} catch (error) {
if (error instanceof RpaError) {
const diag = this.bridge.getPlaceCommitDiagnostics(binding!.placeId);
this.diagnostics.placeRequestObserved =
diag.placeNetworkLog.length > 0;
const status = error.message.includes("未发起 GET place 请求")
? "no_place_request"
: error.message.includes("placeId 不匹配")
? "place_id_mismatch"
: error.message.includes("HTTP")
? "place_http_error"
: "timeout";
this.fail(
params,
status,
formatDiagnosticsFailure({
...this.diagnostics,
status,
commitState: "FAILED",
}),
);
}
throw error;
}
if (this.bridge.hasPlaceCommit(binding!.placeId)) {
await this.assertWidgetLockedAfterCommit(params, binding!.placeId, "legacy");
return this.succeed(
params,
binding!.placeId,
binding!.description,
"legacy",
);
}
const diag = this.bridge.getPlaceCommitDiagnostics(binding!.placeId);
this.diagnostics.placeRequestObserved = diag.placeNetworkLog.length > 0;
const placeLog = diag.placeNetworkLog;
const acceptedIds = this.collectAcceptedPlaceIds(params, binding!.placeId);
const { httpOk: httpOkForAccepted } =
this.bridgeCommitForAcceptedIds(acceptedIds);
const status = resolvePlaceFailureStatus(
placeLog.length > 0,
placeLog.some((e) => e.status >= 200) && !httpOkForAccepted,
placeLog.some((e) => e.status >= 400),
true,
);
const reason = formatPlaceCommitFailure(binding!.placeId, [], diag);
console.log(`[rpa] axel-place-gate: ${reason}`);
const syntheticFallback = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"legacy-fallback",
);
if (syntheticFallback) {
return syntheticFallback;
}
this.fail(
params,
status,
formatDiagnosticsFailure({
...this.diagnostics,
status,
commitState: "FAILED",
}),
);
} finally {
this.bridge?.detach();
this.bridge = null;
}
return this.result!;
}
async waitForCommit(): Promise<AddressCommitResult> {
if (!this.result?.committed) {
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
formatDiagnosticsFailure(this.getDiagnostics()),
{ retryable: true },
);
}
return this.result;
}
private async trySyntheticPreknownCommit(
params: SelectAddressParams,
binding: { placeId: string; description: string },
street: import("playwright").Locator,
methodTag: string,
): Promise<AddressCommitResult | null> {
if (!params.address.selectedFromMothership) {
return null;
}
const preknown =
params.address.mothershipOptionId?.trim() ||
params.address.placeId?.trim();
if (!preknown) {
return null;
}
const fetched = await fetchAxelPlaceLocation(
params.ctx.page,
binding.placeId,
);
if (!fetched.ok || !fetched.location) {
return null;
}
const normalized = normalizeAxelPlaceId(binding.placeId);
this.bridge!.setPlaceDetails(normalized, fetched.location);
this.bridge!.recordSyntheticPlaceCommit(normalized, fetched.status);
params.ctx.state.axelPlaceLocations[params.side] = fetched.location;
if (isDualAddressPatchEnabled()) {
if (params.side === "pickup") {
await applyPickupDualAddressPatch(params.ctx.page, {
placeId: binding.placeId,
location: fetched.location,
description: binding.description,
});
await preservePickupInWidget(params.ctx.page);
await releasePickupInputInWidget(params.ctx.page);
await lockPickupIntoChip(
params.ctx,
params.widgetSelector,
params.street,
params.address,
);
await params.ctx.page.waitForTimeout(600);
await releasePickupFieldForDelivery(
params.ctx,
params.widgetSelector,
params.address,
params.street,
);
} else if (
params.side === "delivery" &&
params.pickupAnchor?.placeId &&
params.ctx.state.axelPlaceLocations.pickup
) {
await applyDeliveryDualAddressPatch(
params.ctx.page,
{
placeId: params.pickupAnchor.placeId,
location: params.ctx.state.axelPlaceLocations.pickup,
description:
params.pickupAnchor.mothershipDisplayLabel?.trim() ||
params.pickupAnchor.formattedAddress?.trim() ||
params.pickupAnchor.street,
},
{
placeId: binding.placeId,
location: fetched.location,
description: binding.description,
},
);
}
} else {
await street.fill(binding.description).catch(() => undefined);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.keyboard.press("Tab").catch(() => undefined);
await params.ctx.page.waitForTimeout(400);
}
console.log(
`[rpa] preknown-synthetic: GET place/${normalized.slice(0, 16)} 合成 commit`,
);
await this.assertWidgetLockedAfterCommit(
params,
binding.placeId,
`${methodTag}-synthetic`,
);
return this.succeed(
params,
binding.placeId,
binding.description,
`${methodTag}-synthetic`,
);
}
private collectAcceptedPlaceIds(
params: SelectAddressParams,
bindingPlaceId: string,
): string[] {
const ids = new Set<string>();
const add = (raw?: string) => {
const normalized = normalizeAxelPlaceId(raw?.trim() ?? "");
if (normalized) {
ids.add(normalized);
}
};
add(bindingPlaceId);
add(params.address.mothershipOptionId);
add(params.address.placeId);
for (const candidate of this.bridge?.getCandidates() ?? []) {
add(candidate.placeId);
}
return [...ids];
}
private bridgeCommitForAcceptedIds(acceptedIds: string[]): {
observed: boolean;
httpOk: boolean;
} {
const bridge = this.bridge;
if (!bridge || acceptedIds.length === 0) {
return { observed: false, httpOk: false };
}
return {
observed: acceptedIds.some((id) => bridge.hasPlaceCommit(id)),
httpOk: acceptedIds.some((id) =>
bridge.hasSuccessfulPlaceNetworkRequest(id),
),
};
}
private async assertWidgetLockedAfterCommit(
params: SelectAddressParams,
placeId: string,
fixMethod: string,
): Promise<void> {
if (isBlindAddressFlowActive(params.ctx)) {
return;
}
const rush = isRpaAddressRushMode();
if (rush && fixMethod.includes("-visual")) {
console.log(
`[rpa] place-commit: rush chip 视觉已确认,跳过网络/下拉闸门 (${fixMethod})`,
);
return;
}
const bridge = this.bridge;
const acceptedIds = this.collectAcceptedPlaceIds(params, placeId);
const networkDiag = bridge
? buildNetworkDiagnostics(
bridge,
placeId,
params.side === "delivery"
? params.pickupAnchor?.placeId
: undefined,
)
: null;
const commitGate = this.bridgeCommitForAcceptedIds(acceptedIds);
this.diagnostics.placeNetworkEvents = networkDiag?.placeNetworkEvents;
this.diagnostics.pickupLostAfterDelivery =
networkDiag?.pickupLostAfterDelivery;
this.diagnostics.placeRequestObserved = commitGate.observed;
const observedPlaceIds = networkDiag?.observedPlaceIds ?? [];
const placeRequestObserved = commitGate.observed;
const placeRequestHttpOk = commitGate.httpOk;
console.log("[rpa] place-commit-diagnostic", {
side: params.side,
placeId,
commitState: this.stateMachine.getState(),
placeRequestObserved,
placeRequestHttpOk,
observedPlaceIds: observedPlaceIds.map((id) => id.slice(0, 24)),
fixMethod,
pickupLostAfterDelivery: networkDiag?.pickupLostAfterDelivery ?? false,
});
// 快速通道visual / syntheticdual-patch vault放宽网络 place 闸门
const isVisualConfirmed = fixMethod.includes("-visual");
const isSyntheticCommit = fixMethod.includes("-synthetic");
const relaxedGate = isVisualConfirmed || isSyntheticCommit;
if (!placeRequestObserved && !relaxedGate) {
this.fail(
params,
"no_place_request",
formatDiagnosticsFailure({
...this.diagnostics,
status: "no_place_request",
commitState: "FAILED",
}),
);
}
if (!placeRequestHttpOk && !relaxedGate) {
this.fail(
params,
"place_http_error",
formatDiagnosticsFailure({
...this.diagnostics,
status: "place_http_error",
commitState: "FAILED",
}),
);
}
if (relaxedGate) {
console.log(
`[rpa] place-commit: ${isSyntheticCommit ? "synthetic" : "视觉"}确认模式,跳过网络请求检查 (observed=${placeRequestObserved}, ok=${placeRequestHttpOk})`,
);
}
let dropdownClosed = await waitForAddressSuggestionLocked(
params.ctx.page,
2_000,
);
let dropdownOpen = await isAddressSuggestionDropdownOpen(params.ctx.page);
if (!dropdownClosed || dropdownOpen) {
await dismissAddressSuggestionDropdown(params.ctx.page, params.street).catch(
() => undefined,
);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.waitForTimeout(400);
dropdownClosed = await waitForAddressSuggestionLocked(params.ctx.page, 2_000);
dropdownOpen = await isAddressSuggestionDropdownOpen(params.ctx.page);
}
if ((!dropdownClosed || dropdownOpen) && !placeRequestHttpOk && !relaxedGate) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
method: fixMethod,
}) + ";联想下拉未收起",
);
} else if (dropdownOpen) {
console.log(
"[rpa] place-commit: place 200 已观测,忽略 styled 下拉误报",
);
}
if (params.side === "delivery" && params.pickupAnchor) {
const pickupStillInWidget = await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.pickupAnchor,
);
this.diagnostics.pickupLostAfterDelivery = !pickupStillInWidget;
if (!pickupStillInWidget) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
pickupLostAfterDelivery: true,
}) + ";填派送后提货地址从 widget 消失",
);
}
} else if (networkDiag?.pickupLostAfterDelivery) {
this.diagnostics.pickupLostAfterDelivery = false;
}
if (params.side === "pickup") {
let locked = await isPickupReadyForDeliveryInput(
params.ctx.page,
params.widgetSelector,
params.address,
);
if (!locked) {
await dismissAddressSuggestionDropdown(params.ctx.page).catch(
() => undefined,
);
await lockPickupIntoChip(
params.ctx,
params.widgetSelector,
params.street,
params.address,
);
await params.ctx.page.waitForTimeout(600);
locked = await isPickupReadyForDeliveryInput(
params.ctx.page,
params.widgetSelector,
params.address,
);
}
if (!locked) {
locked = await releasePickupFieldForDelivery(
params.ctx,
params.widgetSelector,
params.address,
params.street,
);
}
if (!locked) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
method: fixMethod,
}) +
"chip 视觉未确认(须门牌街名行 + City, ST 行,且 Deliver to 可见)",
);
}
return;
}
if (
!(await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
))
) {
if (placeRequestHttpOk) {
await dismissAddressSuggestionDropdown(params.ctx.page).catch(
() => undefined,
);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.keyboard.press("Tab").catch(() => undefined);
const deadline = Date.now() + 4_000;
while (Date.now() < deadline) {
if (
await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
)
) {
return;
}
await params.ctx.page.waitForTimeout(200);
}
if (placeRequestHttpOk) {
console.log(
"[rpa] place-commit: delivery place 200 已观测widget 文案未同步仍接受网络 commit",
);
return;
}
}
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
}) + ";派送地址未写入 widget",
);
}
}
private succeed(
params: SelectAddressParams,
placeId: string,
formattedAddress: string,
method?: string,
): AddressCommitResult {
this.mergeBufferedSelectionTraces();
this.stateMachine.transition("COMMITTED");
this.diagnostics.status = "ok";
this.diagnostics.method = method ?? this.diagnostics.method;
this.diagnostics.placeRequestObserved =
this.bridge?.hasSuccessfulPlaceNetworkRequest(placeId) ?? false;
this.diagnostics.commitState = "COMMITTED";
const details = this.bridge?.getPlaceDetails(placeId);
params.ctx.state.axelPlaceLocations[params.side] = details ?? {
placeId,
formattedAddress,
};
this.result = {
success: true,
placeId,
formattedAddress,
committed: true,
};
logAddressSelectDiagnostics(params.side, this.getDiagnostics());
return this.result;
}
private async recordSelectionTrace(
page: import("playwright").Page,
side: "pickup" | "delivery",
label: string,
): Promise<void> {
const snapshot = await captureSelectionTrace(page, label);
this.diagnostics.selectionTraces = [
...(this.diagnostics.selectionTraces ?? []),
snapshot,
];
this.diagnostics.events.push(formatSelectionTraceEvent(snapshot));
logSelectionTrace(side, snapshot);
}
private mergeBufferedSelectionTraces(): void {
const drained = drainSelectionTraces();
if (!drained.length) {
return;
}
this.diagnostics.selectionTraces = [
...(this.diagnostics.selectionTraces ?? []),
...drained,
];
for (const snapshot of drained) {
this.diagnostics.events.push(formatSelectionTraceEvent(snapshot));
}
}
private fail(
params: SelectAddressParams,
status: AddressSelectDiagnostics["status"],
detail: string,
): never {
this.mergeBufferedSelectionTraces();
this.stateMachine.transition("FAILED");
this.diagnostics.status = status;
this.diagnostics.commitState = "FAILED";
this.diagnostics.events.push(`failed:${status}`);
logAddressSelectDiagnostics(params.side, this.getDiagnostics());
throw new RpaError("ADDRESS_NOT_CONFIRMED", detail, {
retryable: status !== "selection_failed",
});
}
}
/**
* 「随便选一个」降级:先尝试按文字点击,再 ArrowDown→Enter 后视觉确认
* 不依赖 placeId API 回调,只做视觉确认
*/
async function selectFirstSuggestionVisual(
page: import("playwright").Page,
input: import("playwright").Locator,
widgetSelector: string,
address: Parameters<typeof isAddressPresentInWidget>[2],
): Promise<boolean> {
return mouseClickSuggestionUntilVisualConfirmed(
page,
input,
widgetSelector,
address,
"any-suggestion-visual",
);
}