|
|
import type { Locator, Page } from "playwright";
|
|
|
import { isRpaAddressMockMode } from "@/lib/rpa/address-mode";
|
|
|
import {
|
|
|
firstVisibleFromSpecs,
|
|
|
getSelectorSpecs,
|
|
|
} from "@/lib/rpa/selector-specs";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import {
|
|
|
isAddressSearchEditable,
|
|
|
isAddressesLockedForSubmit,
|
|
|
isCargoCommittedState,
|
|
|
} from "@/workers/rpa/cargo-lock";
|
|
|
import { dismissCookieConsent } from "@/workers/rpa/page-prep";
|
|
|
import type { QuoteSubmitOptions } from "@/workers/rpa/quote-capture/types";
|
|
|
|
|
|
const WIDGET_SELECTOR = "#react-quoter-landing-plugin";
|
|
|
const BUSINESS_READY_TIMEOUT_MS = 30_000;
|
|
|
const POLL_MS = 300;
|
|
|
const DOM_STABLE_MS = 600;
|
|
|
|
|
|
const COOKIE_OVERLAY_SELECTORS = [
|
|
|
"#cookieyes",
|
|
|
".cky-consent-container",
|
|
|
".cky-modal",
|
|
|
'[id*="cookie" i]',
|
|
|
'[class*="cookie-banner" i]',
|
|
|
'[class*="privacy-banner" i]',
|
|
|
] as const;
|
|
|
|
|
|
export type BusinessReadyProbe = {
|
|
|
buttonEnabled: boolean;
|
|
|
cookieClear: boolean;
|
|
|
cargoLocked: boolean;
|
|
|
hydrationReady: boolean;
|
|
|
addressesLocked: boolean;
|
|
|
submitHitTestOk: boolean;
|
|
|
domStable: boolean;
|
|
|
reasons: string[];
|
|
|
};
|
|
|
|
|
|
export type BusinessReadySnapshot = BusinessReadyProbe & {
|
|
|
ready: boolean;
|
|
|
url: string;
|
|
|
submitLabel: string | null;
|
|
|
widgetTextPreview: string;
|
|
|
};
|
|
|
|
|
|
export function evaluateBusinessReadyGates(
|
|
|
probe: Omit<BusinessReadyProbe, "reasons">,
|
|
|
): BusinessReadyProbe {
|
|
|
const reasons: string[] = [];
|
|
|
if (!probe.hydrationReady) {
|
|
|
reasons.push("hydration 未完成");
|
|
|
}
|
|
|
if (!probe.domStable) {
|
|
|
reasons.push("DOM 未稳定");
|
|
|
}
|
|
|
if (!probe.addressesLocked) {
|
|
|
reasons.push("地址未锁定");
|
|
|
}
|
|
|
if (!probe.cargoLocked) {
|
|
|
reasons.push("货物状态未锁定");
|
|
|
}
|
|
|
if (!probe.buttonEnabled) {
|
|
|
reasons.push("提交按钮 disabled");
|
|
|
}
|
|
|
if (!probe.cookieClear) {
|
|
|
reasons.push("Cookie 横幅遮挡");
|
|
|
}
|
|
|
if (!probe.submitHitTestOk) {
|
|
|
reasons.push("提交按钮 pointer 被拦截");
|
|
|
}
|
|
|
return { ...probe, reasons };
|
|
|
}
|
|
|
|
|
|
export function isBusinessReady(
|
|
|
probe: BusinessReadyProbe,
|
|
|
options?: QuoteSubmitOptions,
|
|
|
): boolean {
|
|
|
const addressesOk =
|
|
|
options?.skipAddressLock || probe.addressesLocked;
|
|
|
return (
|
|
|
probe.buttonEnabled &&
|
|
|
probe.cookieClear &&
|
|
|
probe.cargoLocked &&
|
|
|
probe.hydrationReady &&
|
|
|
addressesOk &&
|
|
|
probe.submitHitTestOk &&
|
|
|
probe.domStable
|
|
|
);
|
|
|
}
|
|
|
|
|
|
type WidgetDomProbe = {
|
|
|
hydrated: boolean;
|
|
|
addressesLocked: boolean;
|
|
|
cargoLocked: boolean;
|
|
|
widgetTextPreview: string;
|
|
|
};
|
|
|
|
|
|
async function probeWidgetDom(page: Page): Promise<WidgetDomProbe> {
|
|
|
return page.evaluate((widgetSel) => {
|
|
|
const widget = document.querySelector(widgetSel);
|
|
|
if (!widget) {
|
|
|
return {
|
|
|
hydrated: false,
|
|
|
addressesLocked: false,
|
|
|
cargoLocked: false,
|
|
|
widgetTextPreview: "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const text = (widget.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
|
const innerText = (
|
|
|
(widget as HTMLElement).innerText ?? widget.textContent ?? ""
|
|
|
)
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
const probeText = innerText || text;
|
|
|
const zipMatches = probeText.match(/\b\d{5}(?:-\d{4})?\b/g) ?? [];
|
|
|
const hasTwoZips = zipMatches.length >= 2;
|
|
|
const stateMatches = probeText.match(/,\s*[A-Z]{2}/g) ?? [];
|
|
|
const hasTwoStatePairs = stateMatches.length >= 2;
|
|
|
|
|
|
const searchInputs = widget.querySelectorAll<HTMLInputElement>(
|
|
|
'input[placeholder*="Search" i], input[placeholder*="搜索"]',
|
|
|
);
|
|
|
let editableSearch = 0;
|
|
|
for (const inp of searchInputs) {
|
|
|
const visible = inp.offsetParent !== null;
|
|
|
const editable =
|
|
|
!inp.disabled &&
|
|
|
inp.readOnly === false &&
|
|
|
inp.getAttribute("aria-readonly") !== "true";
|
|
|
if (visible && editable) {
|
|
|
editableSearch += 1;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const cargoInputs = widget.querySelectorAll<HTMLInputElement>(
|
|
|
'input[placeholder*="Weight" i], input[placeholder*="Pallet" i], input[placeholder*="重量"], input[placeholder*="托盘"]',
|
|
|
);
|
|
|
let visibleCargoInputs = 0;
|
|
|
for (const inp of cargoInputs) {
|
|
|
if (inp.offsetParent !== null) {
|
|
|
visibleCargoInputs += 1;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const modalOpen = Boolean(
|
|
|
document.querySelector(
|
|
|
'[role="dialog"][aria-modal="true"], [aria-modal="true"][data-state="open"]',
|
|
|
),
|
|
|
);
|
|
|
|
|
|
const hasPalletSummary =
|
|
|
/\d+\s*托盘s?/i.test(probeText) ||
|
|
|
/\d+\s*pallets?/i.test(probeText) ||
|
|
|
/\d+\s*lbs?\b/i.test(probeText) ||
|
|
|
/\d+\s*公斤/.test(probeText) ||
|
|
|
/\d+\s*x\s*\d+\s*x\s*\d+/i.test(probeText) ||
|
|
|
/\d+W,\s*x\s*\d+L/i.test(probeText) ||
|
|
|
Boolean(
|
|
|
widget.querySelector<HTMLInputElement>(
|
|
|
'input[placeholder*="托盘"], input[placeholder*="Pallet" i]',
|
|
|
)?.value,
|
|
|
);
|
|
|
|
|
|
const addressesLocked =
|
|
|
(hasTwoZips || hasTwoStatePairs) &&
|
|
|
(editableSearch === 0 || hasPalletSummary);
|
|
|
|
|
|
const cargoLocked = hasPalletSummary;
|
|
|
|
|
|
const hydrated =
|
|
|
widget.children.length > 0 && probeText.length > 24;
|
|
|
|
|
|
return {
|
|
|
hydrated,
|
|
|
addressesLocked,
|
|
|
cargoLocked,
|
|
|
widgetTextPreview: probeText.slice(0, 220),
|
|
|
};
|
|
|
}, WIDGET_SELECTOR);
|
|
|
}
|
|
|
|
|
|
async function isCookieOverlayVisible(page: Page): Promise<boolean> {
|
|
|
for (const sel of COOKIE_OVERLAY_SELECTORS) {
|
|
|
const loc = page.locator(sel).first();
|
|
|
if (!(await loc.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
const box = await loc.boundingBox().catch(() => null);
|
|
|
if (box && box.width > 40 && box.height > 40) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function isSubmitButtonEnabled(submit: Locator): Promise<boolean> {
|
|
|
if (!(await submit.isVisible().catch(() => false))) {
|
|
|
return false;
|
|
|
}
|
|
|
const disabled = await submit.isDisabled().catch(() => true);
|
|
|
if (disabled) {
|
|
|
return false;
|
|
|
}
|
|
|
const ariaDisabled = await submit.getAttribute("aria-disabled");
|
|
|
if (ariaDisabled === "true") {
|
|
|
return false;
|
|
|
}
|
|
|
const nativeDisabled = await submit.getAttribute("disabled");
|
|
|
return nativeDisabled === null;
|
|
|
}
|
|
|
|
|
|
async function isSubmitHitTestClear(
|
|
|
page: Page,
|
|
|
submit: Locator,
|
|
|
): Promise<boolean> {
|
|
|
const box = await submit.boundingBox().catch(() => null);
|
|
|
if (!box) {
|
|
|
return false;
|
|
|
}
|
|
|
const x = box.x + box.width / 2;
|
|
|
const y = box.y + box.height / 2;
|
|
|
return submit.evaluate(
|
|
|
(btn, point) => {
|
|
|
const top = document.elementFromPoint(point.x, point.y);
|
|
|
if (!top) {
|
|
|
return false;
|
|
|
}
|
|
|
const cookieHit = Boolean(
|
|
|
top.closest(
|
|
|
'#cookieyes, .cky-consent-container, .cky-modal, [id*="cookie" i], [class*="cookie-banner" i]',
|
|
|
),
|
|
|
);
|
|
|
if (cookieHit) {
|
|
|
return false;
|
|
|
}
|
|
|
return btn === top || btn.contains(top);
|
|
|
},
|
|
|
{ x, y },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function waitDomStable(page: Page): Promise<boolean> {
|
|
|
const first = await probeWidgetDom(page);
|
|
|
await page.waitForTimeout(DOM_STABLE_MS);
|
|
|
const second = await probeWidgetDom(page);
|
|
|
return (
|
|
|
first.widgetTextPreview === second.widgetTextPreview &&
|
|
|
first.hydrated === second.hydrated
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function resolveSubmitButton(page: Page): Promise<Locator | null> {
|
|
|
return firstVisibleFromSpecs(page, getSelectorSpecs("RPA_SELECTOR_SUBMIT"));
|
|
|
}
|
|
|
|
|
|
/** 采集 business-ready 各闸门快照(不等待) */
|
|
|
export async function debugBusinessStateSnapshot(
|
|
|
page: Page,
|
|
|
options?: QuoteSubmitOptions,
|
|
|
): Promise<BusinessReadySnapshot> {
|
|
|
await dismissCookieConsent(page).catch(() => undefined);
|
|
|
|
|
|
const submit = await resolveSubmitButton(page);
|
|
|
const widgetDom = await probeWidgetDom(page);
|
|
|
const domStable = await waitDomStable(page);
|
|
|
|
|
|
const buttonEnabled = submit ? await isSubmitButtonEnabled(submit) : false;
|
|
|
const cookieOverlay = await isCookieOverlayVisible(page);
|
|
|
const cookieClear = !cookieOverlay;
|
|
|
const submitHitTestOk =
|
|
|
submit && cookieClear
|
|
|
? await isSubmitHitTestClear(page, submit)
|
|
|
: false;
|
|
|
|
|
|
const submitLabel = submit
|
|
|
? await submit.innerText().catch(() => null)
|
|
|
: null;
|
|
|
|
|
|
const cargoLocked = await isCargoCommittedState(page, WIDGET_SELECTOR);
|
|
|
const addressSearchOpen = await isAddressSearchEditable(page, WIDGET_SELECTOR);
|
|
|
const addressesVisible = await isAddressesLockedForSubmit(page, WIDGET_SELECTOR);
|
|
|
const addressesLocked = isRpaAddressMockMode() || options?.skipAddressLock
|
|
|
? cargoLocked
|
|
|
: !addressSearchOpen && addressesVisible;
|
|
|
|
|
|
const probe = evaluateBusinessReadyGates({
|
|
|
buttonEnabled,
|
|
|
cookieClear,
|
|
|
cargoLocked,
|
|
|
hydrationReady: widgetDom.hydrated,
|
|
|
addressesLocked,
|
|
|
submitHitTestOk,
|
|
|
domStable,
|
|
|
});
|
|
|
|
|
|
const ready = isBusinessReady(probe, options);
|
|
|
|
|
|
return {
|
|
|
...probe,
|
|
|
ready,
|
|
|
url: page.url(),
|
|
|
submitLabel,
|
|
|
widgetTextPreview: widgetDom.widgetTextPreview,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* submit 前闸门:四项就绪 + DOM 稳定;未就绪则轮询 dismiss cookie 直至超时。
|
|
|
*/
|
|
|
export async function waitForBusinessReadyState(
|
|
|
page: Page,
|
|
|
options?: QuoteSubmitOptions,
|
|
|
): Promise<BusinessReadySnapshot> {
|
|
|
const deadline = Date.now() + BUSINESS_READY_TIMEOUT_MS;
|
|
|
let last: BusinessReadySnapshot | null = null;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
await dismissCookieConsent(page).catch(() => undefined);
|
|
|
const snapshot = await debugBusinessStateSnapshot(page, options);
|
|
|
last = snapshot;
|
|
|
|
|
|
if (snapshot.ready) {
|
|
|
console.log(
|
|
|
`[rpa] business-ready: ok url=${snapshot.url} submit="${snapshot.submitLabel ?? ""}"`,
|
|
|
);
|
|
|
return snapshot;
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
|
|
|
const failed = last ?? (await debugBusinessStateSnapshot(page));
|
|
|
console.log(
|
|
|
`[rpa] business-ready: FAIL ${JSON.stringify({
|
|
|
url: failed.url,
|
|
|
reasons: failed.reasons,
|
|
|
buttonEnabled: failed.buttonEnabled,
|
|
|
cookieClear: failed.cookieClear,
|
|
|
cargoLocked: failed.cargoLocked,
|
|
|
hydrationReady: failed.hydrationReady,
|
|
|
addressesLocked: failed.addressesLocked,
|
|
|
submitHitTestOk: failed.submitHitTestOk,
|
|
|
domStable: failed.domStable,
|
|
|
widgetTextPreview: failed.widgetTextPreview,
|
|
|
})}`,
|
|
|
);
|
|
|
|
|
|
throw new RpaError(
|
|
|
"QUOTE_ENTRY_UNAVAILABLE",
|
|
|
`报价表单未达 business-ready(${failed.reasons.join(";")})`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|