import type { Locator, Page } from "playwright"; import type { QuoteRequest } from "@/modules/providers/quote-provider"; import { captureRpaDebugScreenshot } from "@/workers/rpa/page-prep"; import { readWidgetCombinedText } from "@/workers/rpa/address-commit"; import { isAddressChipVisuallyCommitted, isPickupChipVisuallyReady, } from "@/workers/rpa/address-chip-visual"; import { addressInputMatchesQuery, isAddressSuggestionDropdownOpen, } from "@/workers/rpa/address-suggestions"; import { isRpaFastQuoteMode, isRpaBlindAddressFlow } from "@/lib/rpa/env"; import { expandDirectionalStreet } from "@/lib/address/street-normalize"; import { COLLECT_SUGGESTION_MOUSE_TARGETS } from "@/workers/rpa/kernel/browser-scripts"; type AddressFields = QuoteRequest["pickup"]; export type SuggestionMouseTarget = { x: number; y: number; width: number; height: number; text: string; top: number; }; /** 收集输入框下方联想行的视口坐标(不依赖 role=listbox / PAC) */ export async function collectSuggestionMouseTargets( page: Page, input: Locator, ): Promise { const handle = await input.elementHandle().catch(() => null); if (!handle) { return []; } try { const targets = (await page.evaluate( COLLECT_SUGGESTION_MOUSE_TARGETS, handle, )) as SuggestionMouseTarget[]; return Array.isArray(targets) ? targets : []; } finally { await handle.dispose().catch(() => undefined); } } /** 读取 widget 可见文案,作为截图等价的视觉确认依据 */ export async function readWidgetVisualText( page: Page, widgetSelector: string, ): Promise { return readWidgetCombinedText(page, widgetSelector).catch(() => ""); } /** * 唯一联想确认:截图两行 chip(门牌街名 + City, ST)。 * 输入框内多逗号长串打字态 → 未确认。 */ export async function isAddressCommittedVisually( page: Page, widgetSelector: string, address: AddressFields, _input?: Locator, ): Promise { return isAddressChipVisuallyCommitted(page, widgetSelector, address); } /** 人工录制同款:getByText(street+city首词粘连) 等多候选 probe */ export function buildRecordingStyleTextProbes( street: string, city?: string, mothershipDisplayLabel?: string, ): string[] { const probes: string[] = []; const cityFirst = city?.trim().split(/\s+/)[0] ?? ""; const addProbe = (streetPart: string) => { const s = streetPart.trim(); if (!s) { return; } if (cityFirst) { probes.push(`${s}${cityFirst}`); probes.push(`${s}${cityFirst},`); probes.push(`${s}, ${cityFirst}`); } probes.push(s); }; if (mothershipDisplayLabel?.trim()) { const labelParts = mothershipDisplayLabel.split(",").map((p) => p.trim()); addProbe(labelParts[0] ?? ""); if (labelParts[1]) { const labelCityFirst = labelParts[1].split(/\s+/)[0] ?? ""; if (labelParts[0] && labelCityFirst) { probes.push(`${labelParts[0]}${labelCityFirst}`); probes.push(`${labelParts[0]}${labelCityFirst},`); probes.push(`${labelParts[0]}, ${labelCityFirst}`); } } } addProbe(street.split(",")[0]?.trim() ?? street.trim()); const expanded = expandDirectionalStreet(street); if (expanded) { addProbe(expanded); } return [...new Set(probes.filter((p) => p.length >= 6))]; } export function buildRecordingStyleTextProbe( street: string, city?: string, ): string { return buildRecordingStyleTextProbes(street, city)[0] ?? street; } export async function clickSuggestionLikeRecording( page: Page, street: string, city?: string, mothershipDisplayLabel?: string, ): Promise<{ clicked: boolean; probe: string; box?: { width: number; height: number } | null }> { const probes = buildRecordingStyleTextProbes(street, city, mothershipDisplayLabel); for (const probe of probes) { const loc = page.getByText(probe, { exact: false }).first(); if (!(await loc.isVisible().catch(() => false))) { continue; } const box = await loc.boundingBox().catch(() => null); if (box && (box.width > 900 || box.height > 200)) { continue; } if (box && box.width > 500) { continue; } if (box) { await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { delay: 40, }); } else { await loc.click({ force: true, timeout: 2_000 }).catch(() => undefined); } return { clicked: true, probe, box }; } return { clicked: false, probe: probes[0] ?? street }; } /** * 盲执行:尽力点联想,不检测是否选中成功。 * 顺序:recording getByText → ArrowDown+Enter → Escape+Tab */ export async function blindClickAddressSuggestion( page: Page, input: Locator, address: AddressFields, label = "blind-suggestion", ): Promise { console.log(`[rpa] ${label}: blind-flow 点选(不检测确认)`); await input.click({ force: true, timeout: 3_000 }).catch(() => undefined); await page.waitForTimeout(80); await page.keyboard.press("ArrowDown").catch(() => undefined); await page.waitForTimeout(120); const recording = await clickSuggestionLikeRecording( page, address.street, address.city, address.mothershipDisplayLabel, ); if (recording.clicked) { console.log(`[rpa] ${label}: recording probe="${recording.probe}"`); } else { await page.keyboard.press("Enter").catch(() => undefined); console.log(`[rpa] ${label}: recording 未命中,已 ArrowDown+Enter`); } await page.waitForTimeout(150); await page.keyboard.press("Escape").catch(() => undefined); await page.keyboard.press("Tab").catch(() => undefined); await page.waitForTimeout(100); } /** * 模拟鼠标点击联想行,仅以 widget 可见文案(截图等价)判定是否选中。 * 点击成功日志不算数;必须看到地址已写入 widget 才返回 true。 */ export async function mouseClickSuggestionUntilVisualConfirmed( page: Page, input: Locator, widgetSelector: string, address: AddressFields, label = "mouse-suggestion", options?: { reopenSuggestions?: boolean; blindFlow?: boolean }, ): Promise { if (options?.blindFlow || isRpaBlindAddressFlow()) { await blindClickAddressSuggestion(page, input, address, label); return true; } if ( await isAddressCommittedVisually(page, widgetSelector, address, input) ) { console.log(`[rpa] ${label}: 视觉已确认(下拉已关且非纯输入态),跳过点击`); return true; } const fast = isRpaFastQuoteMode(); const settleMs = fast ? 100 : 450; const focusMs = fast ? 80 : 300; await input.click({ force: true, timeout: 3_000 }).catch(() => undefined); await page.waitForTimeout(focusMs); if (options?.reopenSuggestions) { await input.click({ force: true, timeout: 2_000 }).catch(() => undefined); await input.press("End").catch(() => undefined); await input.press("Backspace").catch(() => undefined); await page.waitForTimeout(fast ? 80 : 200); await page.keyboard.press("ArrowDown").catch(() => undefined); await page.waitForTimeout(fast ? 150 : 300); if (!(await isAddressSuggestionDropdownOpen(page))) { await page.keyboard.press("ArrowDown").catch(() => undefined); await page.waitForTimeout(fast ? 100 : 200); } } const recordingClick = await clickSuggestionLikeRecording( page, address.street, address.city, address.mothershipDisplayLabel, ); if (recordingClick.clicked) { await page.waitForTimeout(settleMs); if (!fast) { await captureRpaDebugScreenshot(page, `${label}-recording-click`); } const recordingOk = await isAddressCommittedVisually( page, widgetSelector, address, input, ); console.log( `[rpa] ${label}: recording-click probe="${recordingClick.probe}" visual=${recordingOk}`, ); if (recordingOk) { return true; } } let targets = await collectSuggestionMouseTargets(page, input); if (!targets.length) { await page.waitForTimeout(fast ? 150 : 400); targets = await collectSuggestionMouseTargets(page, input); } if (!targets.length) { if (!fast) { await captureRpaDebugScreenshot(page, `${label}-no-targets`); } console.log(`[rpa] ${label}: 未找到可点击的联想行`); return false; } console.log( `[rpa] ${label}: 找到 ${targets.length} 个候选,` + targets .slice(0, 3) .map((t) => `"${t.text.slice(0, 36)}"@${Math.round(t.x)},${Math.round(t.y)}`) .join("; "), ); for (let i = 0; i < targets.length; i++) { const target = targets[i]!; await page.mouse.move(target.x, target.y); await page.mouse.click(target.x, target.y, { delay: 40 }); await page.waitForTimeout(settleMs); if (!fast) { await captureRpaDebugScreenshot(page, `${label}-after-click-${i}`); } const widgetText = await readWidgetVisualText(page, widgetSelector); const confirmed = await isAddressCommittedVisually( page, widgetSelector, address, input, ); const dropdownOpen = await isAddressSuggestionDropdownOpen(page); console.log( `[rpa] ${label}: click#${i} visual=${confirmed} dropdown=${dropdownOpen} ` + `widget="${widgetText.slice(0, 80)}"`, ); if (confirmed) { return true; } if (!dropdownOpen) { await input.click({ force: true, timeout: 2_000 }).catch(() => undefined); await page.waitForTimeout(250); targets = await collectSuggestionMouseTargets(page, input); if (!targets.length) { break; } } } await captureRpaDebugScreenshot(page, `${label}-all-failed`); return false; }