|
|
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<SuggestionMouseTarget[]> {
|
|
|
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<string> {
|
|
|
return readWidgetCombinedText(page, widgetSelector).catch(() => "");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 唯一联想确认:截图两行 chip(门牌街名 + City, ST)。
|
|
|
* 输入框内多逗号长串打字态 → 未确认。
|
|
|
*/
|
|
|
export async function isAddressCommittedVisually(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
address: AddressFields,
|
|
|
_input?: Locator,
|
|
|
): Promise<boolean> {
|
|
|
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<void> {
|
|
|
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<boolean> {
|
|
|
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;
|
|
|
}
|