|
|
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/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<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 = `() => []`;
|