import { ADDRESS_CANDIDATES_TIMEOUT_MS, } from "@/lib/constants/rpa"; import type { AddressLookupInput, MothershipAddressCandidate, MothershipCandidatesResult, } from "@/modules/address/types"; import { RpaError } from "@/modules/rpa/errors"; import { saveQuoteSessionFromContext } from "@/lib/rpa/quote-session-store"; import { enumerateAddressCandidates, isMotherShipEchoCandidate, labelToCandidate, } from "@/workers/rpa/address-suggestions"; import { openAddressSide, visibleStreetInput } from "@/workers/rpa/address-side"; import { RPAContext } from "@/workers/rpa/kernel/context"; import { captureRpaDebugScreenshot, dismissCookieConsent, scrollQuoteWidgetIntoView, } from "@/workers/rpa/page-prep"; import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter"; import { parkQuoteSession, canPersistParkedQuoteSession } from "@/workers/rpa/parked-quote-session"; import { isParkedSessionEnabled, shouldKeepBrowserWarm } from "@/lib/rpa/env"; import { closeBrowser, openQuotePage, persistContext, withRpaSessionLock, } from "@/workers/rpa/session-manager"; async function dismissAddressSuggestionDropdown( ctx: RPAContext, ): Promise { await ctx.page.keyboard.press("Escape").catch(() => undefined); await ctx.page.waitForTimeout(150); } async function listSideCandidates( ctx: RPAContext, side: "pickup" | "delivery", address: AddressLookupInput, ): Promise { await openAddressSide(ctx, side, { enumerationMode: true }); const input = await visibleStreetInput(ctx, side, { deliverySectionOpened: side === "delivery", enumerationMode: true, }); if (!input) { throw new RpaError( "STRUCT_CHANGE", `${side === "pickup" ? "提货" : "派送"}地址输入框不可见`, { retryable: false }, ); } if (!(await input.isEditable().catch(() => false))) { throw new RpaError( "STRUCT_CHANGE", `${side === "pickup" ? "提货" : "派送"}地址输入框不可编辑`, { retryable: false }, ); } if (side === "delivery") { const current = ((await input.inputValue().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); if (current.length >= 8) { await input.fill("").catch(() => undefined); await input.press("Control+A").catch(() => undefined); await input.press("Backspace").catch(() => undefined); await ctx.page.waitForTimeout(150); } } const labels = await enumerateAddressCandidates(ctx.page, input, address); console.log(`[DEBUG] ${side} labels (${labels.length}):`, labels.slice(0, 5)); const seen = new Set(); const candidates: MothershipAddressCandidate[] = []; const absorbLabel = (label: string, skipEchoFilter: boolean) => { const item = labelToCandidate(label, address); if (!item) { return; } if (!skipEchoFilter && isMotherShipEchoCandidate(item, address)) { return; } const key = item.option_id; if (seen.has(key)) { return; } seen.add(key); candidates.push(item); }; for (const label of labels) { absorbLabel(label, false); } if (candidates.length === 0) { for (const label of labels) { absorbLabel(label, true); } } if (candidates.length === 0) { throw new RpaError( "ADDRESS_SUGGESTION_NOT_FOUND", `MotherShip 未返回可区分的地址候选项,请检查地址或稍后重试`, { retryable: true }, ); } const streetNum = address.street.match(/^\d+/)?.[0]; if (streetNum) { candidates.sort((a, b) => { const aHit = `${a.street} ${a.display_label}`.includes(streetNum) ? 1 : 0; const bHit = `${b.street} ${b.display_label}`.includes(streetNum) ? 1 : 0; return bHit - aHit; }); } // 允许部分解析失败:只要有 1 个以上候选即可继续 // MotherShip 可能返回不完整的联想(如缺少门牌号),这些会被过滤掉 if (candidates.length === 1 && labels.length >= 3) { console.warn( `[candidates] ${side}: ${labels.length} labels 中仅解析出 1 个候选,已跳过无效项`, labels, ); } await dismissAddressSuggestionDropdown(ctx); return candidates; } async function fetchMothershipAddressCandidatesInner(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise { return withRpaSessionLock(async () => { let ctx: RPAContext | null = null; let page: Awaited>["page"] | null = null; let context: Awaited>["context"] | null = null; let parked = false; try { const opened = await openQuotePage(quotePageAdapter, false); page = opened.page; context = opened.context; ctx = RPAContext.fromSession(opened.page, opened.context); await quotePageAdapter.preCheck(opened.page); const pickup_candidates = await listSideCandidates( ctx, "pickup", input.pickup, ); const delivery_candidates = await listSideCandidates( ctx, "delivery", input.delivery, ); const quote_session_id = await saveQuoteSessionFromContext( opened.context, { pickup: input.pickup, delivery: input.delivery, }, ); await persistContext(opened.context); // 用户确认弹窗期间保持同页驻留(仅 worker 进程有效;spawn 子进程无法跨进程复用) await dismissCookieConsent(page); await scrollQuoteWidgetIntoView(page, { force: true }); if (isParkedSessionEnabled() && canPersistParkedQuoteSession()) { await parkQuoteSession(quote_session_id, page, context); parked = true; page = null; context = null; } else { console.warn( "[candidates] 非 worker 进程,跳过驻留页(询价将重开浏览器)", ); } return { pickup_candidates, delivery_candidates, requires_selection: true, quote_session_id, }; } catch (error) { if (ctx?.page) { await captureRpaDebugScreenshot(ctx.page, "address-candidates-failure"); } throw error; } finally { if (!parked) { await page?.close().catch(() => undefined); await context?.close().catch(() => undefined); } if (!shouldKeepBrowserWarm()) { await closeBrowser(); } } }); } /** 真实 RPA:枚举 MotherShip 地址联想项(与询价共用 worker / session-manager) */ export async function fetchMothershipAddressCandidates(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ fetchMothershipAddressCandidatesInner(input), new Promise((_, reject) => { timer = setTimeout(() => { reject( new RpaError( "PAGE_LOAD_TIMEOUT", "MotherShip 地址联想超时,请检查网络或稍后重试", { retryable: true }, ), ); }, ADDRESS_CANDIDATES_TIMEOUT_MS); }), ]); } finally { if (timer) { clearTimeout(timer); } } }