import fs from "node:fs"; import path from "node:path"; import type { Page } from "playwright"; import { RPA_WIDGET_HYDRATION_TIMEOUT_MS } from "@/lib/constants/rpa"; import { getSelector } from "@/lib/rpa/selectors"; import { SCROLL_WIDGET_INTO_VIEW } from "@/workers/rpa/kernel/browser-scripts"; import { resolveRPAContext, type RPAContext, } from "@/workers/rpa/kernel/context"; import type { IRPAContext } from "@/workers/rpa/kernel/context-validator"; const SCROLL_COOLDOWN_MS = 4_000; const WIDGET_HYDRATION_POLL_MS = 400; const PLACES_SCRIPT_TIMEOUT_MS = 20_000; export type QuoterWidgetHydrationProbe = { hydrated: boolean; editableSearchInputs: number; widgetTextPreview: string; }; export function isQuoterWidgetHydrated( editableSearchInputs: number, widgetTextPreview: string, ): boolean { const skeletonLike = editableSearchInputs === 0 && /sign up and book|get a quote|loading/i.test(widgetTextPreview) && widgetTextPreview.length < 120; return editableSearchInputs >= 1 && !skeletonLike; } /** 浏览器内探测 quoter widget 是否完成 hydration(非骨架屏) */ export function probeQuoterWidgetHydration( widgetSelector: string, ): QuoterWidgetHydrationProbe { const widget = document.querySelector(widgetSelector); if (!widget) { return { hydrated: false, editableSearchInputs: 0, widgetTextPreview: "" }; } const probeText = ( (widget as HTMLElement).innerText ?? widget.textContent ?? "" ) .replace(/\s+/g, " ") .trim(); const searchInputs = widget.querySelectorAll( 'input[placeholder*="Search" i], input[placeholder*="搜索"], [role="textbox"], [role="combobox"] input, input[type="text"]', ); let editableSearchInputs = 0; for (const node of searchInputs) { const inp = node instanceof HTMLInputElement ? node : node.querySelector("input"); const target = inp ?? node; const visible = target.offsetParent !== null; const editable = target instanceof HTMLInputElement ? !target.disabled && target.readOnly === false && target.getAttribute("aria-readonly") !== "true" : target.getAttribute("aria-disabled") !== "true" && target.getAttribute("contenteditable") !== "false"; if (visible && editable) { editableSearchInputs += 1; } } const hasPickupSection = /从何处取货|pick up from|where to pick up/i.test( probeText, ); const skeletonLike = editableSearchInputs === 0 && /sign up and book|get a quote|loading/i.test(probeText) && probeText.length < 120; return { hydrated: (editableSearchInputs >= 1 || hasPickupSection) && !skeletonLike, editableSearchInputs, widgetTextPreview: probeText.slice(0, 220), }; } function resolveCtx(pageOrCtx: Page | RPAContext): IRPAContext { return resolveRPAContext(pageOrCtx); } const HIDE_COOKIE_OVERLAY = `() => { var selectors = [ ".cky-consent-container", ".cky-modal", "#cookieyes", ".cky-overlay", "[class*='cookieyes']", ]; selectors.forEach(function(sel) { document.querySelectorAll(sel).forEach(function(el) { el.style.setProperty("display", "none", "important"); el.style.setProperty("visibility", "hidden", "important"); el.style.setProperty("pointer-events", "none", "important"); }); }); }`; /** 关闭 CookieYes / 通用 Cookie 横幅,避免遮挡报价 widget */ export async function dismissCookieConsent(page: Page): Promise { const candidates = [ page.getByRole("button", { name: /^accept$/i }), page.getByRole("button", { name: /accept all/i }), page.getByRole("button", { name: /全部接受|接受全部/i }), page.locator('button:has-text("Accept All")'), page.locator('button:has-text("Accept all")'), page.locator(".cky-btn-accept"), page.locator("#cookieyes button.cky-btn-accept"), ]; for (const loc of candidates) { const btn = loc.first(); if (await btn.isVisible().catch(() => false)) { await btn.click({ timeout: 3_000, force: true }).catch(() => undefined); await page.waitForTimeout(400); await page.evaluate(HIDE_COOKIE_OVERLAY).catch(() => undefined); return; } } await page.evaluate(HIDE_COOKIE_OVERLAY).catch(() => undefined); } /** 将首页嵌入报价 widget 滚入视口(滚动时间戳存于 ctx,无模块级全局) */ export async function scrollQuoteWidgetIntoView( pageOrCtx: Page | RPAContext, options?: { force?: boolean }, ): Promise { const ctx = resolveCtx(pageOrCtx); const now = Date.now(); const last = ctx.getWidgetScrollAt(); if (!options?.force && now - last < SCROLL_COOLDOWN_MS) { return; } const widget = ctx.selectors.widget(ctx.page); await widget .scrollIntoViewIfNeeded({ timeout: 10_000 }) .catch(() => undefined); await ctx.exec .evaluateOnLocator(widget, SCROLL_WIDGET_INTO_VIEW) .catch(() => undefined); ctx.setWidgetScrollAt(Date.now()); await ctx.page.waitForTimeout(150); } /** 打开页面后的稳定化:Cookie + 滚动到报价区(入口强制滚一次) */ export async function stabilizeQuoteLandingPage( pageOrCtx: Page | RPAContext, ): Promise { const ctx = resolveCtx(pageOrCtx); await dismissCookieConsent(ctx.page); await scrollQuoteWidgetIntoView(ctx, { force: true }); } async function waitForQuoterAssets(page: Page): Promise { await page .waitForResponse( (response) => /quoter|landing-plugin|mothership.*widget/i.test(response.url()) && response.status() === 200, { timeout: RPA_WIDGET_HYDRATION_TIMEOUT_MS }, ) .catch(() => undefined); await page .waitForResponse( (response) => response.url().includes("maps.googleapis.com") && response.status() === 200, { timeout: PLACES_SCRIPT_TIMEOUT_MS }, ) .catch(() => undefined); } /** 等待 MotherShip 嵌入 quoter 完成 hydration(可编辑地址框出现) */ export async function waitForQuoterWidgetHydrated( page: Page, timeoutMs = RPA_WIDGET_HYDRATION_TIMEOUT_MS, ): Promise { const widgetSelector = getSelector("RPA_SELECTOR_QUOTE_WIDGET"); await waitForQuoterAssets(page); const deadline = Date.now() + timeoutMs; let last: QuoterWidgetHydrationProbe = { hydrated: false, editableSearchInputs: 0, widgetTextPreview: "", }; while (Date.now() < deadline) { last = await page.evaluate(probeQuoterWidgetHydration, widgetSelector); if (last.hydrated) { return last; } await page.waitForTimeout(WIDGET_HYDRATION_POLL_MS); } return last; } /** @deprecated 使用 lib/rpa/env.shouldKeepBrowserOpen */ export { shouldKeepBrowserOpen } from "@/lib/rpa/env"; /** 失败时落盘截图,便于对照 Cookie/滚动/填表阶段 */ export async function captureRpaDebugScreenshot( page: Page, label: string, ): Promise { const dir = path.join(process.cwd(), ".dev", "logs"); await fs.promises.mkdir(dir, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, "-"); const file = path.join(dir, `rpa-${label}-${ts}.png`); await page.screenshot({ path: file, fullPage: true }).catch(() => undefined); }