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.

358 lines
10 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 { 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 },
);
}