/** * Flock 批量随机探针 — 20 组 ZIP + 货物(差距大),统计失败原因 * * 用法: * RPA_BROWSER_CHANNEL=chrome npm run batch:flock-stress * FLOCK_BATCH_ROUNDS=20 FLOCK_BATCH_DELAY_MS=20000 npm run batch:flock-stress */ import fs from "node:fs"; import path from "node:path"; import type { Page } from "playwright"; import { FLOCK_LIMITS, fitsFlockTrailerCargo, } from "@/lib/constants/flock-limits"; import { createFreshFlockTestAccount } from "@/lib/flock/fresh-account"; import { withFlockExclusiveLock } from "@/lib/flock/exclusive-lock"; import { getFlockMinQuotes, getFlockQuoteUrl, getFlockQuoteWaitMs, getFlockQuotesPerEmail, type FlockTestAccount, } from "@/lib/flock/env"; import { launchRpaBrowser } from "@/lib/rpa/browser-launch"; import { createRpaBrowserContext } from "@/lib/rpa/browser-context"; import { gotoWithResilience } from "@/lib/rpa/page-goto"; import { navigateFlockNewQuote, waitForFlockQuoteForm, } from "@/workers/rpa/flock/form-interactions"; import { runFlockQuoteOnPage, runFlockVisualChain, } from "@/workers/rpa/flock/visual-chain"; import type { FlockQuoteInput } from "@/workers/rpa/flock/types"; const US_ZIPS = [ { zip: "02109", city: "Boston", state: "MA" }, { zip: "94102", city: "San Francisco", state: "CA" }, { zip: "60611", city: "Chicago", state: "IL" }, { zip: "78701", city: "Austin", state: "TX" }, { zip: "21230", city: "Baltimore", state: "MD" }, { zip: "19106", city: "Philadelphia", state: "PA" }, { zip: "15222", city: "Pittsburgh", state: "PA" }, { zip: "30326", city: "Atlanta", state: "GA" }, { zip: "90212", city: "Beverly Hills", state: "CA" }, { zip: "90248", city: "Gardena", state: "CA" }, ] as const; /** * 合法样例:字段硬限内 AND 线性英尺 ≤53' * (禁止 18×403 / 20×636 等会触发官网 Linear feet 拦截的组合) */ const CARGO_PRESETS = [ { palletCount: 4, totalWeightLb: 2_800, lengthIn: 48, widthIn: 40, heightIn: 48 }, { palletCount: 6, totalWeightLb: 5_500, lengthIn: 48, widthIn: 40, heightIn: 72 }, { palletCount: 8, totalWeightLb: 9_200, lengthIn: 48, widthIn: 40, heightIn: 60 }, { palletCount: 10, totalWeightLb: 14_000, lengthIn: 48, widthIn: 40, heightIn: 72 }, { palletCount: 12, totalWeightLb: 18_500, lengthIn: 48, widthIn: 48, heightIn: 96 }, { palletCount: 14, totalWeightLb: 22_000, lengthIn: 48, widthIn: 40, heightIn: 84 }, { palletCount: 16, totalWeightLb: 28_000, lengthIn: 48, widthIn: 40, heightIn: 96 }, { palletCount: 18, totalWeightLb: 32_000, lengthIn: 48, widthIn: 40, heightIn: 108 }, { palletCount: 20, totalWeightLb: 38_000, lengthIn: 48, widthIn: 40, heightIn: 96 }, { palletCount: 5, totalWeightLb: 45_000, lengthIn: 48, widthIn: 40, heightIn: 108 }, { palletCount: 4, totalWeightLb: 8_000, lengthIn: 96, widthIn: 48, heightIn: 72 }, { palletCount: 6, totalWeightLb: 12_000, lengthIn: 96, widthIn: 48, heightIn: 84 }, { palletCount: 8, totalWeightLb: 16_000, lengthIn: 72, widthIn: 48, heightIn: 72 }, { palletCount: 4, totalWeightLb: 20_000, lengthIn: 120, widthIn: 96, heightIn: 96 }, { palletCount: 4, totalWeightLb: 25_000, lengthIn: 144, widthIn: 96, heightIn: 108 }, { palletCount: 6, totalWeightLb: 15_000, lengthIn: 80, widthIn: 80, heightIn: 80 }, { palletCount: 8, totalWeightLb: 18_000, lengthIn: 60, widthIn: 48, heightIn: 60 }, { palletCount: 10, totalWeightLb: 11_000, lengthIn: 60, widthIn: 40, heightIn: 72 }, { palletCount: 12, totalWeightLb: 9_000, lengthIn: 48, widthIn: 40, heightIn: 48 }, { palletCount: 7, totalWeightLb: 35_000, lengthIn: 48, widthIn: 102, heightIn: 108 }, ] as const; function mulberry32(seed: number): () => number { return () => { let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function pickupDateOffsetDays(offset: number): string { const d = new Date(); d.setDate(d.getDate() + offset); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${mm}/${dd}/${d.getFullYear()}`; } function pickZipPair(rng: () => number): { pickup: (typeof US_ZIPS)[number]; delivery: (typeof US_ZIPS)[number]; } { const pickupIdx = Math.floor(rng() * US_ZIPS.length); let deliveryIdx = Math.floor(rng() * US_ZIPS.length); if (deliveryIdx === pickupIdx) { deliveryIdx = (deliveryIdx + 1 + Math.floor(rng() * (US_ZIPS.length - 1))) % US_ZIPS.length; } return { pickup: US_ZIPS[pickupIdx]!, delivery: US_ZIPS[deliveryIdx]! }; } function clampInt(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, Math.round(n))); } /** * 在 FLOCK 硬限 + 拖车线性英尺内随机;差距大但不会触发官网 Linear feet 报错 */ export function randomCargo(rng: () => number): { palletCount: number; totalWeightLb: number; lengthIn: number; widthIn: number; heightIn: number; } { for (let attempt = 0; attempt < 40; attempt += 1) { const base = CARGO_PRESETS[Math.floor(rng() * CARGO_PRESETS.length)]!; const palletCount = clampInt( base.palletCount + Math.floor((rng() - 0.5) * 6), FLOCK_LIMITS.palletCount.min, FLOCK_LIMITS.palletCount.max, ); const widthIn = clampInt( base.widthIn + Math.floor((rng() - 0.5) * 24), 40, FLOCK_LIMITS.dimIn.widthMax, ); const across = widthIn <= 51 ? 2 : 1; const maxRows = Math.floor( (FLOCK_LIMITS.trailerLinearFeetMax * 12) / 48, ); const maxLengthByTrailer = Math.floor( (FLOCK_LIMITS.trailerLinearFeetMax * 12) / Math.ceil(palletCount / across), ); const lengthIn = clampInt( base.lengthIn + Math.floor((rng() - 0.5) * 40), 48, Math.min(FLOCK_LIMITS.dimIn.lengthMax, maxLengthByTrailer, 144), ); const heightIn = clampInt( base.heightIn + Math.floor((rng() - 0.5) * 24), 48, FLOCK_LIMITS.dimIn.heightMax, ); const totalWeightLb = clampInt( base.totalWeightLb + Math.floor((rng() - 0.5) * 8_000), 2_000, FLOCK_LIMITS.totalWeightLbMax, ); const cargo = { palletCount, totalWeightLb, lengthIn, widthIn, heightIn }; if ( lengthIn >= 48 && maxRows >= 1 && fitsFlockTrailerCargo(cargo) ) { return cargo; } } // 兜底:标准托盘双排,必过线性英尺 return { palletCount: 6, totalWeightLb: 3_500, lengthIn: 48, widthIn: 40, heightIn: 48, }; } export function buildFlockBatchCases(count: number, seed = 20260713): FlockQuoteInput[] { const rng = mulberry32(seed); const cases: FlockQuoteInput[] = []; for (let i = 0; i < count; i += 1) { const { pickup, delivery } = pickZipPair(rng); const cargo = randomCargo(rng); const dayOffset = 1 + Math.floor(rng() * 7); cases.push({ pickupDate: pickupDateOffsetDays(dayOffset), pickupZip: pickup.zip, deliveryZip: delivery.zip, palletCount: cargo.palletCount, totalWeightLb: cargo.totalWeightLb, lengthIn: cargo.lengthIn, widthIn: cargo.widthIn, heightIn: cargo.heightIn, }); } return cases; } export function classifyFlockFailure(message: string | undefined): string { const m = (message ?? "").toLowerCase(); if (!m) return "unknown"; if (/flock_quote_limit|thank you for your interest|too many|rate limit|try again later|limit.*quote|quota|throttl|达到.*上限|报价.*上限/i.test( m, )) { return "rate_limit"; } if (/quote_entry_unavailable|表单未加载|无法定位/i.test(m)) { return "form_unavailable"; } if (/flock_account_rejected|issue creating your account/i.test(m)) { return "account_rejected"; } if (/flock_account_exists|already (have|registered)/i.test(m)) { return "account_exists"; } if (/struct_change|注册页|get instant quote/i.test(m)) { return "registration_blocked"; } if (/rpa_data_invalid|未抓齐|no.*quote|prices from/i.test(m)) { return "no_quote_result"; } if (/timeout|timed out|err_timed_out/i.test(m)) { return "timeout"; } if (/connection|network|reset/i.test(m)) { return "network"; } return "other"; } type BatchRow = { round: number; attempt: number; ok: boolean; email?: string; pickupZip: string; deliveryZip: string; cargo: Omit; pickupDate: string; prices?: string; error?: string; failureClass?: string; durationMs: number; }; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function resolveOneBrowserMode(): boolean { const raw = process.env.FLOCK_BATCH_ONE_BROWSER?.trim().toLowerCase(); if (raw === "true" || raw === "1" || raw === "yes") { return true; } if (raw === "false" || raw === "0" || raw === "no") { return false; } // qqdna 探针:每轮全新浏览器;其它模式默认可单会话续查 const mode = process.env.FLOCK_EMAIL_MODE?.trim().toLowerCase(); if (mode === "qqdna" || mode === "qq_dna") { return false; } return true; } async function openFlockBrowserSession( url: string, headless: boolean, ): Promise<{ browser: Awaited>; page: Page; account: FlockTestAccount; }> { const account = createFreshFlockTestAccount(); const browser = await launchRpaBrowser({ headless, incognito: true }); const context = await createRpaBrowserContext(browser); const page = await context.newPage(); await gotoWithResilience(page, url); return { browser, page, account }; } async function runBatchOneBrowser( cases: FlockQuoteInput[], delayMs: number, maxRetries: number, rateLimitDelayMs: number, ): Promise { const url = getFlockQuoteUrl(); const headless = process.env.RPA_HEADLESS !== "false"; const quotesPerEmail = getFlockQuotesPerEmail(); const perEmailLabel = Number.isFinite(quotesPerEmail) ? String(quotesPerEmail) : "不限"; console.log( `[batch-flock] 单浏览器模式 quotesPerEmail=${perEmailLabel}(满额后换新随机邮箱+重注册)`, ); const rows: BatchRow[] = []; let browser: Awaited> | null = null; let page: Page | null = null; let account: FlockTestAccount | null = null; let sessionQuoteCount = 0; const startSession = async (): Promise => { if (browser) { await browser.close(); browser = null; page = null; account = null; } const session = await openFlockBrowserSession(url, headless); browser = session.browser; page = session.page; account = session.account; sessionQuoteCount = 0; console.log( `[batch-flock] 新会话 email=${account.email}(本邮箱最多 ${perEmailLabel} 次)`, ); }; try { for (let i = 0; i < cases.length; i += 1) { const input = cases[i]!; const round = i + 1; if (sessionQuoteCount >= quotesPerEmail || !page || !account) { await startSession(); } else if (i > 0) { if (delayMs > 0) { await sleep(delayMs); } await navigateFlockNewQuote(page, url); } console.log( `\n[batch-flock] === ${round}/${cases.length} === ${account!.email} ` + `${input.pickupZip}→${input.deliveryZip} ` + `${input.palletCount}pl ${input.totalWeightLb}lb ${input.lengthIn}x${input.widthIn}x${input.heightIn}in`, ); const started = Date.now(); let attempt = 1; let result = await (async () => { if (!(await waitForFlockQuoteForm(page!))) { return { ok: false, quotes: [], errorMessage: "QUOTE_ENTRY_UNAVAILABLE:查价表单未加载", }; } return runFlockQuoteOnPage(page!, input, account!); })(); while (attempt < maxRetries && (!result.ok || result.quotes.length < getFlockMinQuotes())) { const cls = classifyFlockFailure(result.errorMessage); if (cls === "rate_limit") { console.log( `[batch-flock] 报价上限,等待 ${rateLimitDelayMs}ms 后重试…`, ); await sleep(rateLimitDelayMs); } else { console.log( `[batch-flock] 重试 ${attempt + 1}/${maxRetries}: [${cls}] ${result.errorMessage}`, ); await sleep(Math.min(delayMs, 10_000)); } attempt += 1; if (!(await waitForFlockQuoteForm(page!))) { await navigateFlockNewQuote(page!, url); await waitForFlockQuoteForm(page!); } result = await runFlockQuoteOnPage(page!, input, account!); } const durationMs = Date.now() - started; const ok = result.ok && result.quotes.length >= getFlockMinQuotes(); const failureClass = ok ? undefined : classifyFlockFailure(result.errorMessage); rows.push({ round, attempt, ok, email: account!.email, pickupZip: input.pickupZip, deliveryZip: input.deliveryZip, pickupDate: input.pickupDate, cargo: { palletCount: input.palletCount, totalWeightLb: input.totalWeightLb, lengthIn: input.lengthIn, widthIn: input.widthIn, heightIn: input.heightIn, }, prices: ok ? result.quotes.map((q) => `${q.label}:$${q.totalUsd}`).join(", ") : undefined, error: ok ? undefined : result.errorMessage, failureClass, durationMs, }); if (ok) { console.log(`[batch-flock] OK (${durationMs}ms) ${rows.at(-1)?.prices}`); sessionQuoteCount += 1; } else { console.log(`[batch-flock] FAIL: [${failureClass}] ${result.errorMessage}`); if (failureClass === "account_rejected" || failureClass === "account_exists" || failureClass === "rate_limit") { sessionQuoteCount = quotesPerEmail; } else { sessionQuoteCount += 1; } } } } finally { if (browser) { await browser.close(); } } return rows; } async function main(): Promise { process.env.FLOCK_FRESH_SESSION_PER_QUOTE ??= "true"; process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE ??= "true"; process.env.FLOCK_MIN_QUOTES ??= "1"; process.env.FLOCK_QUOTE_WAIT_MS ??= "20000"; process.env.FLOCK_FIELD_PAUSE_MS ??= "1000"; process.env.FLOCK_SESSION_MAX_ROTATIONS ??= "3"; const stallsDir = path.join(process.cwd(), ".rpa", "flock-stalls"); if (fs.existsSync(stallsDir)) { for (const name of fs.readdirSync(stallsDir)) { fs.unlinkSync(path.join(stallsDir, name)); } console.log(`[batch-flock] 已清空 ${stallsDir}`); } const rounds = Number(process.env.FLOCK_BATCH_ROUNDS ?? "10"); const delayMs = Number(process.env.FLOCK_BATCH_DELAY_MS ?? "1000"); const maxRetries = Number(process.env.FLOCK_BATCH_MAX_RETRIES ?? "1"); const rateLimitDelayMs = Number( process.env.FLOCK_BATCH_RATE_LIMIT_DELAY_MS ?? "45000", ); const seed = Number(process.env.FLOCK_BATCH_SEED ?? String(Date.now())); const minQuotes = getFlockMinQuotes(); const quoteWaitMs = getFlockQuoteWaitMs(); const quotesPerEmail = getFlockQuotesPerEmail(); const cases = buildFlockBatchCases(rounds, seed); const oneBrowser = resolveOneBrowserMode(); let rows: BatchRow[] = []; const summary: Record = {}; console.log(`[batch-flock] rounds=${rounds} delay=${delayMs}ms seed=${seed}`); console.log( oneBrowser ? "[batch-flock] 单浏览器连续查价(首轮注册,后续 Build another quote)" : "[batch-flock] 每轮无痕新浏览器 + 已验证 QQ 短别名", ); console.log( `[batch-flock] limits: pallets ${FLOCK_LIMITS.palletCount.min}-${FLOCK_LIMITS.palletCount.max}, weight≤${FLOCK_LIMITS.totalWeightLbMax}lb`, ); console.log( `[batch-flock] maxRetries=${maxRetries} rateLimitDelay=${rateLimitDelayMs}ms ` + `minQuotes=${minQuotes} quoteWait=${quoteWaitMs}ms ` + `quotesPerEmail=${Number.isFinite(quotesPerEmail) ? quotesPerEmail : "不限"}`, ); const sessionStarted = Date.now(); // 与 embed Worker 互斥:禁止本机两路浏览器同时打 Flock(共 IP 互踩) await withFlockExclusiveLock( "batch", async () => { if (oneBrowser) { rows = await runBatchOneBrowser(cases, delayMs, maxRetries, rateLimitDelayMs); for (const row of rows.filter((r) => !r.ok)) { const cls = row.failureClass ?? "unknown"; summary[cls] = (summary[cls] ?? 0) + 1; } } else { for (let i = 0; i < cases.length; i += 1) { const input = cases[i]!; const round = i + 1; if (i > 0 && delayMs > 0) { await sleep(delayMs); } console.log( `\n[batch-flock] === ${round}/${rounds} === ${input.pickupZip}→${input.deliveryZip} ` + `${input.palletCount}pl ${input.totalWeightLb}lb ${input.lengthIn}x${input.widthIn}x${input.heightIn}in`, ); const started = Date.now(); let result = await runFlockVisualChain({ input }); let attempt = 1; while ( attempt < maxRetries && (!result.ok || result.quotes.length < minQuotes) ) { const cls = classifyFlockFailure(result.errorMessage); if (cls === "rate_limit") { console.log( `[batch-flock] 报价上限,等待 ${rateLimitDelayMs}ms 后重试 (${attempt + 1}/${maxRetries})…`, ); await sleep(rateLimitDelayMs); } else { console.log( `[batch-flock] 重试 ${attempt + 1}/${maxRetries}: [${cls}] ${result.errorMessage}`, ); await sleep(Math.min(delayMs, 10_000)); } attempt += 1; result = await runFlockVisualChain({ input }); } const durationMs = Date.now() - started; const ok = result.ok && result.quotes.length >= minQuotes; const row: BatchRow = { round, attempt, ok, pickupZip: input.pickupZip, deliveryZip: input.deliveryZip, pickupDate: input.pickupDate, cargo: { palletCount: input.palletCount, totalWeightLb: input.totalWeightLb, lengthIn: input.lengthIn, widthIn: input.widthIn, heightIn: input.heightIn, }, prices: ok ? result.quotes.map((q) => `${q.label}:$${q.totalUsd}`).join(", ") : undefined, error: ok ? undefined : result.errorMessage, failureClass: ok ? undefined : classifyFlockFailure(result.errorMessage), durationMs, }; rows.push(row); if (ok) { console.log(`[batch-flock] OK (${durationMs}ms) ${row.prices}`); } else { console.log(`[batch-flock] FAIL: [${row.failureClass}] ${row.error}`); const cls = row.failureClass ?? "unknown"; summary[cls] = (summary[cls] ?? 0) + 1; } } } }, { waitMs: 60_000, pollMs: 1_000 }, ); const sessionMs = Date.now() - sessionStarted; console.log(`[batch-flock] session total ${sessionMs}ms`); const passed = rows.filter((r) => r.ok).length; const report = { at: new Date().toISOString(), rounds, passed, failed: rounds - passed, failureSummary: summary, rows, }; const outDir = path.join(process.cwd(), ".rpa"); fs.mkdirSync(outDir, { recursive: true }); const reportPath = path.join(outDir, `flock-batch-${Date.now()}.json`); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8"); console.log(`\n[batch-flock] ========== 汇总 ==========`); console.log(`通过: ${passed}/${rounds}`); if (passed < rounds) { console.log("失败分类:", summary); for (const r of rows.filter((x) => !x.ok)) { console.log( ` #${r.round} ${r.pickupZip}→${r.deliveryZip} [${r.failureClass}] ${r.error}`, ); } } console.log(`报告: ${reportPath}`); if (passed < rounds) { process.exitCode = 1; } } const isDirectRun = typeof process.argv[1] === "string" && path.resolve(process.argv[1]) === path.resolve(__filename); if (isDirectRun) { main().catch((error) => { console.error("[batch-flock] fatal:", error); process.exit(1); }); }