/** * 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 { scrapeBothTiersPricingOptions } from "@/workers/rpa/flock/checkout"; import type { FlockQuoteInput, FlockRunQuoteResult } from "@/workers/rpa/flock/types"; import { reportFlockStage, type FlockStageReporter, } from "@/lib/flock/rpa-progress"; import { FLOCK_HOLD_TOTAL_MS } from "@/lib/constants/flock-quote-hold"; import { canPersistParkedQuoteSession, parkQuoteSession, } from "@/workers/rpa/parked-quote-session"; 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)) { const opened = await openSession(input, onStage); // 登录态 open 后常落在 /home,必须点 New quote;匿名走 marketing URL if (loginMode || input.formMode === "logged_in_quick") { const { openFlockLoggedInQuoteEntry } = await import( "@/workers/rpa/flock/logged-in-form" ); const ok = await openFlockLoggedInQuoteEntry(opened.page); if (!ok) { console.warn( "[flock-session] openFlockLoggedInQuoteEntry 未进入 Quick 表单,仍继续 wait", ); } } else { await navigateFlockNewQuote(opened.page, url); } return opened; } if (loginMode || input.formMode === "logged_in_quick") { const { openFlockLoggedInQuoteEntry } = await import( "@/workers/rpa/flock/logged-in-form" ); await openFlockLoggedInQuoteEntry(session.page); } else { 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; } export type RunFlockQuoteWithWorkerSessionOptions = { /** 登录态首价保活:刮两侧三档后 park,session 移交驻留池 */ holdSessionId?: string; }; /** Worker 内复用浏览器执行 Flock 查价;上限/拒号快失败 */ export async function runFlockQuoteWithWorkerSession( input: FlockQuoteInput, onStage?: FlockStageReporter, options?: RunFlockQuoteWithWorkerSessionOptions, ): 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; const holdId = options?.holdSessionId?.trim(); if ( holdId && hasEffectiveFlockLogin() && canPersistParkedQuoteSession() ) { // 官网两种形态:Direct→灵活价;Standard→承运商。同一 Chrome 依次点两档 See Pricing。 const { flexibilityByTier, carriersByTier } = await scrapeBothTiersPricingOptions(active.page); if ( Object.keys(flexibilityByTier).length === 0 && Object.keys(carriersByTier).length === 0 ) { console.warn("[flock-session] 两档均无档内价(仍尝试 park)"); } try { await parkQuoteSession(holdId, active.page, active.context, { ttlMs: FLOCK_HOLD_TOTAL_MS, }); // 移交驻留池:勿 closeSession(会关浏览器) session = null; console.log( `[flock-session] hold parked session=${holdId.slice(0, 12)}… flex=${Object.keys(flexibilityByTier).join(",")} carriers=${Object.keys(carriersByTier).join(",")}`, ); return { ...result, quoteSessionId: holdId, flexibilityByTier, carriersByTier, }; } catch (err) { console.warn( "[flock-session] hold park 失败,回落普通首价(仍带回已刮档内价):", err instanceof Error ? err.message : err, ); return { ...result, flexibilityByTier, carriersByTier, }; } } 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(); }