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

320 lines
9.6 KiB

This file contains ambiguous Unicode characters!

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

import 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;
}