|
|
import type { Locator, Page } from "playwright";
|
|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import { pageLocator } from "@/lib/rpa/locator";
|
|
|
import { clickFirstVisibleFromSpecs } from "@/lib/rpa/selector-specs";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import type { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import type { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge";
|
|
|
import {
|
|
|
getAddressConfirmationSurfaceText,
|
|
|
isAddressInputCommitted,
|
|
|
isAddressSuggestionDropdownOpen,
|
|
|
waitForAddressSuggestionLocked,
|
|
|
} from "@/workers/rpa/address-suggestions";
|
|
|
import { isPickupChipVisuallyReady } from "@/workers/rpa/address-chip-visual";
|
|
|
import { isBlindAddressFlowActive } from "@/lib/rpa/env";
|
|
|
import { mouseClickSuggestionUntilVisualConfirmed } from "@/workers/rpa/address-suggestion-mouse";
|
|
|
import { visibleStreetInput } from "@/workers/rpa/address-side";
|
|
|
|
|
|
type AddressFields = QuoteRequest["pickup"];
|
|
|
|
|
|
const POLL_MS = 200;
|
|
|
const ADDRESS_SIDE_COMMIT_TIMEOUT_MS = 12_000;
|
|
|
const ADDRESS_INPUT_STABLE_MS = 2_000;
|
|
|
|
|
|
export type AddressCommitSnapshot = {
|
|
|
side: "pickup" | "delivery";
|
|
|
inputValues: string[];
|
|
|
surfaceText: string;
|
|
|
widgetTextPreview: string;
|
|
|
dropdownOpen: boolean;
|
|
|
axelLocationHit: boolean;
|
|
|
};
|
|
|
|
|
|
function toQueryHint(address: AddressFields) {
|
|
|
return {
|
|
|
street: address.street,
|
|
|
city: address.city ?? "",
|
|
|
state: address.state ?? "",
|
|
|
zip: address.zip ?? "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function normalizeWidgetText(text: string): string {
|
|
|
return text
|
|
|
.replace(/\bavenue\b/gi, "ave")
|
|
|
.replace(/\bboulevard\b/gi, "blvd")
|
|
|
.replace(/\bstreet\b/gi, "st")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim()
|
|
|
.toLowerCase();
|
|
|
}
|
|
|
|
|
|
function hasWordToken(text: string, token: string): boolean {
|
|
|
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
return new RegExp(`\\b${escaped}\\b`, "i").test(text);
|
|
|
}
|
|
|
|
|
|
const US_STATE_NAME_TO_ABBR: Record<string, string> = {
|
|
|
alabama: "al",
|
|
|
alaska: "ak",
|
|
|
arizona: "az",
|
|
|
arkansas: "ar",
|
|
|
california: "ca",
|
|
|
colorado: "co",
|
|
|
connecticut: "ct",
|
|
|
delaware: "de",
|
|
|
florida: "fl",
|
|
|
georgia: "ga",
|
|
|
hawaii: "hi",
|
|
|
idaho: "id",
|
|
|
illinois: "il",
|
|
|
indiana: "in",
|
|
|
iowa: "ia",
|
|
|
kansas: "ks",
|
|
|
kentucky: "ky",
|
|
|
louisiana: "la",
|
|
|
maine: "me",
|
|
|
maryland: "md",
|
|
|
massachusetts: "ma",
|
|
|
michigan: "mi",
|
|
|
minnesota: "mn",
|
|
|
mississippi: "ms",
|
|
|
missouri: "mo",
|
|
|
montana: "mt",
|
|
|
nebraska: "ne",
|
|
|
nevada: "nv",
|
|
|
"new hampshire": "nh",
|
|
|
"new jersey": "nj",
|
|
|
"new mexico": "nm",
|
|
|
"new york": "ny",
|
|
|
"north carolina": "nc",
|
|
|
"north dakota": "nd",
|
|
|
ohio: "oh",
|
|
|
oklahoma: "ok",
|
|
|
oregon: "or",
|
|
|
pennsylvania: "pa",
|
|
|
"rhode island": "ri",
|
|
|
"south carolina": "sc",
|
|
|
"south dakota": "sd",
|
|
|
tennessee: "tn",
|
|
|
texas: "tx",
|
|
|
utah: "ut",
|
|
|
vermont: "vt",
|
|
|
virginia: "va",
|
|
|
washington: "wa",
|
|
|
"west virginia": "wv",
|
|
|
wisconsin: "wi",
|
|
|
wyoming: "wy",
|
|
|
};
|
|
|
|
|
|
function stateAppearsInWidget(widgetNorm: string, state: string): boolean {
|
|
|
const raw = state.trim().toLowerCase();
|
|
|
if (!raw) {
|
|
|
return true;
|
|
|
}
|
|
|
if (widgetNorm.includes(raw)) {
|
|
|
return true;
|
|
|
}
|
|
|
if (raw.length === 2 && widgetNorm.includes(`, ${raw}`)) {
|
|
|
return true;
|
|
|
}
|
|
|
const abbr = US_STATE_NAME_TO_ABBR[raw];
|
|
|
if (abbr && widgetNorm.includes(`, ${abbr}`)) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** widget 文案是否出现派送侧占位(chip 态:Deliver to / Where to?) */
|
|
|
export function widgetShowsDeliverySectionCue(text: string): boolean {
|
|
|
const normalized = text.replace(/\s+/g, " ");
|
|
|
return /(?:^|\s)(Deliver to|Delivery to|Where to\?)(?:\s|$)/i.test(
|
|
|
normalized,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** widget 内是否体现用户确认的 MotherShip 地址(防 widget 回退) */
|
|
|
export function widgetContainsConfirmedAddress(
|
|
|
widgetText: string,
|
|
|
address: AddressFields,
|
|
|
): boolean {
|
|
|
const label = address.mothershipDisplayLabel?.trim();
|
|
|
if (!label) {
|
|
|
return false;
|
|
|
}
|
|
|
const w = normalizeWidgetText(widgetText);
|
|
|
const streetNum = address.street.match(/^(\d+)/)?.[1];
|
|
|
const labelNorm = normalizeWidgetText(label);
|
|
|
if (
|
|
|
streetNum &&
|
|
|
labelNorm.includes(streetNum.toLowerCase()) &&
|
|
|
!w.includes(streetNum.toLowerCase())
|
|
|
) {
|
|
|
return false;
|
|
|
}
|
|
|
const state = address.state?.trim();
|
|
|
if (state && !stateAppearsInWidget(w, state)) {
|
|
|
return false;
|
|
|
}
|
|
|
const city = address.city?.trim().toLowerCase();
|
|
|
if (city && city.length >= 3 && !w.includes(city)) {
|
|
|
const cityParts = city.split(/\s+/).filter((part) => part.length >= 2);
|
|
|
if (cityParts.length > 1) {
|
|
|
const allPartsPresent = cityParts.every((part) =>
|
|
|
hasWordToken(widgetText, part),
|
|
|
);
|
|
|
if (!allPartsPresent) {
|
|
|
return false;
|
|
|
}
|
|
|
} else {
|
|
|
const streetCore = normalizeWidgetText(address.street);
|
|
|
if (!streetCore || !w.includes(streetCore)) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
const labelTokens = label
|
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
|
.split(/[,\s]+/)
|
|
|
.map((t) => t.toLowerCase())
|
|
|
.filter((t) => t.length > 3);
|
|
|
const streetCore = normalizeWidgetText(address.street);
|
|
|
if (
|
|
|
streetCore &&
|
|
|
!/\bbridge\b/.test(streetCore) &&
|
|
|
/\bbridge\b/.test(w) &&
|
|
|
w.includes(streetCore.split(/\s+/)[0] ?? "")
|
|
|
) {
|
|
|
return false;
|
|
|
}
|
|
|
return labelTokens.some((t) => w.includes(t));
|
|
|
}
|
|
|
|
|
|
|
|
|
/** 提货 chip 视觉确认 + Deliver to 占位(委托 address-chip-visual) */
|
|
|
export async function isPickupChipCommitted(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
): Promise<boolean> {
|
|
|
return isPickupChipVisuallyReady(page, widgetSelector, pickup);
|
|
|
}
|
|
|
|
|
|
async function readCommittedInputSurface(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<string> {
|
|
|
return page.evaluate(
|
|
|
`(function(sel) {
|
|
|
var widget = document.querySelector(sel);
|
|
|
if (!widget) return "";
|
|
|
var chunks = [];
|
|
|
var inputs = widget.querySelectorAll("input");
|
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
|
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
|
|
|
if (val.length >= 8) chunks.push(val);
|
|
|
}
|
|
|
return chunks.join(" ");
|
|
|
})(${JSON.stringify(widgetSelector)})`,
|
|
|
) as Promise<string>;
|
|
|
}
|
|
|
|
|
|
async function readStreetInputValues(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<string[]> {
|
|
|
return page.evaluate(
|
|
|
`(function(sel) {
|
|
|
var widget = document.querySelector(sel);
|
|
|
if (!widget) return [];
|
|
|
var out = [];
|
|
|
var inputs = widget.querySelectorAll("input");
|
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
|
var ph = (inputs[i].placeholder || "").toLowerCase();
|
|
|
if (!/search|搜索/.test(ph)) continue;
|
|
|
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
|
|
|
if (val) out.push(val);
|
|
|
}
|
|
|
return out;
|
|
|
})(${JSON.stringify(widgetSelector)})`,
|
|
|
) as Promise<string[]>;
|
|
|
}
|
|
|
|
|
|
export async function readWidgetCombinedText(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<string> {
|
|
|
const surfaceText = await getAddressConfirmationSurfaceText(
|
|
|
page,
|
|
|
widgetSelector,
|
|
|
);
|
|
|
const fullText = await pageLocator(page, widgetSelector)
|
|
|
.innerText()
|
|
|
.catch(() => "");
|
|
|
return [surfaceText, fullText]
|
|
|
.map((part) => part.replace(/\s+/g, " ").trim())
|
|
|
.filter(Boolean)
|
|
|
.join(" ");
|
|
|
}
|
|
|
|
|
|
export async function captureAddressCommitSnapshot(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
widgetSelector: string,
|
|
|
axelLocationHit = false,
|
|
|
): Promise<AddressCommitSnapshot> {
|
|
|
const widgetText = await readWidgetCombinedText(ctx.page, widgetSelector);
|
|
|
return {
|
|
|
side,
|
|
|
inputValues: await readStreetInputValues(ctx.page, widgetSelector),
|
|
|
surfaceText: await getAddressConfirmationSurfaceText(
|
|
|
ctx.page,
|
|
|
widgetSelector,
|
|
|
),
|
|
|
widgetTextPreview: widgetText.slice(0, 220),
|
|
|
dropdownOpen: await isAddressSuggestionDropdownOpen(ctx.page),
|
|
|
axelLocationHit,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function logAddressCommitSnapshot(
|
|
|
phase: string,
|
|
|
snapshot: AddressCommitSnapshot,
|
|
|
): void {
|
|
|
console.log(
|
|
|
`[rpa] address-commit:${phase} side=${snapshot.side} inputs=${JSON.stringify(snapshot.inputValues)} dropdown=${snapshot.dropdownOpen} axelLocation=${snapshot.axelLocationHit} widget=${snapshot.widgetTextPreview}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export async function dismissAddressSuggestionDropdown(
|
|
|
page: Page,
|
|
|
streetInput?: Locator | null,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + 4_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (!(await isAddressSuggestionDropdownOpen(page))) {
|
|
|
return true;
|
|
|
}
|
|
|
if (streetInput) {
|
|
|
await streetInput.press("Enter").catch(() => undefined);
|
|
|
}
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
return !(await isAddressSuggestionDropdownOpen(page));
|
|
|
}
|
|
|
|
|
|
/** 强制收起联想(仅键盘,避免误点 Use my location / 重置 widget) */
|
|
|
export async function forceDismissAddressSuggestionDropdown(
|
|
|
page: Page,
|
|
|
_widgetSelector: string,
|
|
|
): Promise<void> {
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
|
if (!(await isAddressSuggestionDropdownOpen(page))) {
|
|
|
return;
|
|
|
}
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
for (let i = 0; i < 3; i += 1) {
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
if (!(await isAddressSuggestionDropdownOpen(page))) {
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function readAddressSurfaceForCommit(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
dropdownOpen: boolean,
|
|
|
): Promise<string> {
|
|
|
const surfaceText = await getAddressConfirmationSurfaceText(
|
|
|
page,
|
|
|
widgetSelector,
|
|
|
);
|
|
|
if (dropdownOpen) {
|
|
|
return surfaceText;
|
|
|
}
|
|
|
return readWidgetCombinedText(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
async function waitForInputSurfaceStable(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
address: AddressFields,
|
|
|
stableMs: number,
|
|
|
timeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
let lastSurface = "";
|
|
|
let stableSince = 0;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
const surface = await getAddressConfirmationSurfaceText(page, widgetSelector);
|
|
|
const now = Date.now();
|
|
|
if (widgetContainsConfirmedAddress(surface, address)) {
|
|
|
if (surface === lastSurface) {
|
|
|
if (stableSince === 0) {
|
|
|
stableSince = now;
|
|
|
}
|
|
|
if (now - stableSince >= stableMs) {
|
|
|
return true;
|
|
|
}
|
|
|
} else {
|
|
|
lastSurface = surface;
|
|
|
stableSince = now;
|
|
|
}
|
|
|
} else {
|
|
|
lastSurface = "";
|
|
|
stableSince = 0;
|
|
|
}
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function waitForInputValueStable(
|
|
|
input: Locator,
|
|
|
address: AddressFields,
|
|
|
stableMs: number,
|
|
|
timeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
const queryHint = toQueryHint(address);
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
let lastValue = "";
|
|
|
let stableSince = 0;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
const value = ((await input.inputValue().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
const committed = await isAddressInputCommitted(input, queryHint);
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (value.length >= 8 && committed && value === lastValue) {
|
|
|
if (stableSince === 0) {
|
|
|
stableSince = now;
|
|
|
}
|
|
|
if (now - stableSince >= stableMs) {
|
|
|
return true;
|
|
|
}
|
|
|
} else {
|
|
|
lastValue = value;
|
|
|
stableSince = committed && value.length >= 8 ? now : 0;
|
|
|
}
|
|
|
|
|
|
await input.page().waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 收起当前地址编辑焦点,避免双地址 focus 竞争 */
|
|
|
export async function blurAddressEditing(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
|
|
|
const deadline = Date.now() + 3_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (!(await isAddressSuggestionDropdownOpen(page))) {
|
|
|
return;
|
|
|
}
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** pickup 提交后、打开 delivery 前:锁定提货侧,防止 React 未 commit 即切换 */
|
|
|
export async function settlePickupBeforeDelivery(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
): Promise<void> {
|
|
|
await blurAddressEditing(ctx, widgetSelector);
|
|
|
await dismissAddressSuggestionDropdown(ctx.page);
|
|
|
await forceDismissAddressSuggestionDropdown(ctx.page, widgetSelector);
|
|
|
await ctx.page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(POLL_MS);
|
|
|
|
|
|
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isAddressPresentInWidget(ctx.page, widgetSelector, pickup)) {
|
|
|
break;
|
|
|
}
|
|
|
await ctx.page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
if (!(await isAddressPresentInWidget(ctx.page, widgetSelector, pickup))) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`提货地址未写入 widget:${pickup.mothershipDisplayLabel}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup)) {
|
|
|
break;
|
|
|
}
|
|
|
await releasePickupFieldForDelivery(ctx, widgetSelector, pickup);
|
|
|
await ctx.page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
"pickup",
|
|
|
widgetSelector,
|
|
|
);
|
|
|
logAddressCommitSnapshot("pickup-settled", snapshot);
|
|
|
|
|
|
if (!(await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup))) {
|
|
|
const diag = await captureAddressCommitSnapshot(ctx, "pickup", widgetSelector);
|
|
|
console.log("[rpa] pickup-commit-diagnostic", {
|
|
|
phase: "settlePickupBeforeDelivery",
|
|
|
placeId: pickup.placeId,
|
|
|
mothershipDisplayLabel: pickup.mothershipDisplayLabel,
|
|
|
commitState: "widget_not_locked",
|
|
|
placeRequestObserved: diag.axelLocationHit,
|
|
|
inputValues: diag.inputValues,
|
|
|
dropdownOpen: diag.dropdownOpen,
|
|
|
});
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`提货地址未锁定,无法打开派送侧(${pickup.mothershipDisplayLabel})`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 等待单侧地址被 MotherShip widget 接受(须 place/{id} 200,禁止仅以 input.value 判定) */
|
|
|
export async function waitForAddressSideCommit(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
address: AddressFields,
|
|
|
widgetSelector: string,
|
|
|
streetInput?: Locator | null,
|
|
|
placeCommit?: { bridge: AxelSelectionBridge; placeId: string },
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
const placeId = placeCommit?.placeId?.trim();
|
|
|
const bridge = placeCommit?.bridge;
|
|
|
|
|
|
if (placeId && bridge) {
|
|
|
if (bridge.hasPlaceCommit(placeId)) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
true,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-committed`, snapshot);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const committed = await bridge.waitForPlaceCommit(
|
|
|
placeId,
|
|
|
ADDRESS_SIDE_COMMIT_TIMEOUT_MS,
|
|
|
);
|
|
|
if (committed) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
true,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-committed`, snapshot);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
false,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-commit-failed`, snapshot);
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`${side === "pickup" ? "提货" : "派送"}地址 place/${placeId} 未 commit(网络证据缺失)`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const queryHint = toQueryHint(address);
|
|
|
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
|
|
|
let consecutiveSurfaceMatches = 0;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
const dropdownOpen = await isAddressSuggestionDropdownOpen(page);
|
|
|
if (dropdownOpen) {
|
|
|
await dismissAddressSuggestionDropdown(page, streetInput ?? undefined);
|
|
|
}
|
|
|
|
|
|
const surface = await readCommittedInputSurface(page, widgetSelector);
|
|
|
const surfaceOk = widgetContainsConfirmedAddress(surface, address);
|
|
|
|
|
|
if (surfaceOk) {
|
|
|
consecutiveSurfaceMatches += 1;
|
|
|
if (consecutiveSurfaceMatches * POLL_MS >= ADDRESS_INPUT_STABLE_MS) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
false,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-committed`, snapshot);
|
|
|
return;
|
|
|
}
|
|
|
} else {
|
|
|
consecutiveSurfaceMatches = 0;
|
|
|
}
|
|
|
|
|
|
if (!surfaceOk && !dropdownOpen) {
|
|
|
const widgetText = await readWidgetCombinedText(page, widgetSelector);
|
|
|
if (widgetContainsConfirmedAddress(widgetText, address)) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
false,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-committed`, snapshot);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (streetInput) {
|
|
|
const inputVisible = await streetInput.isVisible().catch(() => false);
|
|
|
const inputEditable = await streetInput.isEditable().catch(() => false);
|
|
|
if (inputVisible && inputEditable && (await isAddressInputCommitted(streetInput, queryHint))) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
false,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-committed`, snapshot);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
side,
|
|
|
widgetSelector,
|
|
|
false,
|
|
|
);
|
|
|
logAddressCommitSnapshot(`${side}-commit-failed`, snapshot);
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`${side === "pickup" ? "提货" : "派送"}地址未提交到 widget(${snapshot.widgetTextPreview.slice(0, 100)})`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 双地址快照校验;delivery 为 null 时仅校验 pickup 已锁定(非仅 input 文本回显) */
|
|
|
export async function assertDualAddressStable(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
delivery: AddressFields | null,
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
|
|
|
let lastPickupOk = false;
|
|
|
let lastDeliveryOk = delivery === null;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
if (delivery === null) {
|
|
|
lastPickupOk = await isPickupReadyForDeliveryInput(
|
|
|
page,
|
|
|
widgetSelector,
|
|
|
pickup,
|
|
|
);
|
|
|
} else {
|
|
|
const surface = await readCommittedInputSurface(page, widgetSelector);
|
|
|
const full = await readWidgetCombinedText(page, widgetSelector);
|
|
|
const combined = `${surface} ${full}`;
|
|
|
lastPickupOk =
|
|
|
widgetContainsConfirmedAddress(surface, pickup) ||
|
|
|
widgetContainsConfirmedAddress(full, pickup) ||
|
|
|
widgetContainsConfirmedAddress(combined, pickup);
|
|
|
lastDeliveryOk =
|
|
|
widgetContainsConfirmedAddress(surface, delivery) ||
|
|
|
widgetContainsConfirmedAddress(full, delivery) ||
|
|
|
widgetContainsConfirmedAddress(combined, delivery);
|
|
|
}
|
|
|
|
|
|
if (lastPickupOk && lastDeliveryOk) {
|
|
|
const snapshot = await captureAddressCommitSnapshot(
|
|
|
ctx,
|
|
|
"pickup",
|
|
|
widgetSelector,
|
|
|
);
|
|
|
logAddressCommitSnapshot("dual-stable", {
|
|
|
...snapshot,
|
|
|
side: "pickup",
|
|
|
widgetTextPreview: snapshot.widgetTextPreview.slice(0, 220),
|
|
|
});
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (delivery && lastPickupOk === false) {
|
|
|
await blurAddressEditing(ctx, widgetSelector);
|
|
|
const pickupSections = ctx.selectors.specs("RPA_SELECTOR_PICKUP_SECTION");
|
|
|
if (pickupSections.length > 0) {
|
|
|
await clickFirstVisibleFromSpecs(page, pickupSections).catch(
|
|
|
() => undefined,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
const widgetText = await readWidgetCombinedText(page, widgetSelector);
|
|
|
if (!lastPickupOk) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`双地址校验失败:提货地址已从 widget 消失(${pickup.mothershipDisplayLabel})preview=${widgetText.slice(0, 120)}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
if (delivery && !lastDeliveryOk) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`双地址校验失败:派送地址未锁定(${delivery.mothershipDisplayLabel})preview=${widgetText.slice(0, 120)}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function isAddressPresentInWidget(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
address: AddressFields,
|
|
|
): Promise<boolean> {
|
|
|
const surface = await readCommittedInputSurface(page, widgetSelector);
|
|
|
const full = await readWidgetCombinedText(page, widgetSelector);
|
|
|
return widgetContainsConfirmedAddress(`${surface} ${full}`, address);
|
|
|
}
|
|
|
|
|
|
/** 纯函数:widget 文案是否已体现双地址(截图态:两州码 / 两门牌 / Freight details 旁双址) */
|
|
|
export function evaluateAddressesVisuallyComplete(widgetText: string): boolean {
|
|
|
const normalized = widgetText.replace(/\s+/g, " ").trim();
|
|
|
if (!normalized) {
|
|
|
return false;
|
|
|
}
|
|
|
const stateCount = (normalized.match(/,\s*[A-Z]{2}\b/g) ?? []).length;
|
|
|
const streetMarkers = normalized.match(/\d{3,5}\s+\S+/g) ?? [];
|
|
|
const hasFreightChip = /freight details|货运详情/i.test(normalized);
|
|
|
if (stateCount >= 2) {
|
|
|
return true;
|
|
|
}
|
|
|
if (streetMarkers.length >= 2) {
|
|
|
return true;
|
|
|
}
|
|
|
if (hasFreightChip && (stateCount >= 1 || streetMarkers.length >= 1)) {
|
|
|
const hasDeliverTo = /deliver to|送货至/i.test(normalized);
|
|
|
if (hasDeliverTo || stateCount >= 1) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 视觉优先:双地址已在 widget/地图体现即可进入货物步(不等 place 网络 / 下拉收起)。
|
|
|
*/
|
|
|
export async function isAddressesVisuallyCompleteForCargo(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
pickup?: AddressFields,
|
|
|
delivery?: AddressFields,
|
|
|
): Promise<boolean> {
|
|
|
const widgetText = await readWidgetCombinedText(page, widgetSelector).catch(
|
|
|
() => "",
|
|
|
);
|
|
|
const surface = await readCommittedInputSurface(page, widgetSelector).catch(
|
|
|
() => "",
|
|
|
);
|
|
|
const combined = `${surface} ${widgetText}`.replace(/\s+/g, " ").trim();
|
|
|
|
|
|
if (pickup && delivery) {
|
|
|
const pickupOk = widgetContainsConfirmedAddress(combined, pickup);
|
|
|
const deliveryOk = widgetContainsConfirmedAddress(combined, delivery);
|
|
|
if (pickupOk && deliveryOk) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (evaluateAddressesVisuallyComplete(combined)) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
return page.evaluate(() => {
|
|
|
const body = (document.body.innerText || "").replace(/\s+/g, " ");
|
|
|
const stateHits = body.match(/,\s*[A-Z]{2}\b/g) ?? [];
|
|
|
if (stateHits.length < 2) {
|
|
|
return false;
|
|
|
}
|
|
|
const hasMap =
|
|
|
document.querySelector("canvas") !== null ||
|
|
|
document.querySelector("svg") !== null ||
|
|
|
document.querySelector('[class*="map" i]') !== null;
|
|
|
return hasMap;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function hasEmptyEditableStreetInput(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate(
|
|
|
`(function(sel) {
|
|
|
var widget = document.querySelector(sel);
|
|
|
if (!widget) return false;
|
|
|
var inputs = widget.querySelectorAll("input");
|
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
|
var ph = (inputs[i].placeholder || "").toLowerCase();
|
|
|
if (!/search|搜索/.test(ph)) continue;
|
|
|
if (inputs[i].offsetParent === null) continue;
|
|
|
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
|
|
|
var editable =
|
|
|
!inputs[i].disabled &&
|
|
|
inputs[i].readOnly === false &&
|
|
|
inputs[i].getAttribute("aria-readonly") !== "true";
|
|
|
if (editable && val.length < 8) return true;
|
|
|
}
|
|
|
return false;
|
|
|
})(${JSON.stringify(widgetSelector)})`,
|
|
|
) as Promise<boolean>;
|
|
|
}
|
|
|
|
|
|
/** 提货已写入 widget 且派送侧可独立输入(仅视觉 chip + Deliver to) */
|
|
|
export async function isPickupReadyForDeliveryInput(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
): Promise<boolean> {
|
|
|
await forceDismissAddressSuggestionDropdown(page, widgetSelector);
|
|
|
return isPickupChipVisuallyReady(page, widgetSelector, pickup);
|
|
|
}
|
|
|
|
|
|
/** 提货点选后:鼠标点联想 + 视觉确认,再点「Deliver to」释放派送框 */
|
|
|
export async function lockPickupIntoChip(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
streetInput?: Locator | null,
|
|
|
pickup?: AddressFields,
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
|
|
|
if (
|
|
|
pickup &&
|
|
|
streetInput &&
|
|
|
(await isAddressSuggestionDropdownOpen(page))
|
|
|
) {
|
|
|
await mouseClickSuggestionUntilVisualConfirmed(
|
|
|
page,
|
|
|
streetInput,
|
|
|
widgetSelector,
|
|
|
pickup,
|
|
|
"pickup-lock-chip",
|
|
|
{ blindFlow: isBlindAddressFlowActive(ctx) },
|
|
|
);
|
|
|
await page.waitForTimeout(300);
|
|
|
}
|
|
|
|
|
|
await dismissAddressSuggestionDropdown(page);
|
|
|
|
|
|
const pickupLocked =
|
|
|
pickup &&
|
|
|
(await isAddressPresentInWidget(page, widgetSelector, pickup));
|
|
|
|
|
|
if (!pickupLocked) {
|
|
|
await page.keyboard.press("ArrowDown").catch(() => undefined);
|
|
|
await page.waitForTimeout(100);
|
|
|
if (streetInput) {
|
|
|
await streetInput.press("Enter").catch(() => undefined);
|
|
|
await page.waitForTimeout(250);
|
|
|
}
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await page.waitForTimeout(200);
|
|
|
}
|
|
|
await forceDismissAddressSuggestionDropdown(page, widgetSelector);
|
|
|
|
|
|
const deliverTo = widget
|
|
|
.getByText(/^(Deliver to|Delivery to|Where to\?)$/i)
|
|
|
.first();
|
|
|
if (await deliverTo.isVisible().catch(() => false)) {
|
|
|
await deliverTo.click({ force: true, timeout: 3_000 }).catch(() => undefined);
|
|
|
} else {
|
|
|
await clickFirstVisibleFromSpecs(
|
|
|
page,
|
|
|
ctx.selectors.specs("RPA_SELECTOR_DELIVERY_SECTION"),
|
|
|
).catch(() => undefined);
|
|
|
}
|
|
|
await page.waitForTimeout(400);
|
|
|
await dismissAddressSuggestionDropdown(page);
|
|
|
}
|
|
|
|
|
|
/** 点选提货后:收起下拉并打开派送侧,释放独立搜索框 */
|
|
|
export async function releasePickupFieldForDelivery(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
streetInput?: Locator | null,
|
|
|
): Promise<boolean> {
|
|
|
const page = ctx.page;
|
|
|
|
|
|
if (await isPickupReadyForDeliveryInput(page, widgetSelector, pickup)) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
const pickupInput =
|
|
|
streetInput ?? (await visibleStreetInput(ctx, "pickup"));
|
|
|
await lockPickupIntoChip(ctx, widgetSelector, pickupInput ?? undefined, pickup);
|
|
|
return isPickupReadyForDeliveryInput(page, widgetSelector, pickup);
|
|
|
}
|
|
|
|
|
|
/** delivery 提交后若 pickup 被覆盖:交替 refill 直到双地址均出现在 widget */
|
|
|
export async function ensureDualAddressCommitted(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
delivery: AddressFields,
|
|
|
refillSide: (
|
|
|
side: "pickup" | "delivery",
|
|
|
address: AddressFields,
|
|
|
) => Promise<void>,
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
const MAX_REFILL_ROUNDS = 6;
|
|
|
|
|
|
for (let round = 0; round < MAX_REFILL_ROUNDS; round += 1) {
|
|
|
await dismissAddressSuggestionDropdown(page);
|
|
|
await blurAddressEditing(ctx, widgetSelector);
|
|
|
|
|
|
const pickupOk = await isAddressPresentInWidget(
|
|
|
page,
|
|
|
widgetSelector,
|
|
|
pickup,
|
|
|
);
|
|
|
const deliveryOk = await isAddressPresentInWidget(
|
|
|
page,
|
|
|
widgetSelector,
|
|
|
delivery,
|
|
|
);
|
|
|
|
|
|
if (pickupOk && deliveryOk) {
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
if (!pickupOk) {
|
|
|
console.log(
|
|
|
`[rpa] address-commit:pickup-missing refill round=${round + 1}`,
|
|
|
);
|
|
|
await refillSide("pickup", pickup);
|
|
|
continue;
|
|
|
}
|
|
|
if (!deliveryOk) {
|
|
|
console.log(
|
|
|
`[rpa] address-commit:delivery-missing refill round=${round + 1}`,
|
|
|
);
|
|
|
await refillSide("delivery", delivery);
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await assertDualAddressStable(ctx, widgetSelector, pickup, delivery);
|
|
|
}
|
|
|
|
|
|
export async function waitForPickupCommit(
|
|
|
ctx: RPAContext,
|
|
|
pickup: AddressFields,
|
|
|
widgetSelector: string,
|
|
|
streetInput?: Locator | null,
|
|
|
): Promise<void> {
|
|
|
await waitForAddressSideCommit(
|
|
|
ctx,
|
|
|
"pickup",
|
|
|
pickup,
|
|
|
widgetSelector,
|
|
|
streetInput,
|
|
|
);
|
|
|
}
|