|
|
/**
|
|
|
* 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);
|
|
|
});
|
|
|
}
|