|
|
import type { Page } from "playwright";
|
|
|
import {
|
|
|
parseMothershipQuoteUrls,
|
|
|
validateMothershipQuoteUrls,
|
|
|
} from "@/lib/rpa/env";
|
|
|
import {
|
|
|
RPA_PAGE_GOTO_TIMEOUT_MS,
|
|
|
RPA_SESSION_WIDGET_TIMEOUT_MS,
|
|
|
} from "@/lib/constants/rpa";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import {
|
|
|
hasMothershipCredentials,
|
|
|
mapUrlToErrorCode,
|
|
|
} from "@/modules/rpa/error-mapper";
|
|
|
import {
|
|
|
getStorageStateForContext,
|
|
|
invalidateStorageState,
|
|
|
persistContext,
|
|
|
} from "@/lib/rpa/storage-state";
|
|
|
import { waitForQuoteWidgetReady } from "@/workers/rpa/address-side";
|
|
|
import { stabilizeQuoteLandingPage } from "@/workers/rpa/page-prep";
|
|
|
|
|
|
const LOGIN_URL_PATTERNS = ["/login", "/sign-in", "/signin"];
|
|
|
const CLOUDFLARE_SELECTORS =
|
|
|
"#challenge-running, .cf-browser-verification, iframe[src*='challenges.cloudflare.com']";
|
|
|
const HUMAN_ENTRY_CLICK_SPECS = [
|
|
|
'role=link[name=/get.*quote/i]',
|
|
|
'role=button[name=/get.*quote/i]',
|
|
|
"text=Get a Quote",
|
|
|
"text=Get freight quote",
|
|
|
"text=获取货运报价",
|
|
|
];
|
|
|
|
|
|
export type QuotePageOpenOptions = {
|
|
|
/** 复用 quote_session storageState:跳过首页预热、缩短 widget 等待 */
|
|
|
reuseSession?: boolean;
|
|
|
};
|
|
|
|
|
|
export type QuotePageAdapter = {
|
|
|
resolveQuoteEntry(
|
|
|
page: Page,
|
|
|
options?: QuotePageOpenOptions,
|
|
|
): Promise<{ activeUrl: string }>;
|
|
|
preCheck(page: Page, options?: QuotePageOpenOptions): Promise<boolean>;
|
|
|
isLoginWithoutCredentials(page: Page): boolean;
|
|
|
};
|
|
|
|
|
|
function isLoginUrl(url: string): boolean {
|
|
|
const lower = url.toLowerCase();
|
|
|
return LOGIN_URL_PATTERNS.some((pattern) => lower.includes(pattern));
|
|
|
}
|
|
|
|
|
|
function readPageGotoTimeoutMs(): number {
|
|
|
const raw = process.env.RPA_PAGE_GOTO_TIMEOUT_MS?.trim();
|
|
|
if (!raw) {
|
|
|
return RPA_PAGE_GOTO_TIMEOUT_MS;
|
|
|
}
|
|
|
const parsed = Number(raw);
|
|
|
return Number.isFinite(parsed) && parsed >= 10_000
|
|
|
? parsed
|
|
|
: RPA_PAGE_GOTO_TIMEOUT_MS;
|
|
|
}
|
|
|
|
|
|
/** domcontentloaded 超时后回退 commit/load,应对慢网或长连接阻塞 */
|
|
|
async function openQuoteEntryUrl(page: Page, url: string): Promise<void> {
|
|
|
const timeout = readPageGotoTimeoutMs();
|
|
|
const strategies: Array<"domcontentloaded" | "commit" | "load"> = [
|
|
|
"domcontentloaded",
|
|
|
"commit",
|
|
|
"load",
|
|
|
];
|
|
|
let lastError: Error | undefined;
|
|
|
|
|
|
for (const waitUntil of strategies) {
|
|
|
try {
|
|
|
await page.goto(url, { waitUntil, timeout });
|
|
|
return;
|
|
|
} catch (error) {
|
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw lastError ?? new RpaError("QUOTE_ENTRY_UNAVAILABLE", `无法打开 ${url}`, {
|
|
|
retryable: false,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 模拟真人:首页预热 → 点击报价入口,携带 Referer/Cookie */
|
|
|
async function runHumanEntryWarmup(page: Page): Promise<void> {
|
|
|
if (process.env.RPA_HUMAN_ENTRY_FLOW !== "true") {
|
|
|
return;
|
|
|
}
|
|
|
const warmupUrl =
|
|
|
process.env.RPA_QUOTE_WARMUP_URL?.trim() || "https://www.mothership.com/";
|
|
|
await openQuoteEntryUrl(page, warmupUrl);
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
|
for (const spec of HUMAN_ENTRY_CLICK_SPECS) {
|
|
|
const target = page.locator(spec).first();
|
|
|
const visible = await target.isVisible().catch(() => false);
|
|
|
if (!visible) {
|
|
|
continue;
|
|
|
}
|
|
|
await target.click({ timeout: 8_000 }).catch(() => undefined);
|
|
|
await page
|
|
|
.waitForLoadState("domcontentloaded", { timeout: 10_000 })
|
|
|
.catch(() => undefined);
|
|
|
console.log(`[rpa] human-entry: clicked ${spec}`);
|
|
|
return;
|
|
|
}
|
|
|
console.warn("[rpa] human-entry: 未找到报价入口按钮,继续直接打开 URL");
|
|
|
}
|
|
|
|
|
|
export class MothershipQuotePageAdapter implements QuotePageAdapter {
|
|
|
isLoginWithoutCredentials(page: Page): boolean {
|
|
|
return isLoginUrl(page.url()) && !hasMothershipCredentials();
|
|
|
}
|
|
|
|
|
|
async preCheck(
|
|
|
page: Page,
|
|
|
options?: QuotePageOpenOptions,
|
|
|
): Promise<boolean> {
|
|
|
const url = page.url();
|
|
|
const urlCode = mapUrlToErrorCode(url);
|
|
|
|
|
|
if (urlCode === "RPA_CAPTCHA") {
|
|
|
throw new RpaError("RPA_CAPTCHA", "检测到验证码页面", {
|
|
|
retryable: false,
|
|
|
pauseWorker: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (this.isLoginWithoutCredentials(page)) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"login 页且无 Mothership 凭据(匿名入口失效)",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (urlCode === "SESSION_EXPIRED") {
|
|
|
throw new RpaError("SESSION_EXPIRED", "会话已过期,需重登", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const cfCount = await page.locator(CLOUDFLARE_SELECTORS).count();
|
|
|
if (cfCount > 0) {
|
|
|
throw new RpaError("RPA_CAPTCHA", "检测到 Cloudflare 验证", {
|
|
|
retryable: false,
|
|
|
pauseWorker: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const bodyText = await page.locator("body").innerText().catch(() => "");
|
|
|
if (/page not found|404|doesn't exist|has been moved/i.test(bodyText)) {
|
|
|
throw new RpaError("STRUCT_CHANGE", "报价页 404 或不可达", {
|
|
|
retryable: false,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
await waitForQuoteWidgetReady(
|
|
|
page,
|
|
|
options?.reuseSession ? RPA_SESSION_WIDGET_TIMEOUT_MS : undefined,
|
|
|
);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
async resolveQuoteEntry(
|
|
|
page: Page,
|
|
|
options?: QuotePageOpenOptions,
|
|
|
): Promise<{ activeUrl: string }> {
|
|
|
const urls = parseMothershipQuoteUrls(process.env.MOTHERSHIP_QUOTE_URLS);
|
|
|
const urlErrors = validateMothershipQuoteUrls(urls);
|
|
|
if (urlErrors.length > 0) {
|
|
|
throw new RpaError(
|
|
|
"QUOTE_ENTRY_UNAVAILABLE",
|
|
|
urlErrors.join(";"),
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const failures: string[] = [];
|
|
|
|
|
|
if (!options?.reuseSession) {
|
|
|
await runHumanEntryWarmup(page);
|
|
|
}
|
|
|
|
|
|
for (const url of urls) {
|
|
|
try {
|
|
|
await openQuoteEntryUrl(page, url);
|
|
|
await page
|
|
|
.waitForLoadState("domcontentloaded", { timeout: 10_000 })
|
|
|
.catch(() => undefined);
|
|
|
await stabilizeQuoteLandingPage(page);
|
|
|
|
|
|
if (this.isLoginWithoutCredentials(page)) {
|
|
|
invalidateStorageState();
|
|
|
await openQuoteEntryUrl(page, url);
|
|
|
await stabilizeQuoteLandingPage(page);
|
|
|
if (this.isLoginWithoutCredentials(page)) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`入口 ${url} 要求登录且无凭据`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { activeUrl: url };
|
|
|
} catch (error) {
|
|
|
const message =
|
|
|
error instanceof Error ? error.message : String(error ?? "unknown");
|
|
|
failures.push(`${url}: ${message}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"QUOTE_ENTRY_UNAVAILABLE",
|
|
|
`全部报价入口不可用:${failures.join(" | ")}(请检查网络/VPN、.rpa/mothership-storage.json 是否有效,或设 RPA_HEADLESS=false 重录登录态)`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export const quotePageAdapter = new MothershipQuotePageAdapter();
|