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.

286 lines
9.2 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 { BrowserContext, Page } from "playwright";
import { REQUIRED_QUOTE_TIERS } from "@/lib/constants/quote";
import { isRpaMockMode } from "@/lib/rpa/selectors";
import type {
QuoteItem,
QuoteProvider,
QuoteRequest,
QuoteResult,
} from "@/modules/providers/quote-provider";
import { RpaError } from "@/modules/rpa/errors";
import { getConfidenceScore } from "@/modules/quote/confidence";
import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request";
import { hasEffectiveMothershipLogin } from "@/lib/rpa/mothership-login-context";
import { runMothershipLoggedInDashboardQuote } from "@/workers/rpa/mothership-logged-in-quote";
import {
isDirectErrorFatalWithoutWidget,
shouldFallbackToWidgetAfterDirectError,
} from "@/lib/rpa/direct-quote-fallback";
import {
isParkedSessionEnabled,
shouldKeepBrowserOpen,
isWidgetQuoteFallbackEnabled,
} from "@/lib/rpa/env";
import { getRpaAddressMode, isRpaAddressMockMode } from "@/lib/rpa/address-mode";
import { storageStateExists } from "@/lib/rpa/storage-state";
import { RPAContext } from "@/workers/rpa/kernel/context";
import { runMothershipQuoteKernel } from "@/workers/rpa/kernel/orchestrator";
import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step";
import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step";
import {
validateResult,
} from "@/workers/rpa/kernel/steps/quote-step";
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
import {
openQuotePage,
persistContext,
withRpaSessionLock,
} from "@/workers/rpa/session-manager";
import {
takeParkedQuoteSession,
prepareParkedQuotePage,
} from "@/workers/rpa/parked-quote-session";
import { captureRpaDebugScreenshot } from "@/workers/rpa/page-prep";
function mockQuoteItems(): QuoteItem[] {
const base = [
{ freight: 320, surcharges: 45, days: "3-5" },
{ freight: 380, surcharges: 45, days: "2-3" },
{ freight: 420, surcharges: 50, days: "3-5" },
{ freight: 480, surcharges: 50, days: "2-3" },
];
return REQUIRED_QUOTE_TIERS.map((tier, index) => {
const sample = base[index] ?? base[0];
const rawTotal = sample.freight + sample.surcharges;
return {
serviceLevel: tier.service_level as QuoteItem["serviceLevel"],
rateOption: tier.rate_option as QuoteItem["rateOption"],
carrier: "Mock Carrier",
transitDays: sample.days,
transitDescription: `${sample.days} business days`,
rawFreight: sample.freight,
surcharges: sample.surcharges,
rawTotal,
};
});
}
export { validateResult } from "@/workers/rpa/kernel/steps/quote-step";
/** @deprecated 使用 fillAddressStep(ctx, req) */
export async function fillAddress(page: Page, req: QuoteRequest): Promise<void> {
await fillAddressStep(RPAContext.fromSession(page), req);
}
/** @deprecated 使用 fillCargoStep(ctx, req) */
export async function fillCargo(page: Page, req: QuoteRequest): Promise<void> {
await fillCargoStep(RPAContext.fromSession(page), req);
}
/** @deprecated 使用 runMothershipQuoteKernel / captureQuotesDirectFirst */
export async function scrapeQuotes(_page: Page): Promise<QuoteItem[]> {
throw new RpaError(
"RPA_DATA_INVALID",
"scrapeQuotes 已废弃:报价须走 Direct 优先策略runMothershipQuoteKernel",
{ retryable: false },
);
}
export class MothershipRPAProvider implements QuoteProvider {
async getQuote(req: QuoteRequest): Promise<QuoteResult> {
if (isRpaMockMode()) {
const items = mockQuoteItems();
validateResult(items);
return {
items,
sourceType: "rpa",
confidenceScore: getConfidenceScore("rpa"),
};
}
// 有账密:强制 dashboard rate-cardABF/XPO/SAIA禁止匿名 Axel Direct
if (hasEffectiveMothershipLogin()) {
console.log(
"[rpa] getQuote: 已配置 MotherShip 账密,跳过 Axel Direct → dashboard DOM",
);
const items = await runMothershipLoggedInDashboardQuote(req);
validateResult(items);
return {
items,
sourceType: "rpa",
confidenceScore: getConfidenceScore("rpa"),
};
}
try {
const items = await fetchAxelQuoteItems(req);
validateResult(items);
console.log(
`[rpa] getQuote: node-direct 成功 tiers=${items.length}(不启动浏览器)`,
);
return {
items,
sourceType: "rpa",
confidenceScore: getConfidenceScore("rpa"),
};
} catch (error) {
const brief = error instanceof Error ? error.message : String(error);
console.log(
`[rpa] getQuote: node-direct 失败 — ${brief.slice(0, 200)}`,
);
if (shouldFallbackToWidgetAfterDirectError(error)) {
console.log(
"[rpa] getQuote: CARRIER_NO_CAPACITY回退 Widget 浏览器抓价",
);
}
const directFatal = isDirectErrorFatalWithoutWidget(error);
if (!isWidgetQuoteFallbackEnabled() || directFatal) {
if (error instanceof RpaError) {
throw error;
}
throw new RpaError("RPA_DATA_INVALID", brief, { retryable: true });
}
}
const session: {
page: Page | null;
context: BrowserContext | null;
rpaCtx: RPAContext | null;
} = {
page: null,
context: null,
rpaCtx: null,
};
const executeQuote = async (
relogin: boolean,
tryParked: boolean,
): Promise<QuoteItem[]> => {
return withRpaSessionLock(async () => {
console.log(
`[rpa] executeQuote start relogin=${relogin} addressMode=${getRpaAddressMode()}`,
);
if (!relogin && tryParked && req.quoteSessionId) {
const parked = await takeParkedQuoteSession(req.quoteSessionId, {
waitMs: 5_000,
});
if (parked) {
const reuseStarted = Date.now();
session.page = parked.page;
session.context = parked.context;
session.rpaCtx = RPAContext.fromSession(
parked.page,
parked.context,
);
console.log(
"[rpa] step: reuseParkedSession候选+询价单次会话续跑)",
);
await prepareParkedQuotePage(session.page);
const items = await runMothershipQuoteKernel(
session.rpaCtx,
req,
);
await persistContext(session.context);
console.log(
`[rpa] reuseParkedSession 完成 ${Date.now() - reuseStarted}ms`,
);
return items;
}
console.log(
"[rpa] 驻留会话不可用,回退 storageState 开页",
);
}
const opened = await openQuotePage(
quotePageAdapter,
relogin,
undefined,
{ freshCargoContext: true },
);
session.page = opened.page;
session.context = opened.context;
session.rpaCtx = RPAContext.fromSession(
opened.page,
opened.context,
);
console.log("[rpa] step: preCheck");
await quotePageAdapter.preCheck(session.page, {
reuseSession: false,
});
const items = await runMothershipQuoteKernel(session.rpaCtx, req);
console.log("[rpa] 询价完成,跳过 persistContextfreshCargoContext");
return items;
});
};
try {
let items: QuoteItem[];
try {
items = await executeQuote(false, isParkedSessionEnabled());
} catch (error) {
if (
error instanceof RpaError &&
error.code === "SESSION_EXPIRED"
) {
await session.page?.close().catch(() => undefined);
await session.context?.close().catch(() => undefined);
session.page = null;
session.context = null;
session.rpaCtx = null;
items = await executeQuote(true, false);
} else {
throw error;
}
}
return {
items,
sourceType: "rpa",
confidenceScore: getConfidenceScore("rpa"),
};
} catch (error) {
if (session.page) {
await captureRpaDebugScreenshot(session.page, "failure");
}
if (error instanceof RpaError) {
throw error;
}
throw new RpaError(
"PAGE_LOAD_TIMEOUT",
error instanceof Error ? error.message : "RPA 执行异常",
);
} finally {
if (!shouldKeepBrowserOpen()) {
await session.page?.close().catch(() => undefined);
await session.context?.close().catch(() => undefined);
} else if (isRpaAddressMockMode()) {
// mock 探通:避免保活 Browser 僵死拖垮后续 job
await session.page?.close().catch(() => undefined);
await session.context?.close().catch(() => undefined);
}
}
}
async healthCheck(): Promise<boolean> {
if (isRpaMockMode()) {
return true;
}
try {
const { ensureMothershipStorageState } = await import(
"@/lib/rpa/ensure-storage-state"
);
await ensureMothershipStorageState();
return storageStateExists();
} catch {
return false;
}
}
}
export const mothershipProvider = new MothershipRPAProvider();
/** @deprecated 使用 quotePageAdapter.preCheck */
export async function preCheck(page: Page): Promise<boolean> {
return quotePageAdapter.preCheck(page);
}