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 = Promise.resolve(); /** 串行化 Playwright 会话,避免候选/询价双 job 争用 browser 与 storageState */ export async function withRpaSessionLock( fn: () => Promise, ): Promise { const previous = rpaLockTail; let release!: () => void; rpaLockTail = new Promise((resolve) => { release = resolve; }); await previous; try { return await fn(); } finally { release(); } } async function launchBrowser(): Promise { 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 { 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 { 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 { 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 { 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 { const { context } = await openQuotePage(quotePageAdapter, relogin); return context; } export async function closeSession(): Promise { /* context 按 job 关闭 */ } export async function closeBrowser(): Promise { await releaseAllParkedQuoteSessions(); if (browser) { await browser.close().catch(() => undefined); browser = null; } } /** @deprecated 兼容 probe */ export async function newCheckedPage(reloginAttempt = false): Promise { const { page } = await openQuotePage(quotePageAdapter, reloginAttempt); return page; } export function assertSessionEnv(): void { assertRpaSelectorsEnv(); if (process.env.RPA_MOCK_MODE === "true") { return; } void getPrimaryMothershipQuoteUrl(); }