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.

243 lines
7.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 {
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);
}
}
}