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 { 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 { 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( '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( '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( '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 { 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 { 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 { 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 { 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 { return firstVisibleFromSpecs(page, getSelectorSpecs("RPA_SELECTOR_SUBMIT")); } /** 采集 business-ready 各闸门快照(不等待) */ export async function debugBusinessStateSnapshot( page: Page, options?: QuoteSubmitOptions, ): Promise { 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 { 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 }, ); }