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.

423 lines
12 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 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;
}