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.

231 lines
7.1 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 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";
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): RPAContext {
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);
}