/** * Flock Worker 浏览器会话复用 — 对齐 batch-flock-stress 单浏览器模式 * 登录凭据模式:复用已登录 page,不换号注册 * 无登录:同一随机 QQ 邮箱连续查价,满 FLOCK_QUOTES_PER_EMAIL 后换新邮箱重注册 */ import fs from "node:fs"; import path from "node:path"; import type { Browser, BrowserContext, Page } from "playwright"; import { getFlockQuoteUrl, getFlockQuotesPerEmail, getFlockSessionMaxRotations, getFlockStorageStatePath, getFlockTestAccount, shouldLoadFlockStorageState, type FlockTestAccount, } from "@/lib/flock/env"; import { getEffectiveFlockLogin, hasEffectiveFlockLogin, mustForceFlockAccountLogin, } from "@/lib/flock/login-context"; import { resolveFlockQuoteAccount } from "@/lib/flock/resolve-account"; import { launchRpaBrowser } from "@/lib/rpa/browser-launch"; import { createRpaBrowserContext } from "@/lib/rpa/browser-context"; import { gotoWithResilience } from "@/lib/rpa/page-goto"; import { isRpaHeaded, resolveRpaHeadless, resolveRpaSlowMoMs, } from "@/lib/rpa/env"; import { navigateFlockNewQuote, waitForFlockQuoteForm, } from "@/workers/rpa/flock/form-interactions"; import { ensureFlockLoggedIn } from "@/workers/rpa/flock/login"; import { flockQuoteLimitMessage } from "@/workers/rpa/flock/page-state"; import { runFlockQuoteOnPage } from "@/workers/rpa/flock/visual-chain"; import type { FlockQuoteInput, FlockRunQuoteResult } from "@/workers/rpa/flock/types"; import { reportFlockStage, type FlockStageReporter, } from "@/lib/flock/rpa-progress"; type SessionState = { browser: Browser; context: BrowserContext; page: Page; account: FlockTestAccount; quoteCount: number; /** 客户/环境登录邮箱;换号必须重开会话 */ loginEmail: string | null; }; let session: SessionState | null = null; /** 需换新邮箱+浏览器的失败(不含报价上限:上限多为 IP 级,换号无效) */ export function shouldRotateFlockSession(errorMessage?: string): boolean { if (!errorMessage) { return false; } if (hasEffectiveFlockLogin()) { return false; } if ( /flock_quote_limit|thank you for your interest|报价.*上限|quote\s*limit/i.test( errorMessage, ) ) { return false; } return ( /flock_account_rejected|issue creating your account/i.test(errorMessage) || /flock_account_exists|already (have|registered)/i.test(errorMessage) ); } async function closeSession(): Promise { if (!session) { return; } await session.browser.close().catch(() => undefined); session = null; } function resolveStoragePath(): string { const raw = getFlockStorageStatePath(); return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw); } async function openSession( input: FlockQuoteInput, onStage?: FlockStageReporter, ): Promise { await closeSession(); const url = getFlockQuoteUrl(); const headless = resolveRpaHeadless(); const slowMo = resolveRpaSlowMoMs(); const loginMode = hasEffectiveFlockLogin(); const loginEmail = getEffectiveFlockLogin()?.email ?? null; const account = loginMode ? getFlockTestAccount() : resolveFlockQuoteAccount(input); const statePath = resolveStoragePath(); const loadStorage = loginMode && !mustForceFlockAccountLogin() && shouldLoadFlockStorageState(fs.existsSync(statePath)); console.log( `[flock-session] open headed=${!headless} slowMo=${slowMo ?? 0} loginMode=${loginMode}`, ); const browser = await launchRpaBrowser({ headless, incognito: !loginMode || mustForceFlockAccountLogin(), ...(slowMo !== undefined ? { slowMo } : {}), }); const context = await createRpaBrowserContext( browser, loadStorage ? { storageState: statePath } : {}, ); const page = await context.newPage(); await gotoWithResilience(page, url); if (loginMode) { await reportFlockStage(onStage, "login"); let login = await ensureFlockLoggedIn(page, context, { forceFreshLogin: mustForceFlockAccountLogin(), }); if (!login.ok) { login = await ensureFlockLoggedIn(page, context, { reloginAttempt: true, forceFreshLogin: true, }); } if (!login.ok) { if (isRpaHeaded()) { await page.waitForTimeout(5_000).catch(() => undefined); } await browser.close().catch(() => undefined); throw new Error(login.errorMessage); } } session = { browser, context, page, account, quoteCount: 0, loginEmail, }; console.log( loginMode ? `[flock-session] 登录会话已就绪 email=${loginEmail ?? "?"} quotesPerEmail=${getFlockQuotesPerEmail()}` : `[flock-session] 新会话 email=${account.email} quotesPerEmail=${getFlockQuotesPerEmail()}`, ); return session; } async function ensureActiveSession( input: FlockQuoteInput, url: string, quotesPerEmail: number, onStage?: FlockStageReporter, ): Promise { const loginMode = hasEffectiveFlockLogin(); const loginEmail = getEffectiveFlockLogin()?.email ?? null; const customEmail = input.registration?.email?.trim(); // 客户切换账密:必须重开会话,禁止串号 if (session && loginMode && session.loginEmail !== loginEmail) { console.log( `[flock-session] 登录账号变更 ${session.loginEmail ?? "none"} → ${loginEmail ?? "none"},重开会话`, ); await closeSession(); } if ( !loginMode && session && customEmail && session.account.email !== customEmail ) { await closeSession(); } if (!session || (!loginMode && session.quoteCount >= quotesPerEmail)) { return openSession(input, onStage); } await navigateFlockNewQuote(session.page, url); if (loginMode) { await reportFlockStage(onStage, "login"); const login = await ensureFlockLoggedIn(session.page, session.context); if (!login.ok) { await closeSession(); return openSession(input, onStage); } } return session; } /** Worker 内复用浏览器执行 Flock 查价;上限/拒号快失败 */ export async function runFlockQuoteWithWorkerSession( input: FlockQuoteInput, onStage?: FlockStageReporter, ): Promise { const quotesPerEmail = getFlockQuotesPerEmail(); const maxRotations = hasEffectiveFlockLogin() ? 1 : getFlockSessionMaxRotations(); const url = getFlockQuoteUrl(); let lastResult: FlockRunQuoteResult = { ok: false, quotes: [], errorMessage: "FLOCK_QUOTE_LIMIT:未执行查价", }; for (let rotation = 0; rotation < maxRotations; rotation += 1) { let active: SessionState; try { active = await ensureActiveSession(input, url, quotesPerEmail, onStage); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return { ok: false, quotes: [], errorMessage: msg }; } const limitAtEntry = await flockQuoteLimitMessage(active.page); if (limitAtEntry) { console.warn(`[flock-session] 入口已达上限,快失败: ${limitAtEntry}`); await closeSession(); return { ok: false, quotes: [], errorMessage: limitAtEntry }; } await reportFlockStage(onStage, "open_quote"); if (!(await waitForFlockQuoteForm(active.page))) { const limitMsg = await flockQuoteLimitMessage(active.page); if (limitMsg) { console.warn(`[flock-session] 表单不可用(上限),快失败`); await closeSession(); return { ok: false, quotes: [], errorMessage: limitMsg }; } await closeSession(); return { ok: false, quotes: [], errorMessage: "QUOTE_ENTRY_UNAVAILABLE:查价表单未加载", }; } const result = await runFlockQuoteOnPage( active.page, input, active.account, onStage, ); lastResult = result; if (result.ok) { active.quoteCount += 1; return result; } if ( /flock_quote_limit|thank you for your interest/i.test( result.errorMessage ?? "", ) ) { await closeSession(); return result; } if (shouldRotateFlockSession(result.errorMessage)) { console.warn( `[flock-session] ${result.errorMessage?.slice(0, 80)}… 换新邮箱 (${rotation + 1}/${maxRotations})`, ); await closeSession(); continue; } await closeSession(); return result; } await closeSession(); return { ok: false, quotes: [], errorMessage: lastResult.errorMessage ?? `FLOCK_QUOTE_LIMIT:已换 ${maxRotations} 个邮箱仍无法查价`, }; } export async function closeFlockWorkerSession(): Promise { await closeSession(); }