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([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 { 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 = { 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 { 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 { 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 { 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 { 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 { 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 { const specs = getSelectorSpecs("RPA_SELECTOR_ADDRESS_SUGGESTION"); const labels: string[] = []; const seen = new Set(); 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 { 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 { const seen = new Set(); 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 { 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 { const queries = buildAddressEnumerationQueries(address); const seen = new Set(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await selectSuggestionByBoundIndex(page, input, index, { alreadyHighlightedFirst: true, }); await page.keyboard.press("Tab"); await page.waitForTimeout(250); } async function clickListboxOptionAtIndex(page: Page, index: number): Promise { 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 { 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 { 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 { 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 { 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 { 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, ): 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 { 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; } /** 提货 commit 后须释放独立派送框(单框仍含提货全文则未锁定) */ async function isDeliveryInputUnlocked( page: Page, widgetSelector: string, query: AddressLookupInput, ): Promise { 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 { 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 => { 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 { 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 { 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; } /** 地址联想下拉是否仍打开(勿用 getByText 判断,输入框回显会误判) */ export async function isAddressSuggestionDropdownOpen( page: Page, ): Promise { 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 { 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 { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!(await isAddressSuggestionDropdownOpen(page))) { return true; } await page.waitForTimeout(100); } return false; }