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.

299 lines
8.5 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 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();
}