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/mothership-styled-suggestio...

687 lines
20 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 { 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<string>();
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<string[]> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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/listboxevaluate 派发事件不可靠,改用 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<boolean> {
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 = `() => []`;