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.

227 lines
6.7 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 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();