/** * Flock Freight 可视全链回放 — 对齐 Priority1 visual-chain + 录制脚本 * 录制:.rpa/recordings/flock-manual-20260713-152051.js */ import fs from "node:fs"; import path from "node:path"; import type { BrowserContext, Page } from "playwright"; import { getFlockMinQuotes, getFlockQuoteUrl, getFlockQuoteWaitMs, getFlockStorageStatePath, isFlockFreshSessionPerQuote, shouldLoadFlockStorageState, shouldSaveFlockStorageState, type FlockTestAccount, } from "@/lib/flock/env"; import { 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 { formatExternalSiteNavigationError, gotoWithResilience, isTransientNavigationError, } from "@/lib/rpa/page-goto"; import { isRpaHeaded, resolveRpaHeadless, resolveRpaSlowMoMs, } from "@/lib/rpa/env"; import { clickFlockQuoteSubmit, fillFlockQuoteForm, waitForFlockQuoteForm, } from "@/workers/rpa/flock/form-interactions"; import { ensureFlockLoggedIn, flockLoginRegistrationBlockedMessage, } from "@/workers/rpa/flock/login"; import { flockQuoteLimitMessage, flockRegistrationErrorMessage, } from "@/workers/rpa/flock/page-state"; import { completeFlockRegistrationIfNeeded, isFlockQuoteResultsPage, isFlockRegistrationPage, } from "@/workers/rpa/flock/register-account"; import { detectFlockRatesPending, extractFlockQuoteCards, waitForFlockQuoteResults, } from "@/workers/rpa/flock/quote-extract"; import { captureFlockStall, withFlockStallWatch } from "@/workers/rpa/flock/stall-watch"; import type { FlockQuoteInput, FlockRunQuoteResult, } from "@/workers/rpa/flock/types"; import { isFlockPageClosedError, flockPageClosedMessage, } from "@/workers/rpa/flock/page-alive"; import { reportFlockStage, type FlockStageReporter, } from "@/lib/flock/rpa-progress"; function envHeadless(): boolean { return resolveRpaHeadless(); } async function dwellIfHeaded(page: Page | null): Promise { if (!isRpaHeaded() || !page) return; await page.waitForTimeout(5_000).catch(() => undefined); } function resolveStoragePath(): string { const raw = getFlockStorageStatePath(); return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw); } async function persistStorageState( context: BrowserContext, statePath: string, ): Promise { try { fs.mkdirSync(path.dirname(statePath), { recursive: true }); await context.storageState({ path: statePath }); } catch { /* ignore */ } } async function afterSubmitWaitForQuotes( page: Page, account: FlockTestAccount, budgetMs: number, ): Promise<{ registered: boolean; errorMessage?: string }> { const deadline = Date.now() + budgetMs; const limitMsg = await flockQuoteLimitMessage(page); if (limitMsg) { return { registered: false }; } let registered = false; if (await isFlockRegistrationPage(page)) { // 登录保障路径:禁止盲填随机注册 if (hasEffectiveFlockLogin()) { return { registered: false, errorMessage: flockLoginRegistrationBlockedMessage(), }; } registered = await completeFlockRegistrationIfNeeded(page, account); if (!registered) { return { registered: false }; } } const remain = Math.max(500, deadline - Date.now()); await waitForFlockQuoteResults(page, remain); if (!(await isFlockQuoteResultsPage(page))) { const leftover = Math.max(0, deadline - Date.now()); if (leftover > 0) { await page .getByText(/Explore your custom quote|FlockDirect|Prices from/i) .first() .waitFor({ timeout: leftover }) .catch(() => undefined); } } return { registered }; } async function runOnPage( page: Page, input: FlockQuoteInput, account: FlockTestAccount, onStage?: FlockStageReporter, ): Promise { try { return await runOnPageInner(page, input, account, onStage); } catch (error) { if (isFlockPageClosedError(error)) { return { ok: false, quotes: [], errorMessage: flockPageClosedMessage("查价流程"), }; } throw error; } } async function runOnPageInner( page: Page, input: FlockQuoteInput, account: FlockTestAccount, onStage?: FlockStageReporter, ): Promise { const quoteWaitMs = getFlockQuoteWaitMs(); const minQuotes = getFlockMinQuotes(); const limitBefore = await flockQuoteLimitMessage(page); if (limitBefore) { return { ok: false, quotes: [], errorMessage: limitBefore }; } await reportFlockStage(onStage, "fill_form"); const filled = await withFlockStallWatch(page, "填写查价表单", () => fillFlockQuoteForm(page, input), ); if (!filled.ok) { return { ok: false, quotes: [], errorMessage: filled.error }; } await reportFlockStage(onStage, "submit"); if (!(await clickFlockQuoteSubmit(page))) { return { ok: false, quotes: [], errorMessage: "STRUCT_CHANGE:未找到提交按钮", }; } await reportFlockStage(onStage, "wait_quote"); const { registered, errorMessage: afterErr } = await withFlockStallWatch( page, "提交后等待报价", () => afterSubmitWaitForQuotes(page, account, quoteWaitMs), ); if (afterErr) { return { ok: false, quotes: [], registered, errorMessage: afterErr }; } if (await isFlockRegistrationPage(page)) { if (hasEffectiveFlockLogin()) { void captureFlockStall(page, "结果-登录态仍注册页", 3_000).catch( () => undefined, ); return { ok: false, quotes: [], registered, errorMessage: flockLoginRegistrationBlockedMessage(), }; } const regErr = await flockRegistrationErrorMessage(page); void captureFlockStall(page, "结果-注册页卡住", 3_000).catch( () => undefined, ); return { ok: false, quotes: [], registered, errorMessage: regErr ?? "STRUCT_CHANGE:进入注册页但无法自动填写(请配置 FLOCK_TEST_* 或重新 npm run record:flock)", accountExists: !!regErr, }; } const pending = await detectFlockRatesPending(page); if (pending.pending) { await captureFlockStall(page, "结果-官网仍在寻价", 3_000).catch( () => undefined, ); return { ok: false, quotes: [], reference: pending.reference, registered, errorMessage: pending.errorMessage ?? undefined, }; } const { quotes, reference } = await extractFlockQuoteCards(page); if (quotes.length < minQuotes) { const pendingLate = await detectFlockRatesPending(page); if (pendingLate.pending) { await captureFlockStall(page, "结果-官网仍在寻价", 3_000).catch( () => undefined, ); return { ok: false, quotes: [], reference: pendingLate.reference ?? reference, registered, errorMessage: pendingLate.errorMessage ?? undefined, }; } await captureFlockStall(page, "结果-无报价或超时", quoteWaitMs).catch( () => undefined, ); return { ok: false, quotes, reference, registered, errorMessage: `RPA_DATA_INVALID:${quoteWaitMs / 1000}s 内未拿到报价(截图见 .rpa/flock-stalls/)`, }; } await reportFlockStage(onStage, "done"); return { ok: true, quotes, reference, registered }; } export type FlockVisualChainOptions = { input: FlockQuoteInput; onStage?: FlockStageReporter; /** 内部:禁止 storage 回退重试 */ _skipStorageRetry?: boolean; }; /** 在已有 page 上执行查价(批量单浏览器复用) */ export async function runFlockQuoteOnPage( page: Page, input: FlockQuoteInput, account: FlockTestAccount, onStage?: FlockStageReporter, ): Promise { const result = await runOnPage(page, input, account, onStage); const { registered: _r, accountExists: _a, ...out } = result; return out; } async function runFlockVisualChainOnce( options: FlockVisualChainOptions, loadStorage: boolean, ): Promise { const url = getFlockQuoteUrl(); const statePath = resolveStoragePath(); const headless = envHeadless(); const slowMo = resolveRpaSlowMoMs(); const fresh = isFlockFreshSessionPerQuote(); const loginMode = hasEffectiveFlockLogin(); const account = resolveFlockQuoteAccount(options.input); console.log( `[flock-rpa] visual-chain headed=${!headless} slowMo=${slowMo ?? 0} fresh=${fresh} loginMode=${loginMode}`, ); if (fresh) { console.log( `[flock-rpa] 无痕查价 email=${account.email} phone=${account.phone}`, ); } if (loginMode) { console.log("[flock-rpa] DOM 保障路径:账号密码登录模式"); } const browser = await launchRpaBrowser({ headless, incognito: fresh, ...(slowMo !== undefined ? { slowMo } : {}), }); let page: Page | null = null; try { const context = await createRpaBrowserContext( browser, loadStorage && fs.existsSync(statePath) ? { storageState: statePath } : {}, ); page = await context.newPage(); try { await gotoWithResilience(page, url); } catch (error) { const raw = error instanceof Error ? error.message : String(error); return { ok: false, quotes: [], errorMessage: isTransientNavigationError({ message: raw }) ? formatExternalSiteNavigationError("Flock Freight 官网", { message: raw, }) : raw, }; } const forceFresh = mustForceFlockAccountLogin(); await reportFlockStage(options.onStage, "login"); let login = await ensureFlockLoggedIn(page, context, { forceFreshLogin: forceFresh, }); if (!login.ok && loginMode) { login = await ensureFlockLoggedIn(page, context, { reloginAttempt: true, forceFreshLogin: true, }); } if (!login.ok) { return { ok: false, quotes: [], errorMessage: login.errorMessage }; } await reportFlockStage(options.onStage, "open_quote"); if (!(await waitForFlockQuoteForm(page))) { const limitMsg = await flockQuoteLimitMessage(page); await captureFlockStall(page, "入口-表单未出现", 20_000).catch(() => undefined); return { ok: false, quotes: [], errorMessage: limitMsg ?? "QUOTE_ENTRY_UNAVAILABLE:查价表单未加载(截图见 .rpa/flock-stalls/)", }; } const result = await runOnPage(page, options.input, account, options.onStage); if ( (result.registered || login.didLogin) && shouldSaveFlockStorageState() ) { await persistStorageState(context, statePath); } const accountExists = result.accountExists === true || !!result.errorMessage?.includes("FLOCK_ACCOUNT_EXISTS"); return { ...result, accountExists }; } finally { await dwellIfHeaded(page); await browser.close(); } } /** * 执行 Flock 官网查价全链(worker / probe 共用) * - 默认加载 .rpa/flock-storage.json(录制产出,可跳过注册) * - 仅注册成功时回写 storage,报价后绝不覆盖 storage * - 无 storage 且邮箱已注册时,自动用 storage 重试一次 */ export async function runFlockVisualChain( options: FlockVisualChainOptions, ): Promise { const statePath = resolveStoragePath(); const storageExists = fs.existsSync(statePath); const loadStorage = shouldLoadFlockStorageState(storageExists); let result = await runFlockVisualChainOnce(options, loadStorage); if ( !options._skipStorageRetry && !isFlockFreshSessionPerQuote() && !loadStorage && storageExists && result.accountExists ) { console.warn( "[flock-rpa] 邮箱已注册,回退加载 flock-storage.json 重试查价…", ); result = await runFlockVisualChainOnce( { ...options, _skipStorageRetry: true }, true, ); } const { registered: _r, accountExists: _a, ...out } = result; return out; }