|
|
/** GD-15:Mothership 报价入口 URL 解析与校验 */
|
|
|
|
|
|
import { assertRpaSelectorsEnv } from "@/lib/rpa/selectors";
|
|
|
|
|
|
const FORBIDDEN_QUOTE_URL_PATTERNS: RegExp[] = [
|
|
|
/dashboard\.mothership\.com/i,
|
|
|
/www\.mothership\.com\/quote/i,
|
|
|
];
|
|
|
|
|
|
export function parseMothershipQuoteUrls(raw: string | undefined): string[] {
|
|
|
if (!raw?.trim()) {
|
|
|
return [];
|
|
|
}
|
|
|
return raw
|
|
|
.split(",")
|
|
|
.map((item) => item.trim())
|
|
|
.filter(Boolean);
|
|
|
}
|
|
|
|
|
|
export function isForbiddenQuoteUrl(url: string): boolean {
|
|
|
return FORBIDDEN_QUOTE_URL_PATTERNS.some((pattern) => pattern.test(url));
|
|
|
}
|
|
|
|
|
|
export function validateMothershipQuoteUrls(urls: string[]): string[] {
|
|
|
const errors: string[] = [];
|
|
|
if (urls.length === 0) {
|
|
|
errors.push("MOTHERSHIP_QUOTE_URLS 未配置或为空");
|
|
|
return errors;
|
|
|
}
|
|
|
|
|
|
for (const url of urls) {
|
|
|
try {
|
|
|
new URL(url);
|
|
|
} catch {
|
|
|
errors.push(`无效的报价入口 URL: ${url}`);
|
|
|
continue;
|
|
|
}
|
|
|
if (isForbiddenQuoteUrl(url)) {
|
|
|
errors.push(
|
|
|
`禁止的报价入口 URL(GD-15): ${url};须使用 headed 录制验证的匿名公开入口`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return errors;
|
|
|
}
|
|
|
|
|
|
/** rpa-worker 启动前 fail-fast(RPA_MOCK_MODE=true 时跳过) */
|
|
|
export function assertRpaWorkerEnv(): void {
|
|
|
if (process.env.RPA_MOCK_MODE === "true") {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const urlErrors = validateMothershipQuoteUrls(
|
|
|
parseMothershipQuoteUrls(process.env.MOTHERSHIP_QUOTE_URLS),
|
|
|
);
|
|
|
if (urlErrors.length > 0) {
|
|
|
throw new Error(`[rpa-worker] ${urlErrors.join(";")}`);
|
|
|
}
|
|
|
|
|
|
assertRpaSelectorsEnv();
|
|
|
|
|
|
const legacy = process.env.MOTHERSHIP_QUOTE_URL?.trim();
|
|
|
if (legacy && isForbiddenQuoteUrl(legacy)) {
|
|
|
throw new Error(
|
|
|
`[rpa-worker] 禁止的 MOTHERSHIP_QUOTE_URL(GD-15): ${legacy}`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 基线兼容:取 URL 列表首项,否则回退已废弃的 MOTHERSHIP_QUOTE_URL */
|
|
|
export function getPrimaryMothershipQuoteUrl(): string {
|
|
|
const urls = parseMothershipQuoteUrls(process.env.MOTHERSHIP_QUOTE_URLS);
|
|
|
if (urls[0]) {
|
|
|
return urls[0];
|
|
|
}
|
|
|
|
|
|
const legacy = process.env.MOTHERSHIP_QUOTE_URL?.trim();
|
|
|
if (legacy) {
|
|
|
return legacy;
|
|
|
}
|
|
|
|
|
|
throw new Error("报价入口 URL 未配置(MOTHERSHIP_QUOTE_URLS)");
|
|
|
}
|
|
|
|
|
|
export const RPA_SELECTOR_ENV_KEYS = [
|
|
|
"RPA_SELECTOR_PICKUP_STREET",
|
|
|
"RPA_SELECTOR_DELIVERY_STREET",
|
|
|
"RPA_SELECTOR_ADDRESS_SUGGESTION",
|
|
|
"RPA_SELECTOR_SUBMIT",
|
|
|
/** @deprecated Network-First:报价不再使用 tab/price selector */
|
|
|
"RPA_SELECTOR_STANDARD_TAB",
|
|
|
"RPA_SELECTOR_GUARANTEED_TAB",
|
|
|
"RPA_SELECTOR_LOWEST_OPTION",
|
|
|
"RPA_SELECTOR_FASTEST_OPTION",
|
|
|
"RPA_SELECTOR_PRICE",
|
|
|
"RPA_SELECTOR_QUOTE_WIDGET",
|
|
|
] as const;
|
|
|
|
|
|
/** worker 启动必填(仅填表 + submit;报价来自 network layer) */
|
|
|
export const RPA_REQUIRED_SELECTOR_ENV_KEYS = [
|
|
|
"RPA_SELECTOR_PICKUP_STREET",
|
|
|
"RPA_SELECTOR_DELIVERY_STREET",
|
|
|
"RPA_SELECTOR_ADDRESS_SUGGESTION",
|
|
|
"RPA_SELECTOR_SUBMIT",
|
|
|
"RPA_SELECTOR_QUOTE_WIDGET",
|
|
|
] as const;
|
|
|
|
|
|
export const DEFAULT_RPA_STORAGE_STATE_PATH = ".rpa/mothership-storage.json";
|
|
|
|
|
|
/** MotherShip 报价 widget 强制 locale;默认 en-US 抑制翻译干扰 */
|
|
|
export function getRpaBrowserLocale(): string {
|
|
|
const raw = process.env.RPA_BROWSER_LOCALE?.trim();
|
|
|
return raw || "en-US";
|
|
|
}
|
|
|
|
|
|
/** RPA 浏览器会话配置:headed 调试是否保留窗口(唯一来源) */
|
|
|
export function shouldKeepBrowserOpen(): boolean {
|
|
|
if (process.env.RPA_KEEP_BROWSER_OPEN === "true") {
|
|
|
return true;
|
|
|
}
|
|
|
if (process.env.RPA_KEEP_BROWSER_OPEN === "false") {
|
|
|
return false;
|
|
|
}
|
|
|
return isRpaHeaded();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 有头调试:RPA_HEADED=true 或 RPA_HEADLESS=false
|
|
|
* (与 MotherShip logged-in 观测模式一致)
|
|
|
*/
|
|
|
export function isRpaHeaded(): boolean {
|
|
|
if (process.env.RPA_HEADED === "true") {
|
|
|
return true;
|
|
|
}
|
|
|
return process.env.RPA_HEADLESS === "false";
|
|
|
}
|
|
|
|
|
|
/** Playwright launch 用:有头时 false */
|
|
|
export function resolveRpaHeadless(): boolean {
|
|
|
return !isRpaHeaded();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 有头默认 slowMo=200(便于肉眼跟手);无头不设;可用 RPA_SLOW_MO_MS 覆盖
|
|
|
*/
|
|
|
export function resolveRpaSlowMoMs(): number | undefined {
|
|
|
const raw = process.env.RPA_SLOW_MO_MS?.trim();
|
|
|
if (raw !== undefined && raw !== "" && Number.isFinite(Number(raw))) {
|
|
|
const n = Number(raw);
|
|
|
return n > 0 ? n : undefined;
|
|
|
}
|
|
|
return isRpaHeaded() ? 200 : undefined;
|
|
|
}
|
|
|
|
|
|
/** Worker headless 默认保活 Browser 实例,避免候选/询价 job 间冷启动 */
|
|
|
export function shouldKeepBrowserWarm(): boolean {
|
|
|
if (process.env.RPA_KEEP_BROWSER_WARM === "false") {
|
|
|
return false;
|
|
|
}
|
|
|
if (process.env.RPA_KEEP_BROWSER_WARM === "true") {
|
|
|
return true;
|
|
|
}
|
|
|
return process.env.RPA_HEADLESS !== "false";
|
|
|
}
|
|
|
|
|
|
/** 进程内驻留页加速(实验性,默认关;需 worker + confirm 全链路才生效) */
|
|
|
export function isParkedSessionEnabled(): boolean {
|
|
|
return process.env.RPA_PARKED_SESSION === "true";
|
|
|
}
|
|
|
|
|
|
export type PlaceFixMode = "event" | "state" | "legacy";
|
|
|
|
|
|
/** place commit 修复模式:event=Google事件触发(默认) | state=React注入 | legacy=原逻辑 */
|
|
|
export function getRpaPlaceFixMode(): PlaceFixMode {
|
|
|
const mode = process.env.RPA_PLACE_FIX_MODE?.trim().toLowerCase();
|
|
|
if (mode === "state" || mode === "legacy") {
|
|
|
return mode;
|
|
|
}
|
|
|
return "event";
|
|
|
}
|
|
|
|
|
|
export type RpaQuoteMode = "direct" | "widget";
|
|
|
|
|
|
/**
|
|
|
* 报价模式(兼容旧配置;报价路径恒为 Direct 优先 → Widget fallback)。
|
|
|
* `widget` 不再表示「默认走 Widget 报价」,仅保留 env 兼容。
|
|
|
*/
|
|
|
export function getRpaQuoteMode(): RpaQuoteMode {
|
|
|
const mode = process.env.RPA_QUOTE_MODE?.trim().toLowerCase();
|
|
|
if (mode === "widget") {
|
|
|
return "widget";
|
|
|
}
|
|
|
return "direct";
|
|
|
}
|
|
|
|
|
|
export function isAxelDirectQuoteMode(): boolean {
|
|
|
return getRpaQuoteMode() === "direct";
|
|
|
}
|
|
|
|
|
|
/** API 侧 inline direct quote 快乐路径;默认开启,失败再回退队列 */
|
|
|
export function isInlineDirectQuoteEnabled(): boolean {
|
|
|
const raw = process.env.RPA_INLINE_DIRECT_QUOTE?.trim().toLowerCase();
|
|
|
return raw !== "false" && raw !== "0";
|
|
|
}
|
|
|
|
|
|
/** Widget 报价 fallback 是否启用(Direct 失败后);默认 true */
|
|
|
export function isWidgetQuoteFallbackEnabled(): boolean {
|
|
|
const raw = process.env.RPA_DISABLE_WIDGET_QUOTE_FALLBACK?.trim().toLowerCase();
|
|
|
return raw !== "true" && raw !== "1";
|
|
|
}
|
|
|
|
|
|
/** 方案 A:widget 双地址 vault + setState 拦截(widget 模式默认开启) */
|
|
|
export function isDualAddressPatchEnabled(): boolean {
|
|
|
if (isRpaAddressRushMode()) {
|
|
|
return false;
|
|
|
}
|
|
|
const explicit = process.env.RPA_DUAL_ADDRESS_PATCH?.trim().toLowerCase();
|
|
|
if (explicit === "false") {
|
|
|
return false;
|
|
|
}
|
|
|
if (explicit === "true") {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 可视验收冲刺:填址+货物后直接点报价,跳过锁定/契约/后端检测闸门 */
|
|
|
export function isRpaVisualRushMode(): boolean {
|
|
|
return process.env.RPA_VISUAL_RUSH === "true";
|
|
|
}
|
|
|
|
|
|
/** 20s 目标:单查询联想 + 短超时 + 跳过 Direct 重试(探针/热路径) */
|
|
|
export function isRpaFastQuoteMode(): boolean {
|
|
|
return process.env.RPA_FAST_QUOTE === "true";
|
|
|
}
|
|
|
|
|
|
/** 填址/货物 rush 闸门(visual-rush 与 fast-quote 共用) */
|
|
|
export function isRpaAddressRushMode(): boolean {
|
|
|
return isRpaVisualRushMode() || isRpaFastQuoteMode();
|
|
|
}
|
|
|
|
|
|
/** 盲执行:填址→点联想→继续,不检测 chip/commit,仅最终验报价 */
|
|
|
export function isRpaBlindAddressFlow(): boolean {
|
|
|
const raw = process.env.RPA_BLIND_ADDRESS_FLOW?.trim().toLowerCase();
|
|
|
if (raw === "false") {
|
|
|
return false;
|
|
|
}
|
|
|
if (raw === "true") {
|
|
|
return true;
|
|
|
}
|
|
|
return isRpaFastQuoteMode() || isRpaVisualRushMode();
|
|
|
}
|
|
|
|
|
|
function isValidMothershipOptionId(id: string | undefined): boolean {
|
|
|
const trimmed = id?.trim() ?? "";
|
|
|
if (!trimmed) {
|
|
|
return false;
|
|
|
}
|
|
|
if (trimmed.startsWith("ms_")) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** embed-demo / 消歧弹窗已选 MotherShip 候选:两侧均有真实 optionId */
|
|
|
export function isPreconfirmedMothershipQuote(
|
|
|
req: Pick<
|
|
|
import("@/modules/providers/quote-provider").QuoteRequest,
|
|
|
"pickup" | "delivery"
|
|
|
>,
|
|
|
): boolean {
|
|
|
const pickupId =
|
|
|
req.pickup.mothershipOptionId?.trim() || req.pickup.placeId?.trim();
|
|
|
const deliveryId =
|
|
|
req.delivery.mothershipOptionId?.trim() ||
|
|
|
req.delivery.placeId?.trim();
|
|
|
return (
|
|
|
req.pickup.selectedFromMothership === true &&
|
|
|
req.delivery.selectedFromMothership === true &&
|
|
|
isValidMothershipOptionId(pickupId) &&
|
|
|
isValidMothershipOptionId(deliveryId)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 环境变量或已确认 MotherShip 候选时启用 blind-flow */
|
|
|
export function resolveBlindAddressFlow(
|
|
|
req?: Pick<
|
|
|
import("@/modules/providers/quote-provider").QuoteRequest,
|
|
|
"pickup" | "delivery"
|
|
|
>,
|
|
|
): boolean {
|
|
|
if (isRpaBlindAddressFlow()) {
|
|
|
return true;
|
|
|
}
|
|
|
return Boolean(req && isPreconfirmedMothershipQuote(req));
|
|
|
}
|
|
|
|
|
|
export type BlindAddressFlowCarrier = {
|
|
|
state?: { blindAddressFlow?: boolean };
|
|
|
};
|
|
|
|
|
|
/** 当前任务是否走 blind 地址流(含 embed 已确认候选) */
|
|
|
export function isBlindAddressFlowActive(
|
|
|
carrier?: BlindAddressFlowCarrier,
|
|
|
): boolean {
|
|
|
return (
|
|
|
isRpaBlindAddressFlow() || carrier?.state?.blindAddressFlow === true
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 跳过 Direct axel/quote,直接 Widget 抓价(Direct 已知 400 的探针用) */
|
|
|
export function isRpaSkipDirectQuote(): boolean {
|
|
|
return (
|
|
|
process.env.RPA_SKIP_DIRECT_QUOTE === "true" || isRpaFastQuoteMode()
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 联想等待(毫秒);RPA_SUGGESTION_TIMEOUT_MS 优先,fast-quote 默认 1s */
|
|
|
export function resolveAddressSuggestionTimeoutMs(defaultMs: number): number {
|
|
|
const raw = process.env.RPA_SUGGESTION_TIMEOUT_MS?.trim();
|
|
|
if (raw && Number.isFinite(Number(raw)) && Number(raw) > 0) {
|
|
|
return Number(raw);
|
|
|
}
|
|
|
if (isRpaFastQuoteMode()) {
|
|
|
return 1_000;
|
|
|
}
|
|
|
return defaultMs;
|
|
|
}
|
|
|
|
|
|
/** axel search 结果等待(毫秒) */
|
|
|
export function resolveAxelSearchWaitMs(
|
|
|
side: "pickup" | "delivery",
|
|
|
preknown: boolean,
|
|
|
): number {
|
|
|
if (isRpaFastQuoteMode()) {
|
|
|
return 400;
|
|
|
}
|
|
|
if (preknown) {
|
|
|
return 800;
|
|
|
}
|
|
|
if (side === "delivery") {
|
|
|
return 1_500;
|
|
|
}
|
|
|
return 8_000;
|
|
|
}
|
|
|
|
|
|
/** visual-rush 下报价网络等待;基准脚本可设 RPA_BENCHMARK_QUOTE_MS 拉长抓价 */
|
|
|
export function resolveRpaQuoteCaptureMs(defaultMs: number): number {
|
|
|
const bench = Number(process.env.RPA_BENCHMARK_QUOTE_MS);
|
|
|
if (Number.isFinite(bench) && bench > 0) {
|
|
|
return bench;
|
|
|
}
|
|
|
if (isRpaVisualRushMode() || isRpaFastQuoteMode()) {
|
|
|
return 3_000;
|
|
|
}
|
|
|
return defaultMs;
|
|
|
}
|
|
|
|
|
|
/** 基准/生产:必须抓到 axel 档位,禁止 sign-up-quote 空报价放行 */
|
|
|
export function isRpaRequireQuoteRates(): boolean {
|
|
|
return process.env.RPA_REQUIRE_QUOTE_RATES === "true";
|
|
|
}
|
|
|
|
|
|
/** 人工对照:地址步 rush,submit 前走 cargo+business-ready */
|
|
|
export function isRpaHumanSubmitStrict(): boolean {
|
|
|
return process.env.RPA_HUMAN_SUBMIT_STRICT === "true";
|
|
|
}
|