|
|
/**
|
|
|
* Flock Direct:浏览器只用于拿匿名会话 cookie,报价走 POST /user/quotes(跳过填表 DOM)
|
|
|
*/
|
|
|
import {
|
|
|
FLOCK_APP_ORIGIN,
|
|
|
FLOCK_DIRECT_QUOTE_TIMEOUT_MS,
|
|
|
FLOCK_QUOTES_URL,
|
|
|
DEFAULT_FLOCK_USER_AGENT,
|
|
|
} from "@/lib/flock/constants";
|
|
|
import { getFlockMinQuotes, getFlockQuoteUrl } from "@/lib/flock/env";
|
|
|
import { mustForceFlockAccountLogin } from "@/lib/flock/login-context";
|
|
|
import { mapFlockFulfillmentOptionsToLines } from "@/lib/flock/map-fulfillment-options";
|
|
|
import { buildFlockQuotesApiRequest } from "@/lib/flock/quote-payload";
|
|
|
import { createRpaBrowserContext } from "@/lib/rpa/browser-context";
|
|
|
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
|
|
|
import {
|
|
|
formatExternalSiteNavigationError,
|
|
|
gotoWithResilience,
|
|
|
isTransientNavigationError,
|
|
|
} from "@/lib/rpa/page-goto";
|
|
|
import { waitForFlockQuoteForm } from "@/workers/rpa/flock/form-interactions";
|
|
|
import type {
|
|
|
FlockQuoteInput,
|
|
|
FlockRunQuoteResult,
|
|
|
} from "@/workers/rpa/flock/types";
|
|
|
|
|
|
function randomDeviceFingerprint(): string {
|
|
|
const alphabet =
|
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
let out = "";
|
|
|
for (let i = 0; i < 20; i += 1) {
|
|
|
out += alphabet[Math.floor(Math.random() * alphabet.length)]!;
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
async function waitForSessionCookies(
|
|
|
context: Awaited<ReturnType<typeof createRpaBrowserContext>>,
|
|
|
timeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
const cookies = await context.cookies();
|
|
|
const hasAnon = cookies.some((c) => c.name === "anonymous_token");
|
|
|
const hasJwt = cookies.some((c) => c.name === "ff_jwt");
|
|
|
if (hasAnon || hasJwt) return true;
|
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** Direct 询价:bootstrap 匿名 cookie → POST /user/quotes → 映射两档 */
|
|
|
export async function fetchFlockQuotesViaDirect(
|
|
|
input: FlockQuoteInput,
|
|
|
): Promise<FlockRunQuoteResult> {
|
|
|
// 防御在深度:客户账密上下文下禁止走 Direct
|
|
|
if (mustForceFlockAccountLogin()) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage:
|
|
|
"FLOCK_PATH_DENIED:客户已绑定官网账密,禁止 Direct /user/quotes,必须 DOM 登录查价",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const headless = process.env.RPA_HEADLESS !== "false";
|
|
|
const browser = await launchRpaBrowser({ headless, incognito: true });
|
|
|
try {
|
|
|
const context = await createRpaBrowserContext(browser, {
|
|
|
userAgent: DEFAULT_FLOCK_USER_AGENT,
|
|
|
extraHTTPHeaders: {
|
|
|
"Accept-Language": "en-US",
|
|
|
},
|
|
|
});
|
|
|
const page = await context.newPage();
|
|
|
const url = getFlockQuoteUrl();
|
|
|
|
|
|
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,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
await waitForFlockQuoteForm(page).catch(() => false);
|
|
|
const ready = await waitForSessionCookies(context, 15_000);
|
|
|
if (!ready) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage:
|
|
|
"FLOCK_DIRECT_NO_SESSION:未拿到 anonymous_token/ff_jwt(入口可能变更)",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const payload = buildFlockQuotesApiRequest(input);
|
|
|
const fingerprint = randomDeviceFingerprint();
|
|
|
|
|
|
const response = await context.request.post(FLOCK_QUOTES_URL, {
|
|
|
timeout: FLOCK_DIRECT_QUOTE_TIMEOUT_MS,
|
|
|
headers: {
|
|
|
Accept: "application/json",
|
|
|
"Content-Type": "application/json",
|
|
|
Origin: FLOCK_APP_ORIGIN,
|
|
|
Referer: `${FLOCK_APP_ORIGIN}/`,
|
|
|
"x-device-fingerprint": fingerprint,
|
|
|
},
|
|
|
data: payload,
|
|
|
});
|
|
|
|
|
|
const status = response.status();
|
|
|
const text = await response.text();
|
|
|
if (status < 200 || status >= 300) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: `FLOCK_DIRECT_HTTP_${status}:${text.slice(0, 240)}`,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
let body: unknown;
|
|
|
try {
|
|
|
body = JSON.parse(text) as unknown;
|
|
|
} catch {
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: "FLOCK_DIRECT_BAD_JSON:报价响应无法解析",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const { quotes, reference } = mapFlockFulfillmentOptionsToLines(
|
|
|
body as Parameters<typeof mapFlockFulfillmentOptionsToLines>[0],
|
|
|
);
|
|
|
const minQuotes = getFlockMinQuotes();
|
|
|
if (quotes.length < minQuotes) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes,
|
|
|
reference,
|
|
|
errorMessage: `FLOCK_DIRECT_NO_RATES:fulfillmentOptions 映射后不足 ${minQuotes} 档`,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
console.log(
|
|
|
`[flock-direct] OK ref=${reference ?? "-"} ${quotes
|
|
|
.map((q) => `${q.label}:$${q.totalUsd}`)
|
|
|
.join(", ")}`,
|
|
|
);
|
|
|
return { ok: true, quotes, reference };
|
|
|
} finally {
|
|
|
await browser.close();
|
|
|
}
|
|
|
}
|