|
|
import { createHash } from "node:crypto";
|
|
|
import type { Locator, Page } from "playwright";
|
|
|
import {
|
|
|
ADDRESS_CANDIDATE_SUGGESTION_TIMEOUT_MS,
|
|
|
ADDRESS_SUGGESTION_TIMEOUT_MS,
|
|
|
RPA_STREET_INPUT_CLICK_MS,
|
|
|
} from "@/lib/constants/rpa";
|
|
|
import { COLLECT_ADDRESS_LABELS_FROM_DOM, COLLECT_PAC_ITEM_LABELS, COLLECT_WIDGET_SUGGESTION_LABELS } from "@/workers/rpa/kernel/browser-scripts";
|
|
|
import { ensureArray } from "@/workers/rpa/kernel/context-validator";
|
|
|
import { pageLocator } from "@/lib/rpa/locator";
|
|
|
import { awaitHumanPacing } from "@/lib/rpa/human-pacing";
|
|
|
import { getSelector } from "@/lib/rpa/selectors";
|
|
|
import { getSelectorSpecs } from "@/lib/rpa/selector-specs";
|
|
|
import type {
|
|
|
AddressLookupInput,
|
|
|
MothershipAddressCandidate,
|
|
|
} from "@/modules/address/types";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import { isPickupReadyForDeliveryInput } from "@/workers/rpa/address-commit";
|
|
|
import {
|
|
|
formatPlaceCommitFailure,
|
|
|
type AxelSelectionBridge,
|
|
|
type AxelSelectionPayload,
|
|
|
} from "@/workers/rpa/axel-selection-bridge";
|
|
|
import { commitViaAxelPlacePrefetch } from "@/workers/rpa/axel-place-prefetch";
|
|
|
import {
|
|
|
clickMothershipStyledSuggestion,
|
|
|
waitForStyledSuggestionRows,
|
|
|
} from "@/workers/rpa/mothership-styled-suggestion-click";
|
|
|
import {
|
|
|
captureSelectionTrace,
|
|
|
logSelectionTrace,
|
|
|
} from "@/workers/rpa/selection-trace";
|
|
|
import type { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
|
|
|
const POLL_INTERVAL_MS = 250;
|
|
|
const MAX_SUGGESTION_ROW_LEN = 180;
|
|
|
const DEFAULT_MOTHERSHIP_COUNTRY = "USA";
|
|
|
const SUGGESTION_STABLE_POLL_MS = 150;
|
|
|
|
|
|
/** 输入框已有与查询等效文案时,避免 fill("") 造成界面「闪清」 */
|
|
|
export function normalizeAddressInputValue(value: string): string {
|
|
|
return value.replace(/\s+/g, " ").trim();
|
|
|
}
|
|
|
|
|
|
export function addressInputMatchesQuery(current: string, query: string): boolean {
|
|
|
const c = normalizeAddressInputValue(current);
|
|
|
const q = normalizeAddressInputValue(query);
|
|
|
if (!c || !q) {
|
|
|
return false;
|
|
|
}
|
|
|
if (c === q) {
|
|
|
return true;
|
|
|
}
|
|
|
const cLower = c.toLowerCase();
|
|
|
const qLower = q.toLowerCase();
|
|
|
if (cLower.includes(qLower) || qLower.includes(cLower)) {
|
|
|
return true;
|
|
|
}
|
|
|
const streetNum = q.match(/^\d+/)?.[0];
|
|
|
if (streetNum && cLower.startsWith(streetNum.toLowerCase()) && c.length >= 12) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
const SUGGESTION_STABLE_PASSES = 2;
|
|
|
|
|
|
/** 候选枚举与 autocomplete 共用 query 序列,避免粘连-only 漏采真实地址 */
|
|
|
export function buildAddressEnumerationQueries(
|
|
|
address: AddressLookupInput,
|
|
|
): string[] {
|
|
|
return buildAddressAutocompleteQueries(address);
|
|
|
}
|
|
|
|
|
|
export function stripCountrySuffix(label: string): string {
|
|
|
return label.replace(/,\s*USA\s*$/i, "").trim();
|
|
|
}
|
|
|
function toGluedStreetCity(street: string, city: string): string {
|
|
|
const s = street.trim();
|
|
|
const c = city.trim().replace(/\s+/g, " ");
|
|
|
if (!s || !c) {
|
|
|
return s;
|
|
|
}
|
|
|
if (s.toLowerCase().includes(c.toLowerCase())) {
|
|
|
return s;
|
|
|
}
|
|
|
return `${s}${c}`;
|
|
|
}
|
|
|
|
|
|
function normalizeParsedStreet(
|
|
|
street: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): string {
|
|
|
const parsed = street.trim();
|
|
|
const qStreet = query?.street?.trim();
|
|
|
if (
|
|
|
qStreet &&
|
|
|
parsed.toLowerCase().startsWith(qStreet.toLowerCase()) &&
|
|
|
parsed.length > qStreet.length
|
|
|
) {
|
|
|
return qStreet;
|
|
|
}
|
|
|
return parsed;
|
|
|
}
|
|
|
|
|
|
function normalizeDomSuggestionLabel(label: string): string {
|
|
|
return stripCountrySuffix(label).replace(/\s+/g, " ").trim();
|
|
|
}
|
|
|
|
|
|
function gluedVariants(label: string): string[] {
|
|
|
const base = normalizeDomSuggestionLabel(label);
|
|
|
const variants = new Set<string>([base]);
|
|
|
const glued = base.replace(
|
|
|
/\b(Street|St|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Road|Rd|Lane|Ln)\s+([A-Z])/gi,
|
|
|
"$1$2",
|
|
|
);
|
|
|
if (glued !== base) {
|
|
|
variants.add(glued);
|
|
|
}
|
|
|
const spaced = base.replace(
|
|
|
/\b(Street|St|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Way|Road|Rd|Lane|Ln)\s*([A-Z])/gi,
|
|
|
"$1 $2",
|
|
|
);
|
|
|
if (spaced !== base) {
|
|
|
variants.add(spaced);
|
|
|
}
|
|
|
return [...variants];
|
|
|
}
|
|
|
|
|
|
async function locateSuggestionByLabelText(
|
|
|
page: Page,
|
|
|
text: string,
|
|
|
): Promise<Locator | null> {
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
const candidates = [
|
|
|
normalized,
|
|
|
stripCountrySuffix(normalized),
|
|
|
normalized.split(",")[0]?.trim() ?? "",
|
|
|
...gluedVariants(normalized),
|
|
|
].filter((value, index, arr) => value.length >= 8 && arr.indexOf(value) === index);
|
|
|
|
|
|
const roots = [
|
|
|
page.locator(".pac-container .pac-item"),
|
|
|
page.locator('[role="listbox"] [role="option"]'),
|
|
|
getQuoteWidgetLocator(page).locator('[role="listbox"] [role="option"]'),
|
|
|
page.locator("div[class*='sc-']"),
|
|
|
];
|
|
|
|
|
|
for (const probe of candidates) {
|
|
|
for (const root of roots) {
|
|
|
const loc = root.filter({ hasText: probe }).first();
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const pageLoc = page.getByText(probe, { exact: false }).first();
|
|
|
if (await pageLoc.isVisible().catch(() => false)) {
|
|
|
return pageLoc;
|
|
|
}
|
|
|
|
|
|
const widgetLoc = getQuoteWidgetLocator(page)
|
|
|
.getByText(probe, { exact: false })
|
|
|
.first();
|
|
|
if (await widgetLoc.isVisible().catch(() => false)) {
|
|
|
return widgetLoc;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function resolveCountry(address: AddressLookupInput): string {
|
|
|
return address.country?.trim() || DEFAULT_MOTHERSHIP_COUNTRY;
|
|
|
}
|
|
|
|
|
|
/** MotherShip 输入框键入:仅街道+城市+州+国家(不含邮编,对齐 PRD GD-15) */
|
|
|
export function formatAddressQuery(address: AddressLookupInput): string {
|
|
|
const street = address.street.trim();
|
|
|
const city = address.city.trim();
|
|
|
const state = address.state.trim();
|
|
|
return `${street}, ${city}, ${state}, ${resolveCountry(address)}`;
|
|
|
}
|
|
|
|
|
|
const STREET_SUFFIX_PATTERN =
|
|
|
"Street|St|Road|Rd|Avenue|Ave|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Circle|Cir|Place|Pl|Terrace|Ter|Highway|Hwy|Parkway|Pkwy|Freeway|Fwy";
|
|
|
|
|
|
/**
|
|
|
* 从 mothership label / 粘连文案提取纯街道名。
|
|
|
* 取最后一个街道后缀(Street/Ave/Blvd…),再回溯门牌号与街名,避免 POI 前缀污染查询。
|
|
|
*/
|
|
|
export function extractStreetFromLabel(label: string): string | null {
|
|
|
const normalized = label.replace(/\s+/g, " ").trim();
|
|
|
if (!normalized) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const suffixRe = new RegExp(
|
|
|
`\\b(${STREET_SUFFIX_PATTERN})(?=\\s*,|\\s|$|[A-Z][a-z])`,
|
|
|
"gi",
|
|
|
);
|
|
|
let lastSuffix: RegExpExecArray | null = null;
|
|
|
let match: RegExpExecArray | null = suffixRe.exec(normalized);
|
|
|
while (match) {
|
|
|
const token = match[1];
|
|
|
const idx = match.index;
|
|
|
if (token.toLowerCase() === "st") {
|
|
|
const prevChar = idx > 0 ? normalized[idx - 1] : "";
|
|
|
if (prevChar && /[a-z]/i.test(prevChar)) {
|
|
|
match = suffixRe.exec(normalized);
|
|
|
continue;
|
|
|
}
|
|
|
const head = normalized.slice(0, idx).trimEnd();
|
|
|
if (!/\s\S+$/.test(head)) {
|
|
|
match = suffixRe.exec(normalized);
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
lastSuffix = match;
|
|
|
match = suffixRe.exec(normalized);
|
|
|
}
|
|
|
if (!lastSuffix) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const suffix = lastSuffix[1];
|
|
|
const before = normalized.slice(0, lastSuffix.index).replace(/,\s*$/, "").trimEnd();
|
|
|
const commaIdx = before.lastIndexOf(",");
|
|
|
const segment = (commaIdx >= 0 ? before.slice(commaIdx + 1) : before).trim();
|
|
|
if (!segment) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const numbered = segment.match(/^(\d+)\s+(.+)$/);
|
|
|
const number = numbered?.[1];
|
|
|
const namePart = (numbered?.[2] ?? segment).trim();
|
|
|
const words = namePart.split(/\s+/).filter(Boolean);
|
|
|
if (!number && words.length === 0) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const streetName = number ? `${number} ${words.join(" ")}`.trim() : words[words.length - 1];
|
|
|
const street = `${streetName} ${suffix}`.replace(/\s+/g, " ").trim();
|
|
|
if (street.length < 8 || street.length > 80) {
|
|
|
return null;
|
|
|
}
|
|
|
return street;
|
|
|
}
|
|
|
|
|
|
/** 将 POI 粘连 street 归一为纯街道,供联想键入与 DOM 采集使用 */
|
|
|
export function resolveAddressAutocompleteInput(
|
|
|
address: AddressLookupInput,
|
|
|
options?: { mothershipLabel?: string },
|
|
|
): AddressLookupInput {
|
|
|
const label = options?.mothershipLabel?.trim();
|
|
|
const sources = [label, address.street.trim()].filter(Boolean) as string[];
|
|
|
|
|
|
for (const source of sources) {
|
|
|
const pureStreet = extractStreetFromLabel(source);
|
|
|
if (!pureStreet) {
|
|
|
continue;
|
|
|
}
|
|
|
const current = address.street.trim();
|
|
|
if (pureStreet.toLowerCase() === current.toLowerCase()) {
|
|
|
return address;
|
|
|
}
|
|
|
return { ...address, street: pureStreet };
|
|
|
}
|
|
|
|
|
|
return address;
|
|
|
}
|
|
|
|
|
|
/** 联想未出现时依次尝试的键入文案(纯街道优先,完整 POI 置后) */
|
|
|
export function buildAddressAutocompleteQueries(
|
|
|
address: AddressLookupInput,
|
|
|
options?: { mothershipLabel?: string; preferConfirmedLabel?: boolean },
|
|
|
): string[] {
|
|
|
const resolved = resolveAddressAutocompleteInput(address, options);
|
|
|
const label = options?.mothershipLabel?.trim();
|
|
|
const street = resolved.street.trim();
|
|
|
const city = resolved.city.trim();
|
|
|
const state = resolved.state.trim();
|
|
|
const origStreet = address.street.trim();
|
|
|
|
|
|
const queries: string[] = [];
|
|
|
if (options?.preferConfirmedLabel && label) {
|
|
|
queries.push(stripCountrySuffix(label));
|
|
|
}
|
|
|
queries.push(formatAddressQuery(resolved));
|
|
|
queries.push(`${street}, ${city}, ${state}`);
|
|
|
queries.push(`${toGluedStreetCity(street, city)}, ${state}`);
|
|
|
|
|
|
const streetToken = street.match(/^\d+\s+\S+(?:\s+\S+)?/)?.[0]?.trim();
|
|
|
if (streetToken && streetToken.length >= 6) {
|
|
|
queries.push(streetToken);
|
|
|
}
|
|
|
|
|
|
if (origStreet && origStreet.toLowerCase() !== street.toLowerCase()) {
|
|
|
queries.push(formatAddressQuery(address));
|
|
|
queries.push(`${origStreet}, ${city}, ${state}`);
|
|
|
queries.push(`${toGluedStreetCity(origStreet, city)}, ${state}`);
|
|
|
if (label) {
|
|
|
queries.push(stripCountrySuffix(label));
|
|
|
}
|
|
|
} else if (label) {
|
|
|
const stripped = stripCountrySuffix(label);
|
|
|
if (stripped !== queries[0]) {
|
|
|
queries.push(stripped);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return [...new Set(queries.map((q) => q.trim()).filter(Boolean))];
|
|
|
}
|
|
|
|
|
|
export function formatMothershipFormattedAddress(
|
|
|
street: string,
|
|
|
city: string,
|
|
|
state: string,
|
|
|
country = DEFAULT_MOTHERSHIP_COUNTRY,
|
|
|
): string {
|
|
|
return `${street}, ${city}, ${state}, ${country}`;
|
|
|
}
|
|
|
|
|
|
function queryStreetNumber(address: AddressLookupInput): string | null {
|
|
|
return address.street.match(/^\d+/)?.[0] ?? null;
|
|
|
}
|
|
|
|
|
|
function labelsContainStreetNumber(labels: readonly string[], streetNum: string): boolean {
|
|
|
return labels.some((label) => label.includes(streetNum));
|
|
|
}
|
|
|
|
|
|
/** place 绑定优先尝试的 label 序列(完整街道优先于粘连 display_label) */
|
|
|
export function buildAddressBindingLabels(
|
|
|
address: AddressLookupInput,
|
|
|
mothershipLabel?: string,
|
|
|
): string[] {
|
|
|
const street = address.street.trim();
|
|
|
const city = address.city.trim();
|
|
|
const state = address.state.trim();
|
|
|
const labels: string[] = [];
|
|
|
if (street && city && state) {
|
|
|
labels.push(`${street}, ${city}, ${state}`);
|
|
|
labels.push(formatAddressQuery(address));
|
|
|
}
|
|
|
const glued = mothershipLabel?.trim();
|
|
|
if (glued) {
|
|
|
labels.push(stripCountrySuffix(glued));
|
|
|
}
|
|
|
return [...new Set(labels.map((l) => l.trim()).filter(Boolean))];
|
|
|
}
|
|
|
|
|
|
function enrichCandidateFromQuery(
|
|
|
item: MothershipAddressCandidate,
|
|
|
query?: AddressLookupInput,
|
|
|
): MothershipAddressCandidate {
|
|
|
if (!query) {
|
|
|
return item;
|
|
|
}
|
|
|
const country = resolveCountry(query);
|
|
|
const zip = item.zip || query.zip;
|
|
|
|
|
|
let street = item.street.trim();
|
|
|
const queryStreetNum = queryStreetNumber(query);
|
|
|
const itemStreetNum = street.match(/^\d+/)?.[0];
|
|
|
if (queryStreetNum && !itemStreetNum) {
|
|
|
const nameFromItem = street.replace(/\s+/g, " ").trim();
|
|
|
const nameFromQuery = query.street.replace(/^\d+\s*/, "").trim();
|
|
|
const streetName = nameFromItem.length >= 3 ? nameFromItem : nameFromQuery;
|
|
|
street = `${queryStreetNum} ${streetName}`.trim();
|
|
|
}
|
|
|
|
|
|
const city = item.city.trim() || query.city.trim();
|
|
|
const state = item.state.trim() || query.state.trim();
|
|
|
const hasStructured = Boolean(city) && Boolean(state) && Boolean(street);
|
|
|
const formatted_address = hasStructured
|
|
|
? formatMothershipFormattedAddress(street, city, state, country)
|
|
|
: item.formatted_address;
|
|
|
const display_label =
|
|
|
queryStreetNum && !itemStreetNum && hasStructured
|
|
|
? `${toGluedStreetCity(street, city)}, ${state}`
|
|
|
: item.display_label;
|
|
|
|
|
|
return {
|
|
|
...item,
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip,
|
|
|
formatted_address,
|
|
|
display_label,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function toOptionId(label: string): string {
|
|
|
return `ms_${createHash("sha256").update(label).digest("hex").slice(0, 16)}`;
|
|
|
}
|
|
|
|
|
|
const US_STATE_NAME_TO_CODE: Record<string, string> = {
|
|
|
alabama: "AL",
|
|
|
alaska: "AK",
|
|
|
arizona: "AZ",
|
|
|
arkansas: "AR",
|
|
|
california: "CA",
|
|
|
colorado: "CO",
|
|
|
connecticut: "CT",
|
|
|
delaware: "DE",
|
|
|
florida: "FL",
|
|
|
georgia: "GA",
|
|
|
texas: "TX",
|
|
|
// 其余州按需扩展;MotherShip 常见 CA/TX
|
|
|
};
|
|
|
|
|
|
function resolveStateCode(raw: string, query?: AddressLookupInput): string {
|
|
|
const trimmed = raw.trim();
|
|
|
if (/^[A-Z]{2}$/.test(trimmed)) {
|
|
|
return trimmed;
|
|
|
}
|
|
|
const mapped = US_STATE_NAME_TO_CODE[trimmed.toLowerCase()];
|
|
|
if (mapped) {
|
|
|
return mapped;
|
|
|
}
|
|
|
return query?.state?.trim() || trimmed.slice(0, 2).toUpperCase();
|
|
|
}
|
|
|
|
|
|
/** 解析 pac-item 结构化标签 main|secondary 或粘连 innerText */
|
|
|
function parsePacStructuredLabel(
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): MothershipAddressCandidate | null {
|
|
|
const pipeIdx = label.indexOf("|");
|
|
|
if (pipeIdx > 0) {
|
|
|
const street = label.slice(0, pipeIdx).trim();
|
|
|
const secondary = stripCountrySuffix(label.slice(pipeIdx + 1).trim());
|
|
|
const parts = secondary.split(",").map((p) => p.trim()).filter(Boolean);
|
|
|
if (street.length >= 5 && parts.length >= 2) {
|
|
|
const city = parts[0];
|
|
|
const stateToken =
|
|
|
[...parts].reverse().find((part) => /^[A-Z]{2}$/.test(part)) ??
|
|
|
parts.find((part) => US_STATE_NAME_TO_CODE[part.toLowerCase()]);
|
|
|
const state = resolveStateCode(stateToken ?? parts[parts.length - 1], query);
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(label),
|
|
|
display_label: `${toGluedStreetCity(street, city)}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(street, city, state),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: query?.zip ?? "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const normalized = stripCountrySuffix(label.replace(/\s+/g, " ").trim());
|
|
|
|
|
|
// 含 suburb 格式:street city, suburb, state
|
|
|
const suburbMatch = normalized.match(
|
|
|
/^(\d+\s+(?:[\w.]+\s+)*?(?:Street|St|Blvd|Boulevard|Dr|Drive|Way|Road|Rd|Lane|Ln|Ave|Avenue)\.?)\s+([A-Z][A-Za-z\s.]+?),\s*([^,]+),\s*([A-Za-z]{2,})$/,
|
|
|
);
|
|
|
if (suburbMatch) {
|
|
|
const [, street, city, , stateRaw] = suburbMatch;
|
|
|
const state = resolveStateCode(stateRaw, query);
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(label),
|
|
|
display_label: `${toGluedStreetCity(street.trim(), city.trim())}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street.trim(),
|
|
|
city.trim(),
|
|
|
state,
|
|
|
),
|
|
|
street: street.trim(),
|
|
|
city: city.trim(),
|
|
|
state,
|
|
|
zip: query?.zip ?? "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
|
|
|
// 无门牌号街道(Fifth Avenue / Hollywood Boulevard 等)
|
|
|
const namedStreetMatch = normalized.match(
|
|
|
/^([A-Z][A-Za-z0-9\s.'-]*(?:Avenue|Ave|Boulevard|Blvd|Street|St|Road|Rd|Drive|Dr|Way|Lane|Ln|Place|Pl|Court|Ct|Highway|Hwy)(?:\s+(?:North|South|East|West|N|S|E|W|NE|NW|SE|SW))?)\s+([A-Z][A-Za-z\s.]+?),\s*([A-Z]{2})$/,
|
|
|
);
|
|
|
if (namedStreetMatch) {
|
|
|
const [, street, city, state] = namedStreetMatch;
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(label),
|
|
|
display_label: `${toGluedStreetCity(street.trim(), city.trim())}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street.trim(),
|
|
|
city.trim(),
|
|
|
state,
|
|
|
),
|
|
|
street: street.trim(),
|
|
|
city: city.trim(),
|
|
|
state,
|
|
|
zip: query?.zip ?? "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
|
|
|
// 无 suburb 格式 (styled-components):street city, state
|
|
|
// 例如:"1234 Warehouse Street Los Angeles, CA"
|
|
|
const directCityMatch = normalized.match(
|
|
|
/^(\d+\s+(?:[\w.]+\s+)*?(?:Street|St|Blvd|Boulevard|Dr|Drive|Way|Road|Rd|Lane|Ln|Ave|Avenue)\.?)\s+([A-Z][A-Za-z\s.]+?),\s*([A-Z]{2})$/,
|
|
|
);
|
|
|
if (directCityMatch) {
|
|
|
const [, street, city, state] = directCityMatch;
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(label),
|
|
|
display_label: `${toGluedStreetCity(street.trim(), city.trim())}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street.trim(),
|
|
|
city.trim(),
|
|
|
state,
|
|
|
),
|
|
|
street: street.trim(),
|
|
|
city: city.trim(),
|
|
|
state,
|
|
|
zip: query?.zip ?? "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 解析 MotherShip 联想文案(含 StreetLos Angeles 粘连格式) */
|
|
|
export function labelToCandidate(
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): MothershipAddressCandidate | null {
|
|
|
const trimmed = label.replace(/\s+/g, " ").trim();
|
|
|
if (trimmed.length < 5) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const pacParsed = parsePacStructuredLabel(trimmed, query);
|
|
|
if (pacParsed) {
|
|
|
return pacParsed;
|
|
|
}
|
|
|
|
|
|
const standard = trimmed.match(
|
|
|
/^(.+?),\s*([^,]+),\s*([A-Z]{2})\s*(\d{5}(?:-\d{4})?)/,
|
|
|
);
|
|
|
if (standard) {
|
|
|
const [, street, city, state, zip] = standard;
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: trimmed.replace(/\s+\d{5}(-\d{4})?$/, "").trim(),
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street.trim(),
|
|
|
city.trim(),
|
|
|
state,
|
|
|
),
|
|
|
street: street.trim(),
|
|
|
city: city.trim(),
|
|
|
state,
|
|
|
zip,
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
|
|
|
const tail = trimmed.match(/,\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
|
|
|
if (tail) {
|
|
|
const state = tail[1];
|
|
|
const zip = tail[2];
|
|
|
const beforeState = trimmed
|
|
|
.slice(0, trimmed.lastIndexOf(tail[0]))
|
|
|
.trim()
|
|
|
.replace(/,\s*$/, "");
|
|
|
const glued = beforeState.match(/^(.+?[a-z])([A-Z][\sA-Za-z.']+)$/);
|
|
|
if (glued) {
|
|
|
const street = glued[1].trim();
|
|
|
const city = glued[2].trim();
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: `${street}${city}, ${state}`.replace(
|
|
|
/\s+\d{5}(-\d{4})?$/,
|
|
|
"",
|
|
|
),
|
|
|
formatted_address: formatMothershipFormattedAddress(street, city, state),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip,
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
if (query && query.zip === zip) {
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: trimmed.replace(/\s+\d{5}(-\d{4})?$/, "").trim(),
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
query.street,
|
|
|
query.city,
|
|
|
query.state,
|
|
|
resolveCountry(query),
|
|
|
),
|
|
|
street: query.street,
|
|
|
city: query.city,
|
|
|
state: query.state,
|
|
|
zip,
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 去掉 ", USA" / ", US" 等国家后缀后再匹配
|
|
|
const trimmedNoCountry = stripCountrySuffix(trimmed);
|
|
|
const stateOnly = trimmedNoCountry.match(/,\s*([A-Z]{2})\s*$/);
|
|
|
if (stateOnly && query && query.state === stateOnly[1]) {
|
|
|
const state = stateOnly[1];
|
|
|
const beforeState = trimmedNoCountry
|
|
|
.slice(0, trimmedNoCountry.lastIndexOf(stateOnly[0]))
|
|
|
.trim();
|
|
|
const glued = beforeState.match(/^(.+?[a-z])([A-Z][\sA-Za-z.']+)$/);
|
|
|
if (glued) {
|
|
|
const street = glued[1].trim();
|
|
|
const city = glued[2].trim();
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: trimmed,
|
|
|
formatted_address: formatMothershipFormattedAddress(street, city, state),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
// 标准逗号分隔格式(无 ZIP):street, city, state
|
|
|
const commaSpaced = beforeState.match(/^(.+?),\s*([^,]+)$/);
|
|
|
if (commaSpaced) {
|
|
|
const street = commaSpaced[1].trim();
|
|
|
const city = commaSpaced[2].trim();
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: `${toGluedStreetCity(street, city)}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(street, city, state),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: query?.zip ?? "",
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (query && isLikelySuggestionRow(trimmed, query)) {
|
|
|
const normalized = normalizeDomSuggestionLabel(trimmed);
|
|
|
const spaced = normalized.match(/^(.+?),\s*([^,]+),\s*([A-Z]{2})$/);
|
|
|
if (spaced) {
|
|
|
const street = normalizeParsedStreet(spaced[1], query);
|
|
|
const city = spaced[2].trim();
|
|
|
const state = spaced[3];
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(trimmed),
|
|
|
display_label: `${toGluedStreetCity(street, city)}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
resolveCountry(query),
|
|
|
),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: query.zip,
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const streetOnly = parseStreetOnlySuggestion(trimmed, query);
|
|
|
if (streetOnly) {
|
|
|
return streetOnly;
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** MotherShip listbox 仅返回街道行(无城市/州)时的候选补全 */
|
|
|
function parseStreetOnlySuggestion(
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): MothershipAddressCandidate | null {
|
|
|
if (!query) {
|
|
|
return null;
|
|
|
}
|
|
|
const trimmed = label.replace(/\s+/g, " ").trim();
|
|
|
if (trimmed.includes(",") || trimmed.includes("|")) {
|
|
|
return null;
|
|
|
}
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0];
|
|
|
if (!streetNum || !trimmed.startsWith(streetNum)) {
|
|
|
return null;
|
|
|
}
|
|
|
if (trimmed.length < 8 || trimmed.length > MAX_SUGGESTION_ROW_LEN) {
|
|
|
return null;
|
|
|
}
|
|
|
if (
|
|
|
!/\b(street|st|avenue|ave|blvd|boulevard|dr|drive|road|rd|lane|ln|way)\b/i.test(
|
|
|
trimmed,
|
|
|
)
|
|
|
) {
|
|
|
return null;
|
|
|
}
|
|
|
if (isInputEcho(trimmed, query)) {
|
|
|
return null;
|
|
|
}
|
|
|
const street = trimmed;
|
|
|
const city = query.city.trim();
|
|
|
const state = query.state.trim();
|
|
|
const item: MothershipAddressCandidate = {
|
|
|
option_id: toOptionId(`${trimmed}|${city}|${state}`),
|
|
|
display_label: `${toGluedStreetCity(street, city)}, ${state}`,
|
|
|
formatted_address: formatMothershipFormattedAddress(
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
resolveCountry(query),
|
|
|
),
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: query.zip,
|
|
|
};
|
|
|
return enrichCandidateFromQuery(item, query);
|
|
|
}
|
|
|
|
|
|
/** 候选是否为「用户输入回显」而非 MotherShip 独立坐标 */
|
|
|
export function isMotherShipEchoCandidate(
|
|
|
item: MothershipAddressCandidate,
|
|
|
query: AddressLookupInput,
|
|
|
): boolean {
|
|
|
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
|
if (norm(item.street) !== norm(query.street)) {
|
|
|
return false;
|
|
|
}
|
|
|
if (norm(item.city) !== norm(query.city)) {
|
|
|
return false;
|
|
|
}
|
|
|
const glued = `${toGluedStreetCity(query.street, query.city)}, ${query.state}`;
|
|
|
const compact = (s: string) => s.replace(/\s+/g, "").toLowerCase();
|
|
|
return (
|
|
|
compact(item.display_label) === compact(glued) ||
|
|
|
compact(item.display_label) ===
|
|
|
compact(`${query.street}, ${query.city}, ${query.state}`)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function getQuoteWidgetLocator(page: Page): Locator {
|
|
|
return pageLocator(page, getSelector("RPA_SELECTOR_QUOTE_WIDGET")).first();
|
|
|
}
|
|
|
|
|
|
function isInputEcho(text: string, query: AddressLookupInput): boolean {
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
for (const candidate of buildAddressAutocompleteQueries(query)) {
|
|
|
if (normalized === candidate || normalized === stripCountrySuffix(candidate)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return (
|
|
|
normalized === `${query.street}, ${query.city}, ${query.state}` ||
|
|
|
normalized ===
|
|
|
`${query.street}, ${query.city}, ${query.state}, ${resolveCountry(query)}`
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function isMotherShipEnumerationRow(
|
|
|
text: string,
|
|
|
query: AddressLookupInput,
|
|
|
): boolean {
|
|
|
if (/use my location|使用我的位置/i.test(text)) {
|
|
|
return false;
|
|
|
}
|
|
|
if (text.includes("|")) {
|
|
|
const main = text.split("|")[0]?.trim() ?? "";
|
|
|
const secondary = text.slice(text.indexOf("|") + 1).trim();
|
|
|
if (main.length < 5 || secondary.length < 2) {
|
|
|
return false;
|
|
|
}
|
|
|
const compact = (s: string) =>
|
|
|
s.replace(/\s+/g, "").toLowerCase().replace(/,usa$/i, "");
|
|
|
const echoSecondary = compact(`${query.city}, ${query.state}`);
|
|
|
if (compact(secondary).startsWith(echoSecondary)) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
const probe = text;
|
|
|
if (isInputEcho(probe, query) || isInputEcho(text, query)) {
|
|
|
return false;
|
|
|
}
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
if (normalized.length < 8) {
|
|
|
return false;
|
|
|
}
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0];
|
|
|
if (streetNum && normalized.includes(streetNum)) {
|
|
|
return true;
|
|
|
}
|
|
|
const cityToken = query.city.trim().split(/\s+/)[0]?.toLowerCase();
|
|
|
return Boolean(
|
|
|
cityToken &&
|
|
|
cityToken.length >= 3 &&
|
|
|
normalized.toLowerCase().includes(cityToken),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** @internal 单测:listbox pipe 行过滤 */
|
|
|
export function isMotherShipEnumerationRowForTest(
|
|
|
text: string,
|
|
|
query: AddressLookupInput,
|
|
|
): boolean {
|
|
|
return isMotherShipEnumerationRow(text, query);
|
|
|
}
|
|
|
|
|
|
function isLikelySuggestionRow(
|
|
|
text: string,
|
|
|
query: AddressLookupInput,
|
|
|
): boolean {
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
if (normalized.length < 8 || normalized.length > MAX_SUGGESTION_ROW_LEN) {
|
|
|
return false;
|
|
|
}
|
|
|
if (isInputEcho(normalized, query)) {
|
|
|
return false;
|
|
|
}
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0];
|
|
|
if (streetNum) {
|
|
|
if (!normalized.includes(streetNum)) {
|
|
|
return false;
|
|
|
}
|
|
|
} else {
|
|
|
const streetTokens = query.street
|
|
|
.trim()
|
|
|
.toLowerCase()
|
|
|
.split(/\s+/)
|
|
|
.filter((token) => token.length >= 4);
|
|
|
if (
|
|
|
streetTokens.length > 0 &&
|
|
|
!streetTokens.some((token) => normalized.toLowerCase().includes(token))
|
|
|
) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
if (query.state && normalized.includes(query.state)) {
|
|
|
return true;
|
|
|
}
|
|
|
const cityToken = query.city.trim().split(/\s+/)[0];
|
|
|
if (
|
|
|
cityToken.length >= 3 &&
|
|
|
normalized.toLowerCase().includes(cityToken.toLowerCase())
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
const streetWord = query.street.split(/\s+/)[1];
|
|
|
return Boolean(
|
|
|
streetWord &&
|
|
|
streetWord.length >= 3 &&
|
|
|
normalized.toLowerCase().includes(streetWord.toLowerCase()),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function streetSearchTokens(query: AddressLookupInput): string[] {
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0] ?? "";
|
|
|
const streetWords = query.street.split(/\s+/).slice(1, 3).join(" ").trim();
|
|
|
const tokens = [
|
|
|
streetNum && streetWords ? `${streetNum} ${streetWords}` : "",
|
|
|
streetNum,
|
|
|
].filter((token) => token.length >= 4);
|
|
|
return [...new Set(tokens)];
|
|
|
}
|
|
|
|
|
|
function normalizeListboxLabel(text: string): string {
|
|
|
const lines = text
|
|
|
.split(/\r?\n/)
|
|
|
.map((line) => line.trim())
|
|
|
.filter(Boolean);
|
|
|
if (lines.length >= 2 && !/use my location|使用我的位置/i.test(lines.join(" "))) {
|
|
|
return `${lines[0]}|${lines.slice(1).join(", ")}`;
|
|
|
}
|
|
|
return text.replace(/\s+/g, " ").trim();
|
|
|
}
|
|
|
|
|
|
async function collectSuggestionLabelsFromWidget(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const rawLabels: string[] = ensureArray(
|
|
|
await page
|
|
|
.evaluate(COLLECT_WIDGET_SUGGESTION_LABELS)
|
|
|
.catch(() => []) as string[] | null | undefined,
|
|
|
) as string[];
|
|
|
// 不过滤:COLLECT_WIDGET_SUGGESTION_LABELS 已内置 skipRe 过滤
|
|
|
// 对于 styled-components 检测,过严的 isMotherShipEnumerationRow 会漏掉合法联想
|
|
|
if (rawLabels.length > 0) {
|
|
|
return rawLabels;
|
|
|
}
|
|
|
// 回退:使用严格过滤
|
|
|
return rawLabels.filter((label) => isMotherShipEnumerationRow(label, query));
|
|
|
}
|
|
|
|
|
|
async function collectEnumerationLabels(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const widgetLabels = await collectSuggestionLabelsFromWidget(page, query);
|
|
|
if (widgetLabels.length > 0) {
|
|
|
return widgetLabels;
|
|
|
}
|
|
|
const merged = await collectAddressSuggestionLabels(page, query);
|
|
|
const filtered = merged.filter((label) =>
|
|
|
isMotherShipEnumerationRow(label, query),
|
|
|
);
|
|
|
// 为何:waitFor 已确认 DOM 有联想,过严 filter 会把 pac-item 真实候选全部滤掉
|
|
|
return filtered.length > 0 ? filtered : merged;
|
|
|
}
|
|
|
|
|
|
async function collectSuggestionLabelsFromPacStructured(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const rawLabels: string[] = ensureArray(
|
|
|
await page
|
|
|
.evaluate(COLLECT_PAC_ITEM_LABELS)
|
|
|
.catch(() => []) as string[] | null | undefined,
|
|
|
) as string[];
|
|
|
return rawLabels.filter((label: string) => {
|
|
|
const main = label.includes("|") ? label.split("|")[0] : label;
|
|
|
return isLikelySuggestionRow(main, query) || isLikelySuggestionRow(label, query);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function collectSuggestionLabelsFromPacContainer(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const labels: string[] = [];
|
|
|
const roots = [
|
|
|
page.locator(".pac-container"),
|
|
|
page.locator('[role="listbox"]'),
|
|
|
page.locator('[class*="suggestion" i], [class*="Suggestion"]'),
|
|
|
];
|
|
|
|
|
|
for (const root of roots) {
|
|
|
const rootCount = await root.count().catch(() => 0);
|
|
|
for (let r = 0; r < rootCount; r += 1) {
|
|
|
const container = root.nth(r);
|
|
|
if (!(await container.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
let itemLocator = container.locator(".pac-item, [role='option']");
|
|
|
let count = await itemLocator.count().catch(() => 0);
|
|
|
if (count === 0) {
|
|
|
itemLocator = container.locator("li, button");
|
|
|
count = await itemLocator.count().catch(() => 0);
|
|
|
}
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const text = await itemLocator.nth(i).innerText().catch(() => "");
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
if (isLikelySuggestionRow(normalized, query)) {
|
|
|
labels.push(normalized);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return labels;
|
|
|
}
|
|
|
|
|
|
/** 对齐录制:page.getByText(门牌号+街道) 点选,下拉项常不含 zip */
|
|
|
async function collectSuggestionLabelsByTextMatch(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const labels: string[] = [];
|
|
|
|
|
|
for (const token of streetSearchTokens(query)) {
|
|
|
const loc = page.getByText(token, { exact: false });
|
|
|
const count = await loc.count().catch(() => 0);
|
|
|
for (let i = 0; i < Math.min(count, 12); i += 1) {
|
|
|
const el = loc.nth(i);
|
|
|
if (!(await el.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
const text = await el.innerText().catch(() => "");
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
if (isLikelySuggestionRow(normalized, query)) {
|
|
|
labels.push(normalized);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return labels;
|
|
|
}
|
|
|
|
|
|
async function collectSuggestionLabelsFromSpecs(page: Page): Promise<string[]> {
|
|
|
const specs = getSelectorSpecs("RPA_SELECTOR_ADDRESS_SUGGESTION");
|
|
|
const labels: string[] = [];
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
for (const spec of specs) {
|
|
|
const root = pageLocator(page, spec);
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
const text = await root
|
|
|
.nth(i)
|
|
|
.innerText()
|
|
|
.catch(() => "");
|
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
if (normalized && !seen.has(normalized)) {
|
|
|
seen.add(normalized);
|
|
|
labels.push(normalizeListboxLabel(text));
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return labels;
|
|
|
}
|
|
|
|
|
|
/** MotherShip 联想:全页扫描(含 body 级 .pac-container portal) */
|
|
|
async function collectSuggestionLabelsByHeuristic(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0] ?? "";
|
|
|
const cityToken = query.city.trim().split(/\s+/)[0] ?? "";
|
|
|
|
|
|
const rawLabels: string[] = ensureArray(
|
|
|
await page
|
|
|
.evaluate(COLLECT_ADDRESS_LABELS_FROM_DOM, {
|
|
|
streetNumber: streetNum,
|
|
|
cityHint: cityToken,
|
|
|
})
|
|
|
.catch(() => []) as string[] | null | undefined,
|
|
|
) as string[];
|
|
|
|
|
|
return rawLabels.filter((label: string) => isLikelySuggestionRow(label, query));
|
|
|
}
|
|
|
|
|
|
export async function collectAddressSuggestionLabels(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const seen = new Set<string>();
|
|
|
const merged: string[] = [];
|
|
|
|
|
|
for (const label of [
|
|
|
// 优先:COLLECT_WIDGET_SUGGESTION_LABELS(含 styled-components 检测)
|
|
|
...(await collectSuggestionLabelsFromWidget(page, query)),
|
|
|
...(await collectSuggestionLabelsFromPacStructured(page, query)),
|
|
|
...(await collectSuggestionLabelsFromPacContainer(page, query)),
|
|
|
...(await collectSuggestionLabelsFromSpecs(page)),
|
|
|
...(await collectSuggestionLabelsByTextMatch(page, query)),
|
|
|
...(await collectSuggestionLabelsByHeuristic(page, query)),
|
|
|
]) {
|
|
|
const normalized = label.replace(/\s+/g, " ").trim();
|
|
|
if (normalized && !seen.has(normalized)) {
|
|
|
seen.add(normalized);
|
|
|
merged.push(normalized);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return merged;
|
|
|
}
|
|
|
|
|
|
/** 等待下拉项数量稳定后再采集(避免首条即返回漏掉其余选项) */
|
|
|
export async function collectAddressSuggestionLabelsWhenStable(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
options?: { timeoutMs?: number },
|
|
|
): Promise<string[]> {
|
|
|
const timeoutMs =
|
|
|
options?.timeoutMs ?? ADDRESS_CANDIDATE_SUGGESTION_TIMEOUT_MS;
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
let best: string[] = [];
|
|
|
let stablePasses = 0;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
const labels = await collectAddressSuggestionLabels(page, query);
|
|
|
if (labels.length >= 2) {
|
|
|
return labels;
|
|
|
}
|
|
|
if (labels.length > best.length) {
|
|
|
best = labels;
|
|
|
stablePasses = 0;
|
|
|
} else if (labels.length === best.length && best.length > 0) {
|
|
|
stablePasses += 1;
|
|
|
if (stablePasses >= SUGGESTION_STABLE_PASSES) {
|
|
|
return best;
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(SUGGESTION_STABLE_POLL_MS);
|
|
|
}
|
|
|
|
|
|
return best;
|
|
|
}
|
|
|
|
|
|
/** 候选 API:多 query 尝试 + WhenStable 采集(不依赖 waitFor 误报) */
|
|
|
export async function enumerateAddressCandidates(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
address: AddressLookupInput,
|
|
|
): Promise<string[]> {
|
|
|
const queries = buildAddressEnumerationQueries(address);
|
|
|
const seen = new Set<string>();
|
|
|
const merged: string[] = [];
|
|
|
let lastQuery = queries[0] ?? formatAddressQuery(address);
|
|
|
const waitMs = ADDRESS_CANDIDATE_SUGGESTION_TIMEOUT_MS;
|
|
|
|
|
|
const absorb = (labels: string[]) => {
|
|
|
for (const label of labels) {
|
|
|
const normalized = label.replace(/\s+/g, " ").trim();
|
|
|
if (normalized && !seen.has(normalized)) {
|
|
|
seen.add(normalized);
|
|
|
merged.push(normalized);
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
|
|
|
for (let i = 0; i < queries.length; i += 1) {
|
|
|
const queryText = queries[i];
|
|
|
lastQuery = queryText;
|
|
|
|
|
|
await triggerAddressAutocomplete(input, queryText, page, {
|
|
|
suggestionTimeoutMs: waitMs,
|
|
|
forEnumeration: true,
|
|
|
});
|
|
|
|
|
|
absorb(
|
|
|
await collectAddressSuggestionLabelsWhenStable(page, address, {
|
|
|
timeoutMs: waitMs,
|
|
|
}),
|
|
|
);
|
|
|
|
|
|
const streetNum = queryStreetNumber(address);
|
|
|
if (
|
|
|
merged.length > 0 &&
|
|
|
(streetNum == null || labelsContainStreetNumber(merged, streetNum))
|
|
|
) {
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (merged.length === 0) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`MotherShip 地址联想未出现:${lastQuery}`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return merged;
|
|
|
}
|
|
|
|
|
|
export async function triggerAddressAutocomplete(
|
|
|
input: Locator,
|
|
|
query: string,
|
|
|
page?: Page,
|
|
|
options?: { suggestionTimeoutMs?: number; forEnumeration?: boolean },
|
|
|
): Promise<void> {
|
|
|
const suggestionTimeoutMs =
|
|
|
options?.suggestionTimeoutMs ?? ADDRESS_SUGGESTION_TIMEOUT_MS;
|
|
|
const forEnumeration = options?.forEnumeration ?? false;
|
|
|
|
|
|
await input.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
|
|
|
const focused = await input
|
|
|
.focus({ timeout: RPA_STREET_INPUT_CLICK_MS })
|
|
|
.then(() => true)
|
|
|
.catch(() => false);
|
|
|
|
|
|
if (!focused) {
|
|
|
await input.click({ timeout: 3_000, force: true }).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
const currentValue = normalizeAddressInputValue(
|
|
|
(await input.inputValue().catch(() => "")) || "",
|
|
|
);
|
|
|
if (addressInputMatchesQuery(currentValue, query)) {
|
|
|
await input.dispatchEvent("input").catch(() => undefined);
|
|
|
await input.dispatchEvent("change").catch(() => undefined);
|
|
|
if (page) {
|
|
|
await awaitHumanPacing(page);
|
|
|
const appeared = await waitForGooglePlacesPredictions(
|
|
|
page,
|
|
|
suggestionTimeoutMs,
|
|
|
);
|
|
|
if (!appeared && !forEnumeration) {
|
|
|
await input.click({ timeout: 3_000, force: true }).catch(() => undefined);
|
|
|
await waitForGooglePlacesPredictions(page, suggestionTimeoutMs);
|
|
|
}
|
|
|
if (!forEnumeration) {
|
|
|
const hasPac = await page
|
|
|
.locator(".pac-container .pac-item")
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (hasPac) {
|
|
|
await input.press("ArrowDown").catch(() => undefined);
|
|
|
await page.waitForTimeout(150);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
await input.fill("", { timeout: suggestionTimeoutMs });
|
|
|
await input.fill(query, { timeout: suggestionTimeoutMs });
|
|
|
} catch {
|
|
|
await input.press("Control+A").catch(() => undefined);
|
|
|
await input.press("Backspace").catch(() => undefined);
|
|
|
await input.pressSequentially(query, { delay: 25 });
|
|
|
}
|
|
|
|
|
|
await input.dispatchEvent("input").catch(() => undefined);
|
|
|
await input.dispatchEvent("change").catch(() => undefined);
|
|
|
if (page) {
|
|
|
await awaitHumanPacing(page);
|
|
|
const appeared = await waitForGooglePlacesPredictions(
|
|
|
page,
|
|
|
suggestionTimeoutMs,
|
|
|
);
|
|
|
// 某些地址(如 7000 NW 52nd St, Miami, FL)fill 写值不触发 Google 联想:
|
|
|
// 逐字符重输产生真实 keydown/input 序列,重新等待联想请求完成
|
|
|
if (!appeared) {
|
|
|
await input.click({ timeout: 3_000, force: true }).catch(() => undefined);
|
|
|
await input.press("Control+A").catch(() => undefined);
|
|
|
await input.press("Backspace").catch(() => undefined);
|
|
|
await input.pressSequentially(query, { delay: 60 }).catch(() => undefined);
|
|
|
await waitForGooglePlacesPredictions(page, suggestionTimeoutMs);
|
|
|
}
|
|
|
if (!forEnumeration) {
|
|
|
const hasPac = await page
|
|
|
.locator(".pac-container .pac-item")
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
// MotherShip 主路径为 styled-components 联想;ArrowDown 会把焦点移到 Use my location
|
|
|
if (hasPac) {
|
|
|
await input.press("ArrowDown").catch(() => undefined);
|
|
|
await page.waitForTimeout(150);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 返回联想是否出现(Google Places 响应、.pac-item、或 styled-components 联想可见) */
|
|
|
async function waitForGooglePlacesPredictions(
|
|
|
page: Page,
|
|
|
timeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
return Promise.race([
|
|
|
// Google Places API 响应
|
|
|
page
|
|
|
.waitForResponse(
|
|
|
(response) =>
|
|
|
response.url().includes("maps.googleapis.com") &&
|
|
|
/Autocomplete|Autocompletion|place/i.test(response.url()) &&
|
|
|
response.status() === 200,
|
|
|
{ timeout: timeoutMs },
|
|
|
)
|
|
|
.then(() => true),
|
|
|
// 标准 pac-container
|
|
|
page
|
|
|
.waitForSelector(".pac-container .pac-item", {
|
|
|
state: "visible",
|
|
|
timeout: timeoutMs,
|
|
|
})
|
|
|
.then(() => true),
|
|
|
// MotherShip styled-components 联想(轮询检测)
|
|
|
(async () => {
|
|
|
const pollInterval = 200;
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
// 检测 styled-components 联想(特征:包含州缩写的 sc- div)
|
|
|
const found = await page.evaluate(() => {
|
|
|
const stateRe = /(USA|US|CA|NY|TX|FL|IL|PA|OH|GA|NC|MI|NJ|VA|WA|AZ|MA|TN|IN|MO|MD|WI|CO|MN|SC|AL|LA|KY|OR|OK|CT|UT|IA|NV|AR|MS|KS|NM|NE|WV|ID|HI|NH|ME|MT|RI|DE|SD|ND|AK|DC|VT|WY|PR)/;
|
|
|
const skipRe = /use my location|使用我的位置|get a freight|freight details|pick up from|deliver to|where to/i;
|
|
|
let count = 0;
|
|
|
document.querySelectorAll("div[class*='sc-']").forEach((el) => {
|
|
|
const text = (el.textContent || "").replace(/\\s+/g, " ").trim();
|
|
|
if (text.length >= 15 && text.length <= 150 && text.includes(",") && stateRe.test(text) && !skipRe.test(text)) {
|
|
|
const rect = (el as HTMLElement).getBoundingClientRect();
|
|
|
if (rect.width > 300 && rect.height >= 30 && rect.height <= 70) {
|
|
|
count++;
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
return count >= 1;
|
|
|
}).catch(() => false);
|
|
|
if (found) return true;
|
|
|
await page.waitForTimeout(pollInterval);
|
|
|
}
|
|
|
return false;
|
|
|
})(),
|
|
|
]).catch(() => false);
|
|
|
}
|
|
|
|
|
|
/** 多策略键入直至 MotherShip 联想出现 */
|
|
|
export async function ensureAddressSuggestions(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
address: AddressLookupInput,
|
|
|
options?: {
|
|
|
mothershipLabel?: string;
|
|
|
suggestionTimeoutMs?: number;
|
|
|
maxQueries?: number;
|
|
|
preferConfirmedLabel?: boolean;
|
|
|
},
|
|
|
): Promise<string> {
|
|
|
const resolved = resolveAddressAutocompleteInput(address, options);
|
|
|
const allQueries = buildAddressAutocompleteQueries(address, options);
|
|
|
const queries = allQueries.slice(
|
|
|
0,
|
|
|
options?.maxQueries ?? allQueries.length,
|
|
|
);
|
|
|
let lastQuery = queries[0] ?? formatAddressQuery(resolved);
|
|
|
const suggestionTimeoutMs =
|
|
|
options?.suggestionTimeoutMs ?? ADDRESS_SUGGESTION_TIMEOUT_MS;
|
|
|
const streetNum = queryStreetNumber(resolved);
|
|
|
let fallbackQuery = "";
|
|
|
|
|
|
for (const query of queries) {
|
|
|
lastQuery = query;
|
|
|
await triggerAddressAutocomplete(input, query, page, {
|
|
|
suggestionTimeoutMs,
|
|
|
});
|
|
|
if (
|
|
|
await waitForAddressSuggestions(page, resolved, suggestionTimeoutMs)
|
|
|
) {
|
|
|
if (streetNum) {
|
|
|
const labels = await collectAddressSuggestionLabelsWhenStable(
|
|
|
page,
|
|
|
resolved,
|
|
|
{ timeoutMs: Math.min(suggestionTimeoutMs, 5_000) },
|
|
|
);
|
|
|
if (labelsContainStreetNumber(labels, streetNum)) {
|
|
|
return query;
|
|
|
}
|
|
|
if (!fallbackQuery) {
|
|
|
fallbackQuery = query;
|
|
|
}
|
|
|
continue;
|
|
|
}
|
|
|
return query;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (fallbackQuery) {
|
|
|
return fallbackQuery;
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`MotherShip 地址联想未出现:${lastQuery}`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function hasVisibleSuggestionItems(page: Page): Promise<boolean> {
|
|
|
const roots = [
|
|
|
page.locator(".pac-container .pac-item"),
|
|
|
page.locator('[role="listbox"] [role="option"]'),
|
|
|
];
|
|
|
for (const root of roots) {
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
if (count > 0) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
export async function waitForAddressSuggestions(
|
|
|
page: Page,
|
|
|
query: AddressLookupInput,
|
|
|
timeoutMs = ADDRESS_SUGGESTION_TIMEOUT_MS,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
let pass = 0;
|
|
|
while (Date.now() < deadline) {
|
|
|
pass += 1;
|
|
|
const quickHit =
|
|
|
pass <= 2 || (await hasVisibleSuggestionItems(page));
|
|
|
if (quickHit) {
|
|
|
const labels = await collectAddressSuggestionLabels(page, query);
|
|
|
if (labels.length > 0) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(POLL_INTERVAL_MS);
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
function suggestionMatchKeys(
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): string[] {
|
|
|
const keys: string[] = [];
|
|
|
const trimmed = label.replace(/\s+/g, " ").trim();
|
|
|
|
|
|
for (const variant of gluedVariants(trimmed)) {
|
|
|
if (variant.length >= 8) {
|
|
|
keys.push(variant.slice(0, Math.min(32, variant.length)));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (trimmed.length >= 12) {
|
|
|
keys.push(trimmed.slice(0, Math.min(40, trimmed.length)));
|
|
|
}
|
|
|
|
|
|
const city = query?.city.trim();
|
|
|
const streetNum = query?.street.match(/^\d+/)?.[0];
|
|
|
if (streetNum && city && city.includes(" ")) {
|
|
|
keys.push(`${streetNum} ${city}`);
|
|
|
}
|
|
|
|
|
|
return [...new Set(keys)].filter((key) => key.length >= 10);
|
|
|
}
|
|
|
|
|
|
/** 在当前页面下拉中解析与用户确认项最匹配的 DOM 文案 */
|
|
|
export function scoreSuggestionLabelMatch(
|
|
|
userLabel: string,
|
|
|
domLabel: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): number {
|
|
|
const userVariants = gluedVariants(userLabel).map((v) => v.toLowerCase());
|
|
|
const dom = normalizeDomSuggestionLabel(domLabel).toLowerCase();
|
|
|
if (!dom) {
|
|
|
return 0;
|
|
|
}
|
|
|
|
|
|
for (const variant of userVariants) {
|
|
|
if (variant === dom) {
|
|
|
return 100;
|
|
|
}
|
|
|
if (variant.length >= 12 && dom.includes(variant)) {
|
|
|
return 100;
|
|
|
}
|
|
|
if (dom.length >= 12 && variant.includes(dom)) {
|
|
|
return 100;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const streetNum = query?.street.match(/^\d+/)?.[0];
|
|
|
if (streetNum) {
|
|
|
if (!dom.includes(streetNum)) {
|
|
|
return 0;
|
|
|
}
|
|
|
} else {
|
|
|
const streetTokens = (query?.street ?? "")
|
|
|
.trim()
|
|
|
.toLowerCase()
|
|
|
.split(/\s+/)
|
|
|
.filter((token) => token.length >= 4);
|
|
|
if (
|
|
|
streetTokens.length > 0 &&
|
|
|
!streetTokens.some((token) => dom.includes(token))
|
|
|
) {
|
|
|
return 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let score = 40;
|
|
|
if (query?.state && dom.includes(query.state.toLowerCase())) {
|
|
|
score += 20;
|
|
|
}
|
|
|
const city = query?.city.trim().toLowerCase();
|
|
|
if (city && city.length >= 3) {
|
|
|
if (dom.includes(city)) {
|
|
|
score += 40;
|
|
|
} else {
|
|
|
const cityParts = city.split(/\s+/).filter((part) => part.length >= 3);
|
|
|
if (
|
|
|
cityParts.length > 1 &&
|
|
|
cityParts.every((part) => new RegExp(`\\b${part}\\b`).test(dom))
|
|
|
) {
|
|
|
score += 25;
|
|
|
} else if (cityParts.length === 1) {
|
|
|
const token = cityParts[0];
|
|
|
if (new RegExp(`\\b${token}\\b`).test(dom)) {
|
|
|
score += 20;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
const streetWord = query?.street.split(/\s+/)[1]?.toLowerCase();
|
|
|
if (streetWord && streetWord.length >= 3 && dom.includes(streetWord)) {
|
|
|
score += 15;
|
|
|
}
|
|
|
return score;
|
|
|
}
|
|
|
|
|
|
export async function resolveDomSuggestionLabel(
|
|
|
page: Page,
|
|
|
userLabel: string,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<string | null> {
|
|
|
const labels = await collectAddressSuggestionLabels(page, query);
|
|
|
let best: { label: string; score: number } | null = null;
|
|
|
|
|
|
for (const label of labels) {
|
|
|
const score = scoreSuggestionLabelMatch(userLabel, label, query);
|
|
|
if (!best || score > best.score) {
|
|
|
best = { label, score };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return best && best.score >= 50 ? best.label : null;
|
|
|
}
|
|
|
|
|
|
/** 用键盘在联想列表中选中评分最高的项 */
|
|
|
export async function selectSuggestionByKeyboard(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
userLabel: string,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const labels = await collectAddressSuggestionLabels(page, query);
|
|
|
let bestIndex = -1;
|
|
|
let bestScore = 0;
|
|
|
labels.forEach((domLabel, index) => {
|
|
|
const score = scoreSuggestionLabelMatch(userLabel, domLabel, query);
|
|
|
if (score > bestScore) {
|
|
|
bestScore = score;
|
|
|
bestIndex = index;
|
|
|
}
|
|
|
});
|
|
|
if (bestIndex < 0 || bestScore < 50) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
await input.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(150);
|
|
|
for (let i = 0; i <= bestIndex; i += 1) {
|
|
|
await page.keyboard.press("ArrowDown");
|
|
|
await page.waitForTimeout(120);
|
|
|
}
|
|
|
await page.keyboard.press("Enter");
|
|
|
await page.waitForTimeout(200);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 定位用户确认的联想项(评分 + 联想容器内查找,避免短键误点) */
|
|
|
export async function findAddressSuggestionLocator(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): Promise<Locator | null> {
|
|
|
if (query) {
|
|
|
const labels = await collectAddressSuggestionLabels(page, query);
|
|
|
let best: { label: string; score: number } | null = null;
|
|
|
for (const domLabel of labels) {
|
|
|
const score = scoreSuggestionLabelMatch(label, domLabel, query);
|
|
|
if (!best || score > best.score) {
|
|
|
best = { label: domLabel, score };
|
|
|
}
|
|
|
}
|
|
|
if (best && best.score >= 50) {
|
|
|
const scored = await locateSuggestionByLabelText(page, best.label);
|
|
|
if (scored) {
|
|
|
return scored;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const specs = getSelectorSpecs("RPA_SELECTOR_ADDRESS_SUGGESTION");
|
|
|
for (const spec of specs) {
|
|
|
const loc = pageLocator(page, spec).filter({ hasText: label }).first();
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
for (const variant of gluedVariants(label)) {
|
|
|
const located = await locateSuggestionByLabelText(page, variant);
|
|
|
if (located) {
|
|
|
return located;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const widget = getQuoteWidgetLocator(page);
|
|
|
for (const key of suggestionMatchKeys(label, query)) {
|
|
|
const partial = widget.getByText(key, { exact: false }).first();
|
|
|
if (await partial.isVisible().catch(() => false)) {
|
|
|
return partial;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** Google Places 打开联想时默认高亮第 0 项;index 即所需 ArrowDown 次数 */
|
|
|
export function arrowDownCountForPacIndex(index: number): number {
|
|
|
return Math.max(0, index);
|
|
|
}
|
|
|
|
|
|
async function selectSuggestionByBoundIndex(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
index: number,
|
|
|
options?: { alreadyHighlightedFirst?: boolean },
|
|
|
): Promise<void> {
|
|
|
await input.focus().catch(() => input.click({ force: true }).catch(() => undefined));
|
|
|
await page.waitForTimeout(100);
|
|
|
|
|
|
const highlighted = options?.alreadyHighlightedFirst ?? true;
|
|
|
const arrowDownCount = highlighted
|
|
|
? arrowDownCountForPacIndex(index)
|
|
|
: index + 1;
|
|
|
for (let i = 0; i < arrowDownCount; i += 1) {
|
|
|
await page.keyboard.press("ArrowDown");
|
|
|
await page.waitForTimeout(80);
|
|
|
}
|
|
|
await page.keyboard.press("Enter");
|
|
|
await page.waitForTimeout(200);
|
|
|
}
|
|
|
|
|
|
async function clickPacItemAtIndex(page: Page, index: number): Promise<boolean> {
|
|
|
const items = page.locator(".pac-container .pac-item");
|
|
|
const count = await items.count().catch(() => 0);
|
|
|
if (index < 0 || index >= count) {
|
|
|
return false;
|
|
|
}
|
|
|
const item = items.nth(index);
|
|
|
if (!(await item.isVisible().catch(() => false))) {
|
|
|
return false;
|
|
|
}
|
|
|
const queryLine = item.locator(".pac-item-query").first();
|
|
|
const target =
|
|
|
(await queryLine.isVisible().catch(() => false)) ? queryLine : item;
|
|
|
await clickLocatedSuggestionWithPointer(page, target);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 在页面上下文派发 pac-item 完整鼠标事件(绕过 Playwright click 未触发 handler 的情况) */
|
|
|
async function dispatchPacItemSelectionEvents(
|
|
|
page: Page,
|
|
|
index: number,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate((idx) => {
|
|
|
const items = document.querySelectorAll(".pac-container .pac-item");
|
|
|
const item = items[idx] as HTMLElement | undefined;
|
|
|
if (!item) {
|
|
|
return false;
|
|
|
}
|
|
|
item.scrollIntoView({ block: "center" });
|
|
|
const target = (item.querySelector(".pac-item-query") ??
|
|
|
item) as HTMLElement;
|
|
|
for (const type of ["mousedown", "mouseup", "click"] as const) {
|
|
|
target.dispatchEvent(
|
|
|
new MouseEvent(type, {
|
|
|
bubbles: true,
|
|
|
cancelable: true,
|
|
|
view: window,
|
|
|
}),
|
|
|
);
|
|
|
}
|
|
|
return true;
|
|
|
}, index);
|
|
|
}
|
|
|
|
|
|
async function commitSelectionViaKeyboardTab(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
index: number,
|
|
|
): Promise<void> {
|
|
|
await selectSuggestionByBoundIndex(page, input, index, {
|
|
|
alreadyHighlightedFirst: true,
|
|
|
});
|
|
|
await page.keyboard.press("Tab");
|
|
|
await page.waitForTimeout(250);
|
|
|
}
|
|
|
|
|
|
async function clickListboxOptionAtIndex(page: Page, index: number): Promise<boolean> {
|
|
|
const roots = [
|
|
|
page.locator('[role="listbox"] [role="option"]'),
|
|
|
getQuoteWidgetLocator(page).locator('[role="listbox"] [role="option"]'),
|
|
|
];
|
|
|
for (const root of roots) {
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
if (index < 0 || index >= count) {
|
|
|
continue;
|
|
|
}
|
|
|
const item = root.nth(index);
|
|
|
if (!(await item.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
await item.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await item.click({ timeout: 3_000 });
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function selectSuggestionBySearchIndex(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
index: number,
|
|
|
): Promise<void> {
|
|
|
await input.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(150);
|
|
|
for (let i = 0; i < arrowDownCountForPacIndex(index); i += 1) {
|
|
|
await page.keyboard.press("ArrowDown");
|
|
|
await page.waitForTimeout(120);
|
|
|
}
|
|
|
await page.keyboard.press("Enter");
|
|
|
await page.waitForTimeout(200);
|
|
|
}
|
|
|
|
|
|
/** 按 axel search 返回的 description 点选(绑定 placeId,非纯 DOM label) */
|
|
|
async function clickSuggestionBySearchDescription(
|
|
|
page: Page,
|
|
|
description: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const probes = [
|
|
|
description,
|
|
|
stripCountrySuffix(description),
|
|
|
description.split(",")[0]?.trim() ?? "",
|
|
|
...gluedVariants(description),
|
|
|
].filter((value, index, arr) => value.length >= 8 && arr.indexOf(value) === index);
|
|
|
|
|
|
for (const probe of probes) {
|
|
|
const located = await locateSuggestionByLabelText(page, probe);
|
|
|
if (!located) {
|
|
|
continue;
|
|
|
}
|
|
|
const score = scoreSuggestionLabelMatch(description, probe, query);
|
|
|
if (score < 50) {
|
|
|
continue;
|
|
|
}
|
|
|
await located.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await located.hover({ force: true }).catch(() => undefined);
|
|
|
await located.click({ force: true, timeout: 3_000 }).catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function clickLocatedSuggestionWithPointer(
|
|
|
page: Page,
|
|
|
located: Locator,
|
|
|
): Promise<void> {
|
|
|
await located.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
const box = await located.boundingBox().catch(() => null);
|
|
|
if (box) {
|
|
|
const x = box.x + box.width / 2;
|
|
|
const y = box.y + box.height / 2;
|
|
|
await page.mouse.move(x, y);
|
|
|
await page.mouse.down();
|
|
|
await page.waitForTimeout(60);
|
|
|
await page.mouse.up();
|
|
|
await page.waitForTimeout(120);
|
|
|
return;
|
|
|
}
|
|
|
await located.click({ force: true, timeout: 3_000 }).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
/** 按 description 文本 pointer 点选(优先于 index 键盘,避免 pac 顺序与 axel search 不一致) */
|
|
|
async function clickSuggestionByDescriptionPointer(
|
|
|
page: Page,
|
|
|
description: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const probes = [
|
|
|
description,
|
|
|
stripCountrySuffix(description),
|
|
|
description.split(",")[0]?.trim() ?? "",
|
|
|
...gluedVariants(description),
|
|
|
].filter((value, index, arr) => value.length >= 8 && arr.indexOf(value) === index);
|
|
|
|
|
|
for (const probe of probes) {
|
|
|
const located = await locateSuggestionByLabelText(page, probe);
|
|
|
if (!located) {
|
|
|
continue;
|
|
|
}
|
|
|
const score = scoreSuggestionLabelMatch(description, probe, query);
|
|
|
if (score < 50) {
|
|
|
continue;
|
|
|
}
|
|
|
await clickLocatedSuggestionWithPointer(page, located);
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
export async function ensureSuggestionDropdownOpen(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
query: AddressLookupInput,
|
|
|
fallbackText: string,
|
|
|
): Promise<void> {
|
|
|
const resolved = resolveAddressAutocompleteInput(query, {
|
|
|
mothershipLabel: fallbackText,
|
|
|
});
|
|
|
const probes = [
|
|
|
fallbackText,
|
|
|
stripCountrySuffix(fallbackText),
|
|
|
`${resolved.street}, ${resolved.city}, ${resolved.state}`,
|
|
|
`${resolved.street}, ${resolved.city}, ${resolved.state}, USA`,
|
|
|
].filter((value, index, arr) => value.length >= 8 && arr.indexOf(value) === index);
|
|
|
|
|
|
await input.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(150);
|
|
|
if (await waitForStyledSuggestionRows(page, probes, resolved, 1_500)) {
|
|
|
return;
|
|
|
}
|
|
|
if (await waitForAddressSuggestions(page, resolved, 2_000)) {
|
|
|
return;
|
|
|
}
|
|
|
const hint = `${resolved.street}, ${resolved.city}, ${resolved.state}`.trim();
|
|
|
const currentValue = normalizeAddressInputValue(
|
|
|
(await input.inputValue().catch(() => "")) || "",
|
|
|
);
|
|
|
if (
|
|
|
addressInputMatchesQuery(currentValue, hint) ||
|
|
|
addressInputMatchesQuery(currentValue, fallbackText)
|
|
|
) {
|
|
|
await waitForStyledSuggestionRows(page, probes, resolved, 4_000);
|
|
|
if (await waitForAddressSuggestions(page, resolved, 2_000)) {
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
if (addressInputMatchesQuery(currentValue, hint)) {
|
|
|
return;
|
|
|
}
|
|
|
await input.fill("").catch(() => undefined);
|
|
|
await input.pressSequentially(hint, { delay: 45 }).catch(() => undefined);
|
|
|
await waitForGooglePlacesPredictions(page, 8_000);
|
|
|
await waitForStyledSuggestionRows(page, probes, resolved, 4_000);
|
|
|
}
|
|
|
|
|
|
const AXEL_PLACE_COMMIT_TIMEOUT_MS = 12_000;
|
|
|
const AXEL_PLACE_COMMIT_QUICK_MS = 5_000;
|
|
|
|
|
|
// #region agent log
|
|
|
function debugSelectLog(
|
|
|
hypothesisId: string,
|
|
|
location: string,
|
|
|
message: string,
|
|
|
data: Record<string, unknown>,
|
|
|
): void {
|
|
|
fetch("http://127.0.0.1:7489/ingest/ff976922-7b71-4f91-ad84-5521d8ba1420", {
|
|
|
method: "POST",
|
|
|
headers: {
|
|
|
"Content-Type": "application/json",
|
|
|
"X-Debug-Session-Id": "efb842",
|
|
|
},
|
|
|
body: JSON.stringify({
|
|
|
sessionId: "efb842",
|
|
|
hypothesisId,
|
|
|
location,
|
|
|
message,
|
|
|
data,
|
|
|
timestamp: Date.now(),
|
|
|
}),
|
|
|
}).catch(() => undefined);
|
|
|
}
|
|
|
// #endregion
|
|
|
|
|
|
/**
|
|
|
* search → placeId 绑定 → 点选 → 等待 GET place/{id} 200
|
|
|
* 禁止无 placeId 的 selection(label)。
|
|
|
*/
|
|
|
function widgetShowsQueryAddress(
|
|
|
widgetText: string,
|
|
|
query: AddressLookupInput,
|
|
|
): boolean {
|
|
|
const w = widgetText.replace(/\s+/g, " ").toLowerCase();
|
|
|
const num = query.street.match(/^\d+/)?.[0];
|
|
|
if (num && !w.includes(num.toLowerCase())) {
|
|
|
return false;
|
|
|
}
|
|
|
const state = query.state?.trim().toLowerCase();
|
|
|
if (state && !w.includes(state)) {
|
|
|
return false;
|
|
|
}
|
|
|
const city = query.city?.trim().toLowerCase();
|
|
|
if (city && city.length >= 3 && !w.includes(city)) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
async function readStreetInputValuesInWidget(
|
|
|
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[]>;
|
|
|
}
|
|
|
|
|
|
/** 提货 commit 后须释放独立派送框(单框仍含提货全文则未锁定) */
|
|
|
async function isDeliveryInputUnlocked(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
query: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const values = await readStreetInputValuesInWidget(page, widgetSelector);
|
|
|
if (values.length >= 2) {
|
|
|
return true;
|
|
|
}
|
|
|
if (values.length === 1) {
|
|
|
const only = values[0]!.replace(/\s+/g, " ").trim().toLowerCase();
|
|
|
const streetNum = query.street.match(/^\d+/)?.[0]?.toLowerCase();
|
|
|
if (streetNum && only.includes(streetNum) && only.length >= 8) {
|
|
|
return false;
|
|
|
}
|
|
|
return only.length < 8;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
export async function selectAddressWithPlaceBinding(
|
|
|
page: Page,
|
|
|
bridge: AxelSelectionBridge,
|
|
|
selection: AxelSelectionPayload,
|
|
|
query: AddressLookupInput,
|
|
|
input: Locator,
|
|
|
options?: {
|
|
|
side?: "pickup" | "delivery";
|
|
|
widgetSelector?: string;
|
|
|
pickupAnchor?: AddressLookupInput;
|
|
|
confirmedAddress?: QuoteRequest["pickup"];
|
|
|
ctx?: RPAContext;
|
|
|
},
|
|
|
): Promise<void> {
|
|
|
if (!selection.placeId?.trim()) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"地址选择缺少 placeId,无法触发 MotherShip commit",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const index = bridge.getCandidateIndex(selection.placeId);
|
|
|
if (index < 0) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`search 绑定 placeId 不在候选列表:${selection.placeId}`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const attemptPaths: string[] = [];
|
|
|
|
|
|
await ensureSuggestionDropdownOpen(page, input, query, selection.description);
|
|
|
|
|
|
const traceSide = options?.side ?? "rpa";
|
|
|
const preCommit = await captureSelectionTrace(page, "pre-commit-attempts");
|
|
|
logSelectionTrace(traceSide, preCommit);
|
|
|
|
|
|
const normalizedPlaceId = selection.placeId;
|
|
|
|
|
|
const validateWidgetCommit = async (path: string): Promise<boolean> => {
|
|
|
if (!options?.widgetSelector) {
|
|
|
return true;
|
|
|
}
|
|
|
if (options.side === "pickup" && options.confirmedAddress) {
|
|
|
const locked = await isPickupReadyForDeliveryInput(
|
|
|
page,
|
|
|
options.widgetSelector,
|
|
|
options.confirmedAddress,
|
|
|
);
|
|
|
if (!locked) {
|
|
|
console.log(
|
|
|
`[rpa] address-select: pickup widget 未锁定 path=${path}`,
|
|
|
);
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
const widgetText = await page
|
|
|
.locator(options.widgetSelector)
|
|
|
.innerText()
|
|
|
.catch(() => "");
|
|
|
if (!widgetShowsQueryAddress(widgetText, query)) {
|
|
|
console.log(
|
|
|
`[rpa] address-select: widget 未体现 ${options.side ?? "side"} 地址 path=${path}`,
|
|
|
);
|
|
|
return false;
|
|
|
}
|
|
|
if (options.side === "delivery" && options.pickupAnchor) {
|
|
|
if (!widgetShowsQueryAddress(widgetText, options.pickupAnchor)) {
|
|
|
console.log(
|
|
|
`[rpa] address-select: 派送 commit 后提货从 widget 消失 path=${path}`,
|
|
|
);
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
return true;
|
|
|
};
|
|
|
|
|
|
const tryCommit = async (path: string, quickMs = AXEL_PLACE_COMMIT_QUICK_MS) => {
|
|
|
attemptPaths.push(path);
|
|
|
if (!(await bridge.waitForPlaceCommit(normalizedPlaceId, quickMs))) {
|
|
|
return false;
|
|
|
}
|
|
|
if (!(await validateWidgetCommit(path))) {
|
|
|
return false;
|
|
|
}
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await page.waitForTimeout(200);
|
|
|
debugSelectLog(
|
|
|
"H2",
|
|
|
"address-suggestions.ts:selectAddressWithPlaceBinding",
|
|
|
"commit-ok",
|
|
|
{ clickPath: attemptPaths.join(" → "), placeId: normalizedPlaceId },
|
|
|
);
|
|
|
return true;
|
|
|
};
|
|
|
|
|
|
const styledClick = await clickMothershipStyledSuggestion(
|
|
|
page,
|
|
|
selection.description,
|
|
|
query,
|
|
|
{ input, candidateIndex: index },
|
|
|
);
|
|
|
if (styledClick.clicked && (await tryCommit("mothership-styled-click"))) {
|
|
|
return;
|
|
|
}
|
|
|
if (styledClick.clicked) {
|
|
|
console.log(
|
|
|
`[rpa] mothership-styled-click: 已点选但未观测 place 请求 matched=${styledClick.matchedText?.slice(0, 48) ?? "—"}`,
|
|
|
);
|
|
|
await ensureSuggestionDropdownOpen(page, input, query, selection.description);
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
(await commitViaAxelPlacePrefetch(
|
|
|
page,
|
|
|
bridge,
|
|
|
normalizedPlaceId,
|
|
|
selection.description,
|
|
|
input,
|
|
|
{ query, candidateIndex: index },
|
|
|
).then((r) => r.ok)) &&
|
|
|
(await tryCommit("axel-place-prefetch"))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const preKeyboard = await captureSelectionTrace(
|
|
|
page,
|
|
|
`pre-keyboard-bound-${index}`,
|
|
|
);
|
|
|
logSelectionTrace(traceSide, preKeyboard);
|
|
|
const pacVisible = await page
|
|
|
.locator(".pac-container .pac-item")
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (pacVisible) {
|
|
|
await selectSuggestionByBoundIndex(page, input, index, {
|
|
|
alreadyHighlightedFirst: true,
|
|
|
});
|
|
|
if (await tryCommit(`keyboard-bound-${index}`)) {
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
const postKeyboard = await captureSelectionTrace(
|
|
|
page,
|
|
|
`post-keyboard-bound-${index}`,
|
|
|
);
|
|
|
logSelectionTrace(traceSide, postKeyboard);
|
|
|
|
|
|
debugSelectLog("H2", "address-suggestions.ts:selectAddressWithPlaceBinding", "after-keyboard", {
|
|
|
clickPath: attemptPaths.join(" → "),
|
|
|
placeId: normalizedPlaceId,
|
|
|
description: selection.description.slice(0, 80),
|
|
|
candidateIndex: index,
|
|
|
});
|
|
|
|
|
|
if (
|
|
|
(await clickListboxOptionAtIndex(page, index)) &&
|
|
|
(await tryCommit(`listbox-${index}`))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await ensureSuggestionDropdownOpen(page, input, query, selection.description);
|
|
|
await commitSelectionViaKeyboardTab(page, input, index);
|
|
|
if (await tryCommit(`keyboard-tab-${index}`)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await ensureSuggestionDropdownOpen(page, input, query, selection.description);
|
|
|
await selectSuggestionBySearchIndex(page, input, index);
|
|
|
if (await tryCommit(`keyboard-full-${index}`)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
(await clickSuggestionByDescriptionPointer(
|
|
|
page,
|
|
|
selection.description,
|
|
|
query,
|
|
|
)) &&
|
|
|
(await tryCommit("description-pointer"))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
(await clickSuggestionBySearchDescription(
|
|
|
page,
|
|
|
selection.description,
|
|
|
query,
|
|
|
)) &&
|
|
|
(await tryCommit("description-click"))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
(await dispatchPacItemSelectionEvents(page, index)) &&
|
|
|
(await tryCommit(`pac-dispatch-${index}`))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
(await clickPacItemAtIndex(page, index)) &&
|
|
|
(await tryCommit(`pac-item-${index}`))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await ensureSuggestionDropdownOpen(page, input, query, selection.description);
|
|
|
if (
|
|
|
(await clickSuggestionByDescriptionPointer(
|
|
|
page,
|
|
|
selection.description,
|
|
|
query,
|
|
|
)) &&
|
|
|
(await tryCommit("description-pointer-retry"))
|
|
|
) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const diag = bridge.getPlaceCommitDiagnostics(normalizedPlaceId);
|
|
|
const reason = formatPlaceCommitFailure(
|
|
|
normalizedPlaceId,
|
|
|
attemptPaths,
|
|
|
diag,
|
|
|
);
|
|
|
console.log(`[rpa] axel-place-gate: ${reason}`);
|
|
|
debugSelectLog(
|
|
|
"H2",
|
|
|
"address-suggestions.ts:selectAddressWithPlaceBinding",
|
|
|
"commit-failed",
|
|
|
{ clickPath: attemptPaths.join(" → "), diag, reason },
|
|
|
);
|
|
|
throw new RpaError("ADDRESS_NOT_CONFIRMED", reason, { retryable: true });
|
|
|
}
|
|
|
|
|
|
/** MotherShip styled-components 联想项:按评分点击单行 */
|
|
|
export async function clickSuggestionByLabel(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
query?: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const labels = query
|
|
|
? await collectAddressSuggestionLabels(page, query)
|
|
|
: [label];
|
|
|
let bestLabel = label;
|
|
|
let bestScore = 0;
|
|
|
for (const domLabel of labels) {
|
|
|
const score = scoreSuggestionLabelMatch(label, domLabel, query);
|
|
|
if (score > bestScore) {
|
|
|
bestScore = score;
|
|
|
bestLabel = domLabel;
|
|
|
}
|
|
|
}
|
|
|
if (bestScore < 50) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
const roots = [
|
|
|
page.locator(".pac-container .pac-item"),
|
|
|
page.locator('[role="listbox"] [role="option"]'),
|
|
|
page.locator("div[class*='sc-']"),
|
|
|
];
|
|
|
|
|
|
let target: Locator | null = null;
|
|
|
let targetScore = 0;
|
|
|
for (const root of roots) {
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const item = root.nth(i);
|
|
|
const text = ((await item.innerText().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (!text) {
|
|
|
continue;
|
|
|
}
|
|
|
const box = await item.boundingBox().catch(() => null);
|
|
|
if (!box || box.width < 200 || box.height < 24 || box.height > 80) {
|
|
|
continue;
|
|
|
}
|
|
|
const score = scoreSuggestionLabelMatch(label, text, query);
|
|
|
if (score > targetScore) {
|
|
|
targetScore = score;
|
|
|
target = item;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!target || targetScore < bestScore - 5) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
await target.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await target.hover({ force: true }).catch(() => undefined);
|
|
|
await target.click({ force: true, timeout: 3_000 }).catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 读取已填入/锁定的地址字段文本(排除联想下拉 innerText 污染) */
|
|
|
export async function getAddressConfirmationSurfaceText(
|
|
|
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 >= 4) chunks.push(val);
|
|
|
}
|
|
|
return chunks.join(" ");
|
|
|
})(${JSON.stringify(widgetSelector)})`,
|
|
|
) as Promise<string>;
|
|
|
}
|
|
|
|
|
|
/** 地址联想下拉是否仍打开(勿用 getByText 判断,输入框回显会误判) */
|
|
|
export async function isAddressSuggestionDropdownOpen(
|
|
|
page: Page,
|
|
|
): Promise<boolean> {
|
|
|
const roots = [
|
|
|
page.locator(".pac-container"),
|
|
|
page.locator('[role="listbox"]'),
|
|
|
getQuoteWidgetLocator(page).locator('[role="listbox"]'),
|
|
|
];
|
|
|
|
|
|
for (const root of roots) {
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
if (await root.nth(i).isVisible().catch(() => false)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const styledOpen = (await page.evaluate(`
|
|
|
(function() {
|
|
|
var stateRe = /,\\s*(USA|US|CA|NY|TX|FL|WA|[A-Z]{2})\\b/;
|
|
|
var nodes = document.querySelectorAll("div[class*='sc-']");
|
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
|
var el = nodes[i];
|
|
|
var text = (el.innerText || "").replace(/\\s+/g, " ").trim();
|
|
|
if (text.length < 15 || !stateRe.test(text)) continue;
|
|
|
var rect = el.getBoundingClientRect();
|
|
|
if (
|
|
|
rect.width > 300 &&
|
|
|
rect.height >= 30 &&
|
|
|
rect.height <= 70 &&
|
|
|
el.offsetParent !== null
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
})()
|
|
|
`)) as boolean;
|
|
|
|
|
|
return styledOpen;
|
|
|
}
|
|
|
|
|
|
/** 地址输入框是否已写入确认地址(styled-components 下拉检测不可靠时的兜底) */
|
|
|
export async function isAddressInputCommitted(
|
|
|
input: Locator,
|
|
|
address: AddressLookupInput,
|
|
|
): Promise<boolean> {
|
|
|
const value = ((await input.inputValue().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim()
|
|
|
.toLowerCase();
|
|
|
if (value.length < 8) {
|
|
|
return false;
|
|
|
}
|
|
|
const streetNum = address.street.match(/^\d+/)?.[0];
|
|
|
if (streetNum && !value.includes(streetNum)) {
|
|
|
return false;
|
|
|
}
|
|
|
const city = address.city.trim().toLowerCase();
|
|
|
if (city && !value.includes(city)) {
|
|
|
const last = city.split(/\s+/).pop();
|
|
|
if (!last || !value.includes(last)) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
const state = address.state.trim().toLowerCase();
|
|
|
return !state || value.includes(state);
|
|
|
}
|
|
|
|
|
|
/** 点选后等待联想下拉收起 */
|
|
|
export async function waitForAddressSuggestionLocked(
|
|
|
page: Page,
|
|
|
timeoutMs = 3_000,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (!(await isAddressSuggestionDropdownOpen(page))) {
|
|
|
return true;
|
|
|
}
|
|
|
await page.waitForTimeout(100);
|
|
|
}
|
|
|
return false;
|
|
|
}
|