|
|
/**
|
|
|
* Flock Worker 浏览器会话复用 — 对齐 batch-flock-stress 单浏览器模式
|
|
|
* 登录凭据模式:复用已登录 page,不换号注册
|
|
|
* 无登录:同一随机 QQ 邮箱连续查价,满 FLOCK_QUOTES_PER_EMAIL 后换新邮箱重注册
|
|
|
*/
|
|
|
import fs from "node:fs";
|
|
|
import path from "node:path";
|
|
|
import type { Browser, BrowserContext, Page } from "playwright";
|
|
|
import {
|
|
|
getFlockQuoteUrl,
|
|
|
getFlockQuotesPerEmail,
|
|
|
getFlockSessionMaxRotations,
|
|
|
getFlockStorageStatePath,
|
|
|
getFlockTestAccount,
|
|
|
shouldLoadFlockStorageState,
|
|
|
type FlockTestAccount,
|
|
|
} from "@/lib/flock/env";
|
|
|
import {
|
|
|
getEffectiveFlockLogin,
|
|
|
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 { gotoWithResilience } from "@/lib/rpa/page-goto";
|
|
|
import {
|
|
|
isRpaHeaded,
|
|
|
resolveRpaHeadless,
|
|
|
resolveRpaSlowMoMs,
|
|
|
} from "@/lib/rpa/env";
|
|
|
import {
|
|
|
navigateFlockNewQuote,
|
|
|
waitForFlockQuoteForm,
|
|
|
} from "@/workers/rpa/flock/form-interactions";
|
|
|
import { ensureFlockLoggedIn } from "@/workers/rpa/flock/login";
|
|
|
import { flockQuoteLimitMessage } from "@/workers/rpa/flock/page-state";
|
|
|
import { runFlockQuoteOnPage } from "@/workers/rpa/flock/visual-chain";
|
|
|
import type { FlockQuoteInput, FlockRunQuoteResult } from "@/workers/rpa/flock/types";
|
|
|
import {
|
|
|
reportFlockStage,
|
|
|
type FlockStageReporter,
|
|
|
} from "@/lib/flock/rpa-progress";
|
|
|
|
|
|
type SessionState = {
|
|
|
browser: Browser;
|
|
|
context: BrowserContext;
|
|
|
page: Page;
|
|
|
account: FlockTestAccount;
|
|
|
quoteCount: number;
|
|
|
/** 客户/环境登录邮箱;换号必须重开会话 */
|
|
|
loginEmail: string | null;
|
|
|
};
|
|
|
|
|
|
let session: SessionState | null = null;
|
|
|
|
|
|
/** 需换新邮箱+浏览器的失败(不含报价上限:上限多为 IP 级,换号无效) */
|
|
|
export function shouldRotateFlockSession(errorMessage?: string): boolean {
|
|
|
if (!errorMessage) {
|
|
|
return false;
|
|
|
}
|
|
|
if (hasEffectiveFlockLogin()) {
|
|
|
return false;
|
|
|
}
|
|
|
if (
|
|
|
/flock_quote_limit|thank you for your interest|报价.*上限|quote\s*limit/i.test(
|
|
|
errorMessage,
|
|
|
)
|
|
|
) {
|
|
|
return false;
|
|
|
}
|
|
|
return (
|
|
|
/flock_account_rejected|issue creating your account/i.test(errorMessage) ||
|
|
|
/flock_account_exists|already (have|registered)/i.test(errorMessage)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function closeSession(): Promise<void> {
|
|
|
if (!session) {
|
|
|
return;
|
|
|
}
|
|
|
await session.browser.close().catch(() => undefined);
|
|
|
session = null;
|
|
|
}
|
|
|
|
|
|
function resolveStoragePath(): string {
|
|
|
const raw = getFlockStorageStatePath();
|
|
|
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
|
|
|
}
|
|
|
|
|
|
async function openSession(
|
|
|
input: FlockQuoteInput,
|
|
|
onStage?: FlockStageReporter,
|
|
|
): Promise<SessionState> {
|
|
|
await closeSession();
|
|
|
const url = getFlockQuoteUrl();
|
|
|
const headless = resolveRpaHeadless();
|
|
|
const slowMo = resolveRpaSlowMoMs();
|
|
|
const loginMode = hasEffectiveFlockLogin();
|
|
|
const loginEmail = getEffectiveFlockLogin()?.email ?? null;
|
|
|
const account = loginMode
|
|
|
? getFlockTestAccount()
|
|
|
: resolveFlockQuoteAccount(input);
|
|
|
const statePath = resolveStoragePath();
|
|
|
const loadStorage =
|
|
|
loginMode &&
|
|
|
!mustForceFlockAccountLogin() &&
|
|
|
shouldLoadFlockStorageState(fs.existsSync(statePath));
|
|
|
|
|
|
console.log(
|
|
|
`[flock-session] open headed=${!headless} slowMo=${slowMo ?? 0} loginMode=${loginMode}`,
|
|
|
);
|
|
|
|
|
|
const browser = await launchRpaBrowser({
|
|
|
headless,
|
|
|
incognito: !loginMode || mustForceFlockAccountLogin(),
|
|
|
...(slowMo !== undefined ? { slowMo } : {}),
|
|
|
});
|
|
|
const context = await createRpaBrowserContext(
|
|
|
browser,
|
|
|
loadStorage ? { storageState: statePath } : {},
|
|
|
);
|
|
|
const page = await context.newPage();
|
|
|
await gotoWithResilience(page, url);
|
|
|
|
|
|
if (loginMode) {
|
|
|
await reportFlockStage(onStage, "login");
|
|
|
let login = await ensureFlockLoggedIn(page, context, {
|
|
|
forceFreshLogin: mustForceFlockAccountLogin(),
|
|
|
});
|
|
|
if (!login.ok) {
|
|
|
login = await ensureFlockLoggedIn(page, context, {
|
|
|
reloginAttempt: true,
|
|
|
forceFreshLogin: true,
|
|
|
});
|
|
|
}
|
|
|
if (!login.ok) {
|
|
|
if (isRpaHeaded()) {
|
|
|
await page.waitForTimeout(5_000).catch(() => undefined);
|
|
|
}
|
|
|
await browser.close().catch(() => undefined);
|
|
|
throw new Error(login.errorMessage);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
session = {
|
|
|
browser,
|
|
|
context,
|
|
|
page,
|
|
|
account,
|
|
|
quoteCount: 0,
|
|
|
loginEmail,
|
|
|
};
|
|
|
console.log(
|
|
|
loginMode
|
|
|
? `[flock-session] 登录会话已就绪 email=${loginEmail ?? "?"} quotesPerEmail=${getFlockQuotesPerEmail()}`
|
|
|
: `[flock-session] 新会话 email=${account.email} quotesPerEmail=${getFlockQuotesPerEmail()}`,
|
|
|
);
|
|
|
return session;
|
|
|
}
|
|
|
|
|
|
async function ensureActiveSession(
|
|
|
input: FlockQuoteInput,
|
|
|
url: string,
|
|
|
quotesPerEmail: number,
|
|
|
onStage?: FlockStageReporter,
|
|
|
): Promise<SessionState> {
|
|
|
const loginMode = hasEffectiveFlockLogin();
|
|
|
const loginEmail = getEffectiveFlockLogin()?.email ?? null;
|
|
|
const customEmail = input.registration?.email?.trim();
|
|
|
|
|
|
// 客户切换账密:必须重开会话,禁止串号
|
|
|
if (session && loginMode && session.loginEmail !== loginEmail) {
|
|
|
console.log(
|
|
|
`[flock-session] 登录账号变更 ${session.loginEmail ?? "none"} → ${loginEmail ?? "none"},重开会话`,
|
|
|
);
|
|
|
await closeSession();
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
!loginMode &&
|
|
|
session &&
|
|
|
customEmail &&
|
|
|
session.account.email !== customEmail
|
|
|
) {
|
|
|
await closeSession();
|
|
|
}
|
|
|
|
|
|
if (!session || (!loginMode && session.quoteCount >= quotesPerEmail)) {
|
|
|
return openSession(input, onStage);
|
|
|
}
|
|
|
await navigateFlockNewQuote(session.page, url);
|
|
|
if (loginMode) {
|
|
|
await reportFlockStage(onStage, "login");
|
|
|
const login = await ensureFlockLoggedIn(session.page, session.context);
|
|
|
if (!login.ok) {
|
|
|
await closeSession();
|
|
|
return openSession(input, onStage);
|
|
|
}
|
|
|
}
|
|
|
return session;
|
|
|
}
|
|
|
|
|
|
/** Worker 内复用浏览器执行 Flock 查价;上限/拒号快失败 */
|
|
|
export async function runFlockQuoteWithWorkerSession(
|
|
|
input: FlockQuoteInput,
|
|
|
onStage?: FlockStageReporter,
|
|
|
): Promise<FlockRunQuoteResult> {
|
|
|
const quotesPerEmail = getFlockQuotesPerEmail();
|
|
|
const maxRotations = hasEffectiveFlockLogin()
|
|
|
? 1
|
|
|
: getFlockSessionMaxRotations();
|
|
|
const url = getFlockQuoteUrl();
|
|
|
let lastResult: FlockRunQuoteResult = {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: "FLOCK_QUOTE_LIMIT:未执行查价",
|
|
|
};
|
|
|
|
|
|
for (let rotation = 0; rotation < maxRotations; rotation += 1) {
|
|
|
let active: SessionState;
|
|
|
try {
|
|
|
active = await ensureActiveSession(input, url, quotesPerEmail, onStage);
|
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
|
return { ok: false, quotes: [], errorMessage: msg };
|
|
|
}
|
|
|
|
|
|
const limitAtEntry = await flockQuoteLimitMessage(active.page);
|
|
|
if (limitAtEntry) {
|
|
|
console.warn(`[flock-session] 入口已达上限,快失败: ${limitAtEntry}`);
|
|
|
await closeSession();
|
|
|
return { ok: false, quotes: [], errorMessage: limitAtEntry };
|
|
|
}
|
|
|
|
|
|
await reportFlockStage(onStage, "open_quote");
|
|
|
if (!(await waitForFlockQuoteForm(active.page))) {
|
|
|
const limitMsg = await flockQuoteLimitMessage(active.page);
|
|
|
if (limitMsg) {
|
|
|
console.warn(`[flock-session] 表单不可用(上限),快失败`);
|
|
|
await closeSession();
|
|
|
return { ok: false, quotes: [], errorMessage: limitMsg };
|
|
|
}
|
|
|
await closeSession();
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: "QUOTE_ENTRY_UNAVAILABLE:查价表单未加载",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const result = await runFlockQuoteOnPage(
|
|
|
active.page,
|
|
|
input,
|
|
|
active.account,
|
|
|
onStage,
|
|
|
);
|
|
|
lastResult = result;
|
|
|
|
|
|
if (result.ok) {
|
|
|
active.quoteCount += 1;
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
/flock_quote_limit|thank you for your interest/i.test(
|
|
|
result.errorMessage ?? "",
|
|
|
)
|
|
|
) {
|
|
|
await closeSession();
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
if (shouldRotateFlockSession(result.errorMessage)) {
|
|
|
console.warn(
|
|
|
`[flock-session] ${result.errorMessage?.slice(0, 80)}… 换新邮箱 (${rotation + 1}/${maxRotations})`,
|
|
|
);
|
|
|
await closeSession();
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
await closeSession();
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
await closeSession();
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage:
|
|
|
lastResult.errorMessage ??
|
|
|
`FLOCK_QUOTE_LIMIT:已换 ${maxRotations} 个邮箱仍无法查价`,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export async function closeFlockWorkerSession(): Promise<void> {
|
|
|
await closeSession();
|
|
|
}
|