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.

598 lines
20 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.

/**
* 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<FlockQuoteInput, "pickupDate" | "pickupZip" | "deliveryZip">;
pickupDate: string;
prices?: string;
error?: string;
failureClass?: string;
durationMs: number;
};
function sleep(ms: number): Promise<void> {
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<ReturnType<typeof launchRpaBrowser>>;
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<BatchRow[]> {
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<ReturnType<typeof launchRpaBrowser>> | null = null;
let page: Page | null = null;
let account: FlockTestAccount | null = null;
let sessionQuoteCount = 0;
const startSession = async (): Promise<void> => {
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<void> {
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<string, number> = {};
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);
});
}