|
|
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-card(ABF/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] 询价完成,跳过 persistContext(freshCargoContext)");
|
|
|
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);
|
|
|
}
|