You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/workers/rpa/address-suggestions.ts

2382 lines
71 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { 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 { resolveAddressSuggestionTimeoutMs } from "@/lib/rpa/env";
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);
}
// 标准逗号分隔格式(无 ZIPstreet, 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, FLfill 写值不触发 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 ??
(options?.preferConfirmedLabel ? 1 : allQueries.length),
);
let lastQuery = queries[0] ?? formatAddressQuery(resolved);
const suggestionTimeoutMs = resolveAddressSuggestionTimeoutMs(
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 > 180 &&
rect.height >= 24 &&
rect.height <= 100 &&
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;
}
/**
* 通过街道文字直接找到建议行并点击Playwright codegen 验证方案)
* 不依赖 styled-component 尺寸检测,直接用 page.getByText 匹配第一条可见建议
*/
export async function clickFirstVisibleSuggestionByText(
page: Page,
streetAddress: string,
): Promise<boolean> {
// 取街道号+街道名前缀(去掉城市、州),作为 partial match
const street = streetAddress.split(",")[0]?.trim() ?? streetAddress;
if (!street) return false;
// 找所有包含街道文字的可见元素,排除 input/textarea 本身
const candidates = page.locator(
`:not(input):not(textarea):not(label):not(button)`,
).filter({ hasText: street });
const count = await candidates.count().catch(() => 0);
for (let i = 0; i < count; i++) {
const el = candidates.nth(i);
if (!(await el.isVisible().catch(() => false))) continue;
const box = await el.boundingBox().catch(() => null);
if (!box || box.width < 100 || box.height < 20 || box.height > 200) continue;
// 排除整个 widget 容器(面积过大)
if (box.width > 900 || box.height > 300) continue;
await el.click({ force: true, timeout: 3_000 }).catch(() => undefined);
console.log(
`[rpa] click-suggestion-by-text: clicked "${street.slice(0, 40)}" box=${Math.round(box.width)}x${Math.round(box.height)}`,
);
return true;
}
return false;
}