|
|
import fs from "node:fs";
|
|
|
import path from "node:path";
|
|
|
import type { Browser, BrowserContext, Page } from "playwright";
|
|
|
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
|
|
|
import { getPrimaryMothershipQuoteUrl, getRpaBrowserLocale } from "@/lib/rpa/env";
|
|
|
import { resolveQuoteSessionStoragePath } from "@/lib/rpa/quote-session-store";
|
|
|
import { RPA_PAGE_GOTO_TIMEOUT_MS } from "@/lib/constants/rpa";
|
|
|
import {
|
|
|
getStorageStateForContext,
|
|
|
invalidateStorageState,
|
|
|
persistContext,
|
|
|
} from "@/lib/rpa/storage-state";
|
|
|
import { assertRpaSelectorsEnv } from "@/lib/rpa/selectors";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { hasMothershipCredentials } from "@/modules/rpa/error-mapper";
|
|
|
import {
|
|
|
quotePageAdapter,
|
|
|
type QuotePageAdapter,
|
|
|
} from "@/workers/rpa/quote-page-adapter";
|
|
|
import { releaseAllParkedQuoteSessions } from "@/workers/rpa/parked-quote-session";
|
|
|
import { AUTOCOMPLETE_MONKEY_PATCH_INIT } from "@/workers/rpa/fix-place/recon-helpers";
|
|
|
import {
|
|
|
P0_PLACE_DIAG_INIT,
|
|
|
P2_FETCH_HIJACK_INIT,
|
|
|
} from "@/workers/rpa/fix-place/place-diagnostic-init";
|
|
|
import { isPlaceDiagEnabled } from "@/workers/rpa/fix-place/place-diagnostic-probe";
|
|
|
import { isDualAddressPatchEnabled } from "@/lib/rpa/env";
|
|
|
import { DUAL_ADDRESS_PATCH_INIT } from "@/workers/rpa/inject/dual-address-patch-init";
|
|
|
|
|
|
export {
|
|
|
getStorageStatePath,
|
|
|
invalidateStorageState,
|
|
|
persistContext,
|
|
|
storageStateExists,
|
|
|
} from "@/lib/rpa/storage-state";
|
|
|
|
|
|
const LOGIN_URL_PATTERNS = ["/login", "/sign-in", "/signin"];
|
|
|
|
|
|
let browser: Browser | null = null;
|
|
|
let rpaLockTail: Promise<void> = Promise.resolve();
|
|
|
|
|
|
/** 串行化 Playwright 会话,避免候选/询价双 job 争用 browser 与 storageState */
|
|
|
export async function withRpaSessionLock<T>(
|
|
|
fn: () => Promise<T>,
|
|
|
): Promise<T> {
|
|
|
const previous = rpaLockTail;
|
|
|
let release!: () => void;
|
|
|
rpaLockTail = new Promise<void>((resolve) => {
|
|
|
release = resolve;
|
|
|
});
|
|
|
await previous;
|
|
|
try {
|
|
|
return await fn();
|
|
|
} finally {
|
|
|
release();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function launchBrowser(): Promise<Browser> {
|
|
|
const headless =
|
|
|
process.env.RPA_HEADED === "true"
|
|
|
? false
|
|
|
: process.env.RPA_HEADLESS !== "false";
|
|
|
const slowMoRaw = process.env.RPA_SLOW_MO_MS?.trim();
|
|
|
const slowMo =
|
|
|
!headless && slowMoRaw && Number.isFinite(Number(slowMoRaw))
|
|
|
? Number(slowMoRaw)
|
|
|
: !headless
|
|
|
? 100
|
|
|
: undefined;
|
|
|
|
|
|
return launchRpaBrowser({ headless, ...(slowMo !== undefined ? { slowMo } : {}) });
|
|
|
}
|
|
|
|
|
|
function attachNetworkDebug(page: Page): void {
|
|
|
if (process.env.RPA_NETWORK_DEBUG !== "true") {
|
|
|
return;
|
|
|
}
|
|
|
page.on("request", (req) => {
|
|
|
console.log(`[rpa-net] >> ${req.method()} ${req.url()}`);
|
|
|
});
|
|
|
page.on("response", (res) => {
|
|
|
console.log(`[rpa-net] << ${res.status()} ${res.url()}`);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function ensureBrowser(): Promise<Browser> {
|
|
|
if (!browser) {
|
|
|
browser = await launchBrowser();
|
|
|
}
|
|
|
return browser;
|
|
|
}
|
|
|
|
|
|
function isLoginPage(page: Page): boolean {
|
|
|
const url = page.url().toLowerCase();
|
|
|
return LOGIN_URL_PATTERNS.some((pattern) => url.includes(pattern));
|
|
|
}
|
|
|
|
|
|
async function loginIfNeeded(page: Page): Promise<void> {
|
|
|
if (!isLoginPage(page)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!hasMothershipCredentials()) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"login 页且无 Mothership 凭据",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const email = process.env.MOTHERSHIP_EMAIL!.trim();
|
|
|
const password = process.env.MOTHERSHIP_PASSWORD!.trim();
|
|
|
|
|
|
const emailSelector =
|
|
|
process.env.RPA_SELECTOR_LOGIN_EMAIL ??
|
|
|
'input[type="email"], input[name="email"]';
|
|
|
const passwordSelector =
|
|
|
process.env.RPA_SELECTOR_LOGIN_PASSWORD ??
|
|
|
'input[type="password"], input[name="password"]';
|
|
|
const submitSelector =
|
|
|
process.env.RPA_SELECTOR_LOGIN_SUBMIT ??
|
|
|
'button[type="submit"], button:has-text("Sign in"), button:has-text("Log in")';
|
|
|
|
|
|
await page.fill(emailSelector, email);
|
|
|
await page.fill(passwordSelector, password);
|
|
|
await page.click(submitSelector);
|
|
|
await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {
|
|
|
/* 部分页面无 networkidle */
|
|
|
});
|
|
|
|
|
|
if (isLoginPage(page)) {
|
|
|
throw new RpaError("SESSION_EXPIRED", "Mothership 登录失败", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function createContext(
|
|
|
b: Browser,
|
|
|
opts?: { storageStatePath?: string; skipStorageState?: boolean },
|
|
|
): Promise<BrowserContext> {
|
|
|
const statePath: { storageState?: string } =
|
|
|
opts?.skipStorageState === true
|
|
|
? {}
|
|
|
: getStorageStateForContext(opts?.storageStatePath);
|
|
|
const locale = getRpaBrowserLocale();
|
|
|
const baseOpts = {
|
|
|
viewport: { width: 1280, height: 900 } as const,
|
|
|
locale,
|
|
|
extraHTTPHeaders: { "Accept-Language": `${locale},en;q=0.9` },
|
|
|
userAgent:
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
|
};
|
|
|
const context = await b.newContext({ ...baseOpts, ...statePath });
|
|
|
if (isPlaceDiagEnabled()) {
|
|
|
await context.addInitScript({ content: P0_PLACE_DIAG_INIT });
|
|
|
await context.addInitScript({ content: P2_FETCH_HIJACK_INIT });
|
|
|
} else {
|
|
|
await context.addInitScript(AUTOCOMPLETE_MONKEY_PATCH_INIT);
|
|
|
}
|
|
|
if (isDualAddressPatchEnabled()) {
|
|
|
await context.addInitScript({ content: DUAL_ADDRESS_PATCH_INIT });
|
|
|
}
|
|
|
await context.addInitScript(() => {
|
|
|
document.documentElement.setAttribute("translate", "no");
|
|
|
document.documentElement.lang = "en";
|
|
|
const injectNotranslate = () => {
|
|
|
const existing = document.querySelector(
|
|
|
'meta[name="google"][content="notranslate"]',
|
|
|
);
|
|
|
if (existing) {
|
|
|
return;
|
|
|
}
|
|
|
const meta = document.createElement("meta");
|
|
|
meta.name = "google";
|
|
|
meta.content = "notranslate";
|
|
|
document.head?.appendChild(meta);
|
|
|
};
|
|
|
if (document.head) {
|
|
|
injectNotranslate();
|
|
|
} else {
|
|
|
document.addEventListener("DOMContentLoaded", injectNotranslate, {
|
|
|
once: true,
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
return context;
|
|
|
}
|
|
|
|
|
|
export type OpenQuotePageResult = {
|
|
|
page: Page;
|
|
|
context: BrowserContext;
|
|
|
activeUrl: string;
|
|
|
reuseSession: boolean;
|
|
|
};
|
|
|
|
|
|
export type OpenQuotePageOptions = {
|
|
|
/** 询价专用:不加载任何 storageState,并清空页面 storage,避免 MotherShip 货物回填 */
|
|
|
freshCargoContext?: boolean;
|
|
|
};
|
|
|
|
|
|
export async function openQuotePage(
|
|
|
adapter: QuotePageAdapter = quotePageAdapter,
|
|
|
reloginAttempt = false,
|
|
|
quoteSessionId?: string,
|
|
|
options?: OpenQuotePageOptions,
|
|
|
): Promise<OpenQuotePageResult> {
|
|
|
if (reloginAttempt) {
|
|
|
invalidateStorageState();
|
|
|
if (browser) {
|
|
|
await browser.close().catch(() => undefined);
|
|
|
browser = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const freshCargoContext = options?.freshCargoContext === true;
|
|
|
const reuseSession =
|
|
|
!freshCargoContext && Boolean(quoteSessionId?.trim());
|
|
|
|
|
|
const b = await ensureBrowser();
|
|
|
let sessionStoragePath: string | null = null;
|
|
|
if (!freshCargoContext) {
|
|
|
sessionStoragePath = quoteSessionId
|
|
|
? await resolveQuoteSessionStoragePath(quoteSessionId)
|
|
|
: null;
|
|
|
if (quoteSessionId && !sessionStoragePath) {
|
|
|
console.warn(
|
|
|
`[rpa] quote_session 无效或已过期 id=${quoteSessionId.slice(0, 8)}…,回退默认 storageState`,
|
|
|
);
|
|
|
} else if (sessionStoragePath) {
|
|
|
console.log(
|
|
|
`[rpa] 复用 quote_session storageState id=${quoteSessionId!.slice(0, 8)}…`,
|
|
|
);
|
|
|
}
|
|
|
} else {
|
|
|
console.log("[rpa] 询价使用全新浏览器上下文(不复用 storageState)");
|
|
|
}
|
|
|
|
|
|
const context = await createContext(
|
|
|
b,
|
|
|
freshCargoContext
|
|
|
? { skipStorageState: true }
|
|
|
: { storageStatePath: sessionStoragePath ?? undefined },
|
|
|
);
|
|
|
const page = await context.newPage();
|
|
|
page.setDefaultTimeout(12_000);
|
|
|
page.setDefaultNavigationTimeout(RPA_PAGE_GOTO_TIMEOUT_MS);
|
|
|
attachNetworkDebug(page);
|
|
|
|
|
|
try {
|
|
|
const { activeUrl } = await adapter.resolveQuoteEntry(page, {
|
|
|
reuseSession,
|
|
|
});
|
|
|
if (freshCargoContext) {
|
|
|
await page
|
|
|
.evaluate(() => {
|
|
|
localStorage.clear();
|
|
|
sessionStorage.clear();
|
|
|
})
|
|
|
.catch(() => undefined);
|
|
|
console.log("[rpa] 已清空页面 localStorage/sessionStorage");
|
|
|
}
|
|
|
await loginIfNeeded(page);
|
|
|
return { page, context, activeUrl, reuseSession };
|
|
|
} catch (error) {
|
|
|
await page.close().catch(() => undefined);
|
|
|
await context.close().catch(() => undefined);
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** @deprecated 兼容旧调用 */
|
|
|
export async function getSession(relogin = false): Promise<BrowserContext> {
|
|
|
const { context } = await openQuotePage(quotePageAdapter, relogin);
|
|
|
return context;
|
|
|
}
|
|
|
|
|
|
export async function closeSession(): Promise<void> {
|
|
|
/* context 按 job 关闭 */
|
|
|
}
|
|
|
|
|
|
export async function closeBrowser(): Promise<void> {
|
|
|
await releaseAllParkedQuoteSessions();
|
|
|
if (browser) {
|
|
|
await browser.close().catch(() => undefined);
|
|
|
browser = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** @deprecated 兼容 probe */
|
|
|
export async function newCheckedPage(reloginAttempt = false): Promise<Page> {
|
|
|
const { page } = await openQuotePage(quotePageAdapter, reloginAttempt);
|
|
|
return page;
|
|
|
}
|
|
|
|
|
|
export function assertSessionEnv(): void {
|
|
|
assertRpaSelectorsEnv();
|
|
|
if (process.env.RPA_MOCK_MODE === "true") {
|
|
|
return;
|
|
|
}
|
|
|
void getPrimaryMothershipQuoteUrl();
|
|
|
}
|