|
|
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<HTMLElement>(
|
|
|
'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<HTMLInputElement>("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<void> {
|
|
|
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<void> {
|
|
|
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<void> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
await dismissCookieConsent(ctx.page);
|
|
|
await scrollQuoteWidgetIntoView(ctx, { force: true });
|
|
|
}
|
|
|
|
|
|
async function waitForQuoterAssets(page: Page): Promise<void> {
|
|
|
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<QuoterWidgetHydrationProbe> {
|
|
|
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<void> {
|
|
|
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);
|
|
|
}
|