import type { Locator, Page } from "playwright"; import type { AddressLookupInput } from "@/modules/address/types"; import { pageLocator } from "@/lib/rpa/locator"; import { awaitHumanPacing } from "@/lib/rpa/human-pacing"; import { isRpaVisualRushMode } from "@/lib/rpa/env"; import { COLLECT_WIDGET_SUGGESTION_LABELS } from "@/workers/rpa/kernel/browser-scripts"; import { dismissCookieConsent } from "@/workers/rpa/page-prep"; import type { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge"; import { findGoldStandardTargets, type GoldStandardClickTarget, } from "@/workers/rpa/gold-standard-targets"; const NEGATIVE_CLICK_LABEL_RE = /^(use my location|pick up from|deliver to)$/i; /** 易误点联想词:查询未含该词时不应匹配(如 Market Street vs Market Street Bridge) */ const SPURIOUS_STREET_TOKEN_RE = /\b(bridge|bypass|overpass|highway|expy|expressway|freeway)\b/i; export function suggestionLabelConflicts( candidateLabel: string, query: AddressLookupInput, expectedLabel?: string, ): boolean { const cand = normalizeLabel(candidateLabel); const street = normalizeLabel(query.street); const expected = normalizeLabel(expectedLabel ?? ""); const reference = expected || street; if (!cand || !reference) { return false; } const streetNum = query.street.match(/^\d+/)?.[0]; if (streetNum && cand.includes(streetNum) && !reference.includes("bridge")) { if (/\bbridge\b/.test(cand) && !/\bbridge\b/.test(reference)) { return true; } } for (const token of ["bridge", "bypass", "overpass", "highway", "freeway"]) { const inCand = cand.includes(token); const inRef = reference.includes(token) || street.includes(token); if (inCand && !inRef) { return true; } } if (streetNum && !cand.includes(streetNum)) { const city = normalizeLabel(query.city); const state = normalizeLabel(query.state); if ( city && state && cand.includes(city) && cand.includes(state) && !/\d/.test(cand.split(",")[0] ?? "") ) { return true; } } return false; } export type StyledClickStrategy = { name: string; locator: Locator; }; function stripCountrySuffix(label: string): string { return label.replace(/,\s*USA\s*$/i, "").trim(); } function normalizeLabel(text: string): string { return text.replace(/\s+/g, " ").trim().toLowerCase(); } export function labelsMatch(a: string, b: string): boolean { const x = normalizeLabel(a); const y = normalizeLabel(b); if (!x || !y) return false; if (x === y) return true; if (x.includes(y) || y.includes(x)) return true; const num = x.match(/^\d+/)?.[0]; if (num && x.includes(num) && y.includes(num)) { const stateA = x.match(/\b([a-z]{2})\b(?!.*\b[a-z]{2}\b)/i)?.[1]; const stateB = y.match(/\b([a-z]{2})\b(?!.*\b[a-z]{2}\b)/i)?.[1]; if (stateA && stateB && stateA.toLowerCase() === stateB.toLowerCase()) { if (SPURIOUS_STREET_TOKEN_RE.test(x) !== SPURIOUS_STREET_TOKEN_RE.test(y)) { return false; } return true; } } return false; } export function buildDescriptionProbes( description: string, query: AddressLookupInput, ): string[] { return [ description, stripCountrySuffix(description), description.split(",")[0]?.trim() ?? "", `${query.street}, ${query.city}, ${query.state}`, `${query.street}, ${query.city}, ${query.state}, USA`, query.street, ].filter((value, index, arr) => value.length >= 6 && arr.indexOf(value) === index); } function pickLabelIndex( labels: string[], description: string, query: AddressLookupInput, preferredIndex: number, ): number { const eligible = labels .map((label, index) => ({ label, index })) .filter( ({ label }) => !suggestionLabelConflicts(label, query, description), ); const pool = eligible.length ? eligible : labels.map((label, index) => ({ label, index })); if (preferredIndex >= 0 && preferredIndex < labels.length) { const preferred = labels[preferredIndex]!; if (!suggestionLabelConflicts(preferred, query, description)) { return preferredIndex; } } const probes = buildDescriptionProbes(description, query); for (const probe of probes) { const hit = pool.find(({ label }) => labelsMatch(label, probe)); if (hit) { return hit.index; } } return pool[0]?.index ?? 0; } function cssEscapeToken(token: string): string { return token.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1"); } function buildClassSelector(tagName: string, className: string): string | null { const tokens = className .split(/\s+/) .map((part) => part.trim()) .filter((part) => part.length > 0 && part.startsWith("sc-")); if (!tokens.length) { return null; } const tag = (tagName || "div").toLowerCase(); return `${tag}.${tokens.map(cssEscapeToken).join(".")}`; } /** 金样本 + 文本探针构建多层回退 Locator 链 */ export function buildGoldDrivenLocators( page: Page, description: string, query: AddressLookupInput, options?: { side?: "pickup" | "delivery"; goldTargets?: GoldStandardClickTarget[]; omitScFallback?: boolean; }, ): StyledClickStrategy[] { const widget = pageLocator(page, "#react-quoter-landing-plugin"); const strategies: StyledClickStrategy[] = []; const seen = new Set(); const push = (name: string, locator: Locator): void => { if (seen.has(name)) { return; } seen.add(name); strategies.push({ name, locator }); }; const goldTargets = options?.goldTargets ?? (options?.side ? findGoldStandardTargets(description, query, options.side, 70) : []); for (const gold of goldTargets) { if (gold.dataOptionId) { push( `gold-data-option-id:${gold.dataOptionId.slice(0, 12)}`, widget.locator(`[data-option-id="${gold.dataOptionId}"]`), ); push( `gold-data-place-id:${gold.dataOptionId.slice(0, 12)}`, widget.locator(`[data-place-id="${gold.dataOptionId}"]`), ); } if (gold.textPreview.length >= 12) { push( `gold-exact-text:${gold.textPreview.slice(0, 24)}`, widget.getByText(gold.textPreview, { exact: true }), ); const partial = stripCountrySuffix(gold.textPreview); if (partial !== gold.textPreview) { push( `gold-partial-text:${partial.slice(0, 24)}`, widget.getByText(partial, { exact: false }), ); } } const classSel = buildClassSelector(gold.tagName, gold.className); if (classSel && gold.textPreview.length >= 12) { push( `gold-class-text:${gold.className.slice(0, 20)}`, widget.locator(classSel).filter({ hasText: gold.textPreview.slice(0, 40) }), ); } } for (const probe of buildDescriptionProbes(description, query)) { push( `probe-text:${probe.slice(0, 24)}`, widget.locator("div[class*='sc-']").filter({ hasText: probe }), ); push(`probe-getByText:${probe.slice(0, 24)}`, widget.getByText(probe)); } if (!options?.omitScFallback) { push("sc-fallback", widget.locator("div[class*='sc-']")); } return strategies; } async function collectWidgetLabels(page: Page): Promise { return (await page .evaluate(COLLECT_WIDGET_SUGGESTION_LABELS) .catch(() => [])) as string[]; } /** 重新键入以唤起 styled-components 联想下拉 */ export async function reopenStyledSuggestionDropdown( page: Page, input: Locator, query: AddressLookupInput, description: string, ): Promise { const fullText = description.trim() || `${query.street}, ${query.city}, ${query.state}, USA`; await input.click({ force: true }).catch(() => undefined); await page.waitForTimeout(120); const currentValue = ((await input.inputValue().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); const fullNorm = fullText.replace(/\s+/g, " ").trim(); const alreadyTyped = currentValue.length >= 8 && (currentValue === fullNorm || currentValue.toLowerCase().includes(fullNorm.toLowerCase().slice(0, 16))); if (!alreadyTyped) { await input.fill("").catch(() => undefined); await page.waitForTimeout(80); } const typePromise = input .pressSequentially(fullText, { delay: 35 }) .catch(() => undefined); const searchPromise = page .waitForResponse( (response) => response.url().includes("axel/location/search") && response.request().method() === "POST" && response.status() === 200, { timeout: 12_000 }, ) .catch(() => undefined); await Promise.all([typePromise, searchPromise]); await page.waitForTimeout(500); } async function clickRowWithPointer(page: Page, row: Locator): Promise { await row.scrollIntoViewIfNeeded().catch(() => undefined); const box = await row.boundingBox().catch(() => null); if (box) { const x = box.x + box.width / 2; const y = box.y + box.height / 2; const steps = process.env.RPA_FAST_CLICK === "true" ? 1 : 6; await page.mouse.move(x, y, { steps }); if (steps > 1) { await page.waitForTimeout(60); } await page.mouse.down(); if (steps > 1) { await page.waitForTimeout(40); } await page.mouse.up(); return; } await row.click({ force: true, timeout: 3_000 }).catch(() => undefined); } async function isNegativeClickTarget(row: Locator): Promise { const text = ((await row.innerText().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); return text.length < 12 || NEGATIVE_CLICK_LABEL_RE.test(text); } /** 等待 styled-components 联想行出现 */ export async function waitForStyledSuggestionRows( page: Page, _probes: string[], _query: AddressLookupInput, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const labels = await collectWidgetLabels(page); if (labels.length > 0) { return true; } await page.waitForTimeout(150); } return false; } async function clickStyledRowByLabel( page: Page, targetLabel: string, query?: AddressLookupInput, ): Promise<{ clicked: boolean; matchedText?: string; reason?: string }> { const widget = pageLocator(page, "#react-quoter-landing-plugin"); const queryHint: AddressLookupInput = query ?? { street: targetLabel.split(",")[0]?.trim() ?? targetLabel, city: targetLabel.split(",")[1]?.trim() ?? "", state: targetLabel.split(",")[2]?.trim()?.slice(0, 2) ?? "", zip: "", }; const probes = buildDescriptionProbes(targetLabel, queryHint); for (const probe of probes) { const byText = widget.getByText(probe, { exact: false }).first(); if (!(await byText.isVisible().catch(() => false))) { continue; } const box = await byText.boundingBox().catch(() => null); if (box && box.width >= 80 && box.height >= 18) { await clickRowWithPointer(page, byText); return { clicked: true, matchedText: probe }; } } const rows = widget.locator("div[class*='sc-']"); const count = await rows.count().catch(() => 0); let best: { row: Locator; text: string; score: number } | null = null; for (let i = 0; i < count; i += 1) { const row = rows.nth(i); if (await row.locator("input, textarea, button").count().catch(() => 0)) { continue; } const text = ((await row.innerText().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); if (text.length < 12) { continue; } if (!labelsMatch(text, targetLabel)) { continue; } if (suggestionLabelConflicts(text, queryHint, targetLabel)) { continue; } const box = await row.boundingBox().catch(() => null); if (!box || box.width < 120 || box.height < 20) { continue; } const score = text.length; if (!best || score > best.score) { best = { row, text, score }; } } if (!best) { return { clicked: false, reason: "no-dom-row" }; } await clickRowWithPointer(page, best.row); return { clicked: true, matchedText: best.text }; } async function tryLocatorStrategies( page: Page, strategies: StyledClickStrategy[], description: string, query: AddressLookupInput, options?: { bridge?: AxelSelectionBridge; placeId?: string; maxAttempts?: number; placeCommitTimeoutMs?: number; }, ): Promise<{ clicked: boolean; reason?: string; matchedText?: string; method?: string; }> { const maxAttempts = options?.maxAttempts ?? strategies.length; const placeCommitMs = options?.placeCommitTimeoutMs ?? 2_000; let attempts = 0; for (const strategy of strategies) { if (attempts >= maxAttempts) { break; } const count = await strategy.locator.count().catch(() => 0); if (count === 0) { continue; } for (let i = 0; i < count; i += 1) { if (attempts >= maxAttempts) { break; } const row = strategy.locator.nth(i); if (!(await row.isVisible().catch(() => false))) { continue; } if (await isNegativeClickTarget(row)) { continue; } const text = ((await row.innerText().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); if ( text.length >= 12 && suggestionLabelConflicts(text, query, description) ) { continue; } if (text.length >= 12 && !labelsMatch(text, description)) { const probes = buildDescriptionProbes(description, query); if (!probes.some((probe) => labelsMatch(text, probe))) { continue; } } attempts += 1; await clickRowWithPointer(page, row); if (options?.bridge && options?.placeId) { const committed = await options.bridge.waitForPlaceCommit( options.placeId, placeCommitMs, ); if (committed) { return { clicked: true, matchedText: text, method: strategy.name, }; } continue; } return { clicked: true, matchedText: text, method: strategy.name, }; } } return { clicked: false, reason: "no-strategy-match" }; } /** * 对齐人工操作:在 MotherShip styled-components 联想 DIV 上执行真实鼠标点击。 * 为何:widget 不使用 Google pac/listbox;evaluate 派发事件不可靠,改用 locator + mouse。 */ export async function clickMothershipStyledSuggestion( page: Page, description: string, query: AddressLookupInput, options?: { input?: Locator; candidateIndex?: number; skipReopen?: boolean; side?: "pickup" | "delivery"; bridge?: AxelSelectionBridge; placeId?: string; maxStrategyAttempts?: number; preferLegacyFirst?: boolean; legacyOnly?: boolean; fastClick?: boolean; placeCommitTimeoutMs?: number; }, ): Promise<{ clicked: boolean; reason?: string; matchedText?: string; method?: string; }> { await dismissCookieConsent(page); const rush = isRpaVisualRushMode(); const placeCommitMs = options?.placeCommitTimeoutMs ?? 2_000; if (options?.legacyOnly) { const legacyOnly = await clickStyledRowByLabel(page, description, query); if (!legacyOnly.clicked) { return legacyOnly; } if (rush) { console.log( `[rpa] mothership-styled-click:ok method=legacy-only-rush "${(legacyOnly.matchedText ?? "").slice(0, 64)}"`, ); return { ...legacyOnly, method: "legacy-only-rush" }; } if (options?.bridge && options?.placeId) { const committed = await options.bridge.waitForPlaceCommit( options.placeId, placeCommitMs, ); if (!committed) { return { clicked: false, reason: "legacy-click-no-place-request" }; } } console.log( `[rpa] mothership-styled-click:ok method=legacy-only "${(legacyOnly.matchedText ?? "").slice(0, 64)}"`, ); return { ...legacyOnly, method: "legacy-only" }; } if (options?.preferLegacyFirst) { const legacyFast = await clickStyledRowByLabel(page, description, query); if (legacyFast.clicked) { if (rush) { return { ...legacyFast, method: "legacy-label-row-fast-rush" }; } if (options?.bridge && options?.placeId) { const committed = await options.bridge.waitForPlaceCommit( options.placeId, placeCommitMs, ); if (committed) { console.log( `[rpa] mothership-styled-click:ok method=legacy-label-row-fast "${(legacyFast.matchedText ?? "").slice(0, 64)}"`, ); return { ...legacyFast, method: "legacy-label-row-fast" }; } } else if (!options?.bridge) { return { ...legacyFast, method: "legacy-label-row-fast" }; } } } let labels = await collectWidgetLabels(page); if (labels.length > 0) { console.log( `[rpa] mothership-styled-click:widget-labels ${JSON.stringify(labels.slice(0, 4))}`, ); } const goldTargets = options?.side ? findGoldStandardTargets(description, query, options.side, 70) : []; if (goldTargets.length > 0) { console.log( `[rpa] mothership-styled-click:gold-targets ${goldTargets.length} best="${goldTargets[0]!.textPreview.slice(0, 48)}"`, ); } const strategies = buildGoldDrivenLocators(page, description, query, { side: options?.side, goldTargets, omitScFallback: Boolean(options?.placeId), }); const tryMultiStrategy = async (): Promise<{ clicked: boolean; reason?: string; matchedText?: string; method?: string; }> => { const multi = await tryLocatorStrategies( page, strategies, description, query, { bridge: options?.bridge, placeId: options?.placeId, maxAttempts: options?.maxStrategyAttempts ?? 3, placeCommitTimeoutMs: placeCommitMs, }, ); if (multi.clicked) { return multi; } labels = await collectWidgetLabels(page); if (!labels.length) { return { clicked: false, reason: "no-labels" }; } const idx = pickLabelIndex( labels, description, query, options?.candidateIndex ?? -1, ); const targetLabel = labels[idx] ?? labels[0]!; const legacy = await clickStyledRowByLabel(page, targetLabel, query); if (!legacy.clicked) { return legacy; } if (rush) { return { ...legacy, method: "legacy-label-row-rush" }; } if (options?.bridge && options?.placeId) { const committed = await options.bridge.waitForPlaceCommit( options.placeId, placeCommitMs, ); if (!committed) { return { clicked: false, reason: "legacy-click-no-place-request" }; } } return { ...legacy, method: "legacy-label-row" }; }; let result = await tryMultiStrategy(); if (!result.clicked && options?.input && !options?.skipReopen) { await reopenStyledSuggestionDropdown(page, options.input, query, description); await waitForStyledSuggestionRows(page, [], query, 8_000); result = await tryMultiStrategy(); } if (!result.clicked) { console.log( `[rpa] mothership-styled-click:miss reason=${result.reason ?? "unknown"}`, ); return result; } if (!options?.fastClick) { await awaitHumanPacing(page); await page.waitForTimeout(250); } console.log( `[rpa] mothership-styled-click:ok method=${result.method ?? "unknown"} "${(result.matchedText ?? "").slice(0, 64)}"`, ); return result; } /** search 已返回但 styled 联想 DOM 不可见时,用键盘索引点选 */ export async function tryStyledKeyboardCommit( page: Page, input: Locator, bridge: AxelSelectionBridge, placeId: string, candidateIndex: number, ): Promise { await dismissCookieConsent(page); await input.scrollIntoViewIfNeeded().catch(() => undefined); await input.click({ force: true, timeout: 5_000 }).catch(() => undefined); await page.waitForTimeout(250); const steps = candidateIndex >= 0 ? candidateIndex + 1 : 1; for (let i = 0; i < steps; i += 1) { await page.keyboard.press("ArrowDown"); await page.waitForTimeout(150); } await page.keyboard.press("Enter"); const committed = await bridge.waitForPlaceCommit(placeId, 10_000); if (committed) { console.log( `[rpa] mothership-styled-keyboard:ok index=${candidateIndex} placeId=${placeId.slice(0, 16)}`, ); } return committed; } /** @deprecated 保留单测字符串探针 */ export const FIND_MOTHERSHIP_SUGGESTION_TARGET = `() => ({ ok: false, reason: "use-locator-click" })`; export const CLICK_MOTHERSHIP_STYLED_SUGGESTION = FIND_MOTHERSHIP_SUGGESTION_TARGET; export const CLICK_WIDGET_SUGGESTION_BY_LABEL = FIND_MOTHERSHIP_SUGGESTION_TARGET; export const LIST_STYLED_SUGGESTION_ROWS = `() => []`;