|
|
import type { Page } from "playwright";
|
|
|
import {
|
|
|
isRpaAddressRushMode,
|
|
|
isRpaBlindAddressFlow,
|
|
|
} from "@/lib/rpa/env";
|
|
|
import { pageLocator } from "@/lib/rpa/locator";
|
|
|
import {
|
|
|
clickFirstVisibleFromSpecs,
|
|
|
getSelectorSpecs,
|
|
|
} from "@/lib/rpa/selector-specs";
|
|
|
import { dismissCargoPopoverOnly } from "@/workers/rpa/cargo-lock";
|
|
|
import { scrollQuoteWidgetIntoView } from "@/workers/rpa/page-prep";
|
|
|
|
|
|
import type { QuoteSubmitOptions } from "@/workers/rpa/quote-capture/types";
|
|
|
|
|
|
const DEFAULT_WIDGET = "#react-quoter-landing-plugin";
|
|
|
|
|
|
/** 提交前 widget 是否仍含双地址 + 货物步(防误触后退/刷新后空表单) */
|
|
|
export async function isWidgetQuoteFormReady(
|
|
|
page: Page,
|
|
|
widgetSelector = DEFAULT_WIDGET,
|
|
|
): Promise<boolean> {
|
|
|
const text = ((await pageLocator(page, widgetSelector).innerText().catch(
|
|
|
() => "",
|
|
|
)) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
const stateHits = text.match(/,\s*[A-Z]{2}\b/g) ?? [];
|
|
|
return (
|
|
|
/Deliver to/i.test(text) &&
|
|
|
/Freight details/i.test(text) &&
|
|
|
stateHits.length >= 2
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 提交前准备:仅收起仍打开的货物弹层 + 滚入视口。
|
|
|
* 禁止点击 Freight details / 坐标盲点(会重开向导或触发后退)。
|
|
|
*/
|
|
|
export async function prepareWidgetForQuoteSubmit(
|
|
|
page: Page,
|
|
|
widgetSelector = DEFAULT_WIDGET,
|
|
|
): Promise<void> {
|
|
|
await dismissCargoPopoverOnly(page, widgetSelector);
|
|
|
await scrollQuoteWidgetIntoView(page, { force: true });
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 点击 widget 主栏绿色「Get a freight quote」(多策略 + enabled 检查)。
|
|
|
*/
|
|
|
export async function clickWidgetQuoteSubmitButton(
|
|
|
page: Page,
|
|
|
widgetSelector = DEFAULT_WIDGET,
|
|
|
options?: Pick<QuoteSubmitOptions, "blindAddressFlow">,
|
|
|
): Promise<boolean> {
|
|
|
await prepareWidgetForQuoteSubmit(page, widgetSelector);
|
|
|
|
|
|
const rushOrBlind =
|
|
|
isRpaAddressRushMode() ||
|
|
|
isRpaBlindAddressFlow() ||
|
|
|
options?.blindAddressFlow === true;
|
|
|
if (!(await isWidgetQuoteFormReady(page, widgetSelector))) {
|
|
|
if (rushOrBlind) {
|
|
|
console.log(
|
|
|
"[rpa] submit: blind/rush — 跳过表单就绪检测,直接尝试点击报价",
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[rpa] submit: 警告 — widget 表单未就绪(地址可能已被重置),取消点击以免空提交",
|
|
|
);
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
|
|
|
const candidates: Array<{ label: string; loc: ReturnType<typeof pageLocator> }> =
|
|
|
[
|
|
|
{
|
|
|
label: "widget-get-a-freight-quote",
|
|
|
loc: widget.getByRole("button", { name: /Get a freight quote/i }).first(),
|
|
|
},
|
|
|
{
|
|
|
label: "widget-get-freight-quote",
|
|
|
loc: widget.getByRole("button", { name: /Get freight quote/i }).first(),
|
|
|
},
|
|
|
{
|
|
|
label: "widget-get-quote",
|
|
|
loc: widget.getByRole("button", { name: /^GET QUOTE$/i }).first(),
|
|
|
},
|
|
|
{
|
|
|
label: "widget-text-filter",
|
|
|
loc: widget
|
|
|
.locator("button")
|
|
|
.filter({ hasText: /Get a freight quote/i })
|
|
|
.first(),
|
|
|
},
|
|
|
];
|
|
|
|
|
|
for (const { label, loc } of candidates) {
|
|
|
if (!(await loc.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
const disabled = await loc.isDisabled().catch(() => false);
|
|
|
if (disabled) {
|
|
|
console.log(`[rpa] submit: ${label} 可见但 disabled,跳过`);
|
|
|
continue;
|
|
|
}
|
|
|
await loc.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
try {
|
|
|
await loc.click({ force: true, timeout: 8_000 });
|
|
|
console.log(`[rpa] submit: 已点击 ${label}`);
|
|
|
return true;
|
|
|
} catch (error) {
|
|
|
console.log(
|
|
|
`[rpa] submit: ${label} 点击失败 — ${error instanceof Error ? error.message : String(error)}`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const fallback = await clickFirstVisibleFromSpecs(
|
|
|
page,
|
|
|
getSelectorSpecs("RPA_SELECTOR_SUBMIT"),
|
|
|
);
|
|
|
if (fallback) {
|
|
|
console.log("[rpa] submit: 已通过 RPA_SELECTOR_SUBMIT 回退点击");
|
|
|
}
|
|
|
return fallback;
|
|
|
}
|