|
|
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<void> {
|
|
|
await ctx.page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(150);
|
|
|
}
|
|
|
|
|
|
async function listSideCandidates(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
address: AddressLookupInput,
|
|
|
): Promise<MothershipAddressCandidate[]> {
|
|
|
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<string>();
|
|
|
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<MothershipCandidatesResult> {
|
|
|
return withRpaSessionLock(async () => {
|
|
|
let ctx: RPAContext | null = null;
|
|
|
let page: Awaited<ReturnType<typeof openQuotePage>>["page"] | null = null;
|
|
|
let context: Awaited<ReturnType<typeof openQuotePage>>["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<MothershipCandidatesResult> {
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
try {
|
|
|
return await Promise.race([
|
|
|
fetchMothershipAddressCandidatesInner(input),
|
|
|
new Promise<MothershipCandidatesResult>((_, reject) => {
|
|
|
timer = setTimeout(() => {
|
|
|
reject(
|
|
|
new RpaError(
|
|
|
"PAGE_LOAD_TIMEOUT",
|
|
|
"MotherShip 地址联想超时,请检查网络或稍后重试",
|
|
|
{ retryable: true },
|
|
|
),
|
|
|
);
|
|
|
}, ADDRESS_CANDIDATES_TIMEOUT_MS);
|
|
|
}),
|
|
|
]);
|
|
|
} finally {
|
|
|
if (timer) {
|
|
|
clearTimeout(timer);
|
|
|
}
|
|
|
}
|
|
|
}
|