|
|
/**
|
|
|
* 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<void> {
|
|
|
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<void> {
|
|
|
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<FlockRunQuoteResult & { registered?: boolean; accountExists?: boolean }> {
|
|
|
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<FlockRunQuoteResult & { registered?: boolean; accountExists?: boolean }> {
|
|
|
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<FlockRunQuoteResult> {
|
|
|
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<FlockRunQuoteResult & { registered?: boolean; accountExists?: boolean }> {
|
|
|
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<FlockRunQuoteResult> {
|
|
|
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;
|
|
|
}
|