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.

313 lines
9.5 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 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 { PROVIDER_LOGIN_FAILED_USER_MESSAGE } from "@/modules/rpa/provider-login-message";
import { getEffectiveMothershipLogin } from "@/lib/rpa/mothership-login-context";
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;
}
const creds = getEffectiveMothershipLogin();
if (!creds) {
throw new RpaError(
"STRUCT_CHANGE",
"login 页且无 Mothership 凭据(请配置客户承运商账密或 MOTHERSHIP_EMAIL/PASSWORD",
{ retryable: false },
);
}
const email = creds.email;
const password = creds.password;
const byTestIdEmail = page.getByTestId("auth-email-input");
if ((await byTestIdEmail.count()) > 0) {
await byTestIdEmail.fill(email);
await page.getByTestId("auth-password-input").fill(password);
await page.getByTestId("auth-log-in-button").click();
} else {
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("PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, {
retryable: false,
});
}
}
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();
}