|
|
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 { 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);
|
|
|
}
|
|
|
|
|
|
/** 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 && !w.includes(state.toLowerCase())) {
|
|
|
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));
|
|
|
}
|
|
|
|
|
|
|
|
|
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 且派送侧可独立输入(非同一搜索框覆盖) */
|
|
|
export async function isPickupReadyForDeliveryInput(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
): Promise<boolean> {
|
|
|
await forceDismissAddressSuggestionDropdown(page, widgetSelector);
|
|
|
|
|
|
const values = await readStreetInputValues(page, widgetSelector);
|
|
|
const surface = await readCommittedInputSurface(page, widgetSelector);
|
|
|
const full = await readWidgetCombinedText(page, widgetSelector);
|
|
|
const combined = `${surface} ${full}`;
|
|
|
|
|
|
if (!widgetContainsConfirmedAddress(combined, pickup)) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
if (values.length >= 2) {
|
|
|
return true;
|
|
|
}
|
|
|
if (await hasEmptyEditableStreetInput(page, widgetSelector)) {
|
|
|
return true;
|
|
|
}
|
|
|
if (values.length === 1) {
|
|
|
const only = values[0]!.replace(/\s+/g, " ").trim().toLowerCase();
|
|
|
const streetNum = pickup.street.match(/^\d+/)?.[0]?.toLowerCase();
|
|
|
if (streetNum && only.includes(streetNum) && only.length >= 8) {
|
|
|
return false;
|
|
|
}
|
|
|
if (only.length >= 8) {
|
|
|
const deliverVisible = await pageLocator(page, widgetSelector)
|
|
|
.getByText(/^Deliver to$/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (deliverVisible && widgetContainsConfirmedAddress(combined, pickup)) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
return only.length < 8;
|
|
|
}
|
|
|
|
|
|
// chip 态:inputs 为空但 widget 已含提货且派送框可编辑
|
|
|
return values.length === 0 && (await hasEmptyEditableStreetInput(page, widgetSelector));
|
|
|
}
|
|
|
|
|
|
/** 提货点选后:Enter/Tab 收起联想,再点「Deliver to」释放派送框 */
|
|
|
export async function lockPickupIntoChip(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
streetInput?: Locator | null,
|
|
|
): Promise<void> {
|
|
|
const page = ctx.page;
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
|
|
|
await dismissAddressSuggestionDropdown(page);
|
|
|
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$/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);
|
|
|
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,
|
|
|
);
|
|
|
}
|