|
|
import type { Page, Response } from "playwright";
|
|
|
import {
|
|
|
formatPinnedTelemetry,
|
|
|
isJsonNetworkCandidate,
|
|
|
RPA_QUOTE_CAPTURE_TIMEOUT_MS,
|
|
|
RPA_QUOTE_POST_SUBMIT_SETTLE_MS,
|
|
|
} from "@/workers/rpa/quote-capture/constants";
|
|
|
import { resolveRpaQuoteCaptureMs } from "@/lib/rpa/env";
|
|
|
import { isAxelQuoteResponseUrl } from "@/workers/rpa/quote-capture/axel-contract";
|
|
|
import { normalizeQuote } from "@/workers/rpa/quote-capture/quote-normalize";
|
|
|
import {
|
|
|
attachNetworkVisibility,
|
|
|
type NetworkTraceEntry,
|
|
|
} from "@/workers/rpa/quote-capture/quote-network-visibility";
|
|
|
import type { QuoteCaptureSignals } from "@/workers/rpa/quote-capture/types";
|
|
|
|
|
|
async function tryParseResponseJson(
|
|
|
response: Response,
|
|
|
): Promise<unknown | null> {
|
|
|
try {
|
|
|
return await response.json();
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function pollUntilAxelQuote(
|
|
|
getAxelCount: () => number,
|
|
|
deadlineMs: number,
|
|
|
): Promise<void> {
|
|
|
while (Date.now() < deadlineMs) {
|
|
|
if (getAxelCount() > 0) {
|
|
|
return;
|
|
|
}
|
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function countAxelPayloads(
|
|
|
contractPayloads: QuoteCaptureSignals["contractPayloads"],
|
|
|
jsonPayloads: QuoteCaptureSignals["jsonPayloads"],
|
|
|
): number {
|
|
|
const contractAxel = contractPayloads.filter((p) =>
|
|
|
isAxelQuoteResponseUrl(p.url),
|
|
|
).length;
|
|
|
const jsonAxel = jsonPayloads.filter((p) => isAxelQuoteResponseUrl(p.url)).length;
|
|
|
return contractAxel + jsonAxel;
|
|
|
}
|
|
|
|
|
|
function isQuoteRateApiResponse(response: Response): boolean {
|
|
|
if (response.status() < 200 || response.status() >= 300) {
|
|
|
return false;
|
|
|
}
|
|
|
const url = response.url().toLowerCase();
|
|
|
if (url.includes("axel/quote") || url.includes("services.mothership.com/axel")) {
|
|
|
return true;
|
|
|
}
|
|
|
if (
|
|
|
/dashboard\.mothership\.com|api\.mothership\.com|plugins\.mothership\.com/.test(
|
|
|
url,
|
|
|
) &&
|
|
|
/quote|rate|pricing|graphql/.test(url)
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function drainInflight(inflight: Set<Promise<void>>): Promise<void> {
|
|
|
if (inflight.size === 0) {
|
|
|
return;
|
|
|
}
|
|
|
await Promise.allSettled([...inflight]);
|
|
|
}
|
|
|
|
|
|
async function waitForSignUpQuoteRates(page: Page, timeoutMs: number): Promise<void> {
|
|
|
if (!page.url().includes("sign-up-quote")) {
|
|
|
return;
|
|
|
}
|
|
|
console.log("[rpa] quote-network: sign-up-quote 页等待 widget 拉价…");
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
const remaining = () => Math.max(500, deadline - Date.now());
|
|
|
|
|
|
/** 先等 quoter-sign-up.js,再 axel/quote;勿用 networkidle(会吞掉整段超时且 axel 尚未发起) */
|
|
|
await page
|
|
|
.waitForResponse(
|
|
|
(response) =>
|
|
|
response.url().includes("quoter-sign-up.js") &&
|
|
|
response.status() >= 200 &&
|
|
|
response.status() < 300,
|
|
|
{ timeout: remaining() },
|
|
|
)
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
await page
|
|
|
.waitForResponse((response) => isQuoteRateApiResponse(response), {
|
|
|
timeout: remaining(),
|
|
|
})
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
await page.waitForTimeout(Math.min(1_500, remaining()));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 契约驱动捕获:listener 在 submit 之前挂载;drain 异步 handler 避免 json=0 竞态。
|
|
|
*/
|
|
|
export async function captureQuoteNetworkResponses(
|
|
|
page: Page,
|
|
|
submit: () => Promise<void>,
|
|
|
): Promise<QuoteCaptureSignals> {
|
|
|
const contractPayloads: QuoteCaptureSignals["contractPayloads"] = [];
|
|
|
const jsonPayloads: QuoteCaptureSignals["jsonPayloads"] = [];
|
|
|
const seenKeys = new Set<string>();
|
|
|
const sniffedUrls: string[] = [];
|
|
|
const inflight = new Set<Promise<void>>();
|
|
|
|
|
|
const visibility = attachNetworkVisibility(page);
|
|
|
let networkTrace: NetworkTraceEntry[] = [];
|
|
|
|
|
|
const onResponse = (response: Response): void => {
|
|
|
const task = (async () => {
|
|
|
try {
|
|
|
if (response.status() < 200 || response.status() >= 300) {
|
|
|
return;
|
|
|
}
|
|
|
const url = response.url();
|
|
|
const contentType = response.headers()["content-type"] ?? "";
|
|
|
|
|
|
if (!isJsonNetworkCandidate(url, contentType)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (process.env.RPA_QUOTE_NETWORK_SNIFF === "true") {
|
|
|
sniffedUrls.push(url);
|
|
|
}
|
|
|
|
|
|
const body = await tryParseResponseJson(response);
|
|
|
if (body === null) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
jsonPayloads.push({ url, body });
|
|
|
|
|
|
if (!normalizeQuote(body)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const key = url;
|
|
|
if (seenKeys.has(key)) {
|
|
|
return;
|
|
|
}
|
|
|
seenKeys.add(key);
|
|
|
|
|
|
contractPayloads.push({ url, body });
|
|
|
console.log(
|
|
|
`[rpa] quote-contract match url=${url} ${formatPinnedTelemetry(url)}`,
|
|
|
);
|
|
|
} catch {
|
|
|
/* 忽略非 JSON 响应 */
|
|
|
}
|
|
|
})();
|
|
|
inflight.add(task);
|
|
|
void task.finally(() => {
|
|
|
inflight.delete(task);
|
|
|
});
|
|
|
};
|
|
|
|
|
|
page.on("response", onResponse);
|
|
|
|
|
|
const captureTimeoutMs = resolveRpaQuoteCaptureMs(RPA_QUOTE_CAPTURE_TIMEOUT_MS);
|
|
|
|
|
|
await submit();
|
|
|
console.log(`[rpa] quote-network: post-submit url=${page.url()}`);
|
|
|
|
|
|
/** deadline 须在 submit 之后,否则长地址步会耗尽 capture 窗口 */
|
|
|
const deadline = Date.now() + captureTimeoutMs;
|
|
|
const remaining = () => Math.max(500, deadline - Date.now());
|
|
|
const axelCount = () =>
|
|
|
countAxelPayloads(contractPayloads, jsonPayloads);
|
|
|
|
|
|
await waitForSignUpQuoteRates(page, captureTimeoutMs);
|
|
|
|
|
|
await pollUntilAxelQuote(axelCount, deadline);
|
|
|
|
|
|
if (axelCount() === 0 && page.url().includes("sign-up-quote")) {
|
|
|
console.log("[rpa] quote-network: sign-up-quote 补等 axel/quote…");
|
|
|
await page
|
|
|
.waitForResponse((response) => isQuoteRateApiResponse(response), {
|
|
|
timeout: remaining(),
|
|
|
})
|
|
|
.catch(() => null);
|
|
|
await drainInflight(inflight);
|
|
|
await pollUntilAxelQuote(axelCount, Date.now() + Math.min(8_000, remaining()));
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(
|
|
|
Math.min(RPA_QUOTE_POST_SUBMIT_SETTLE_MS, remaining()),
|
|
|
);
|
|
|
|
|
|
page.off("response", onResponse);
|
|
|
await drainInflight(inflight);
|
|
|
|
|
|
networkTrace = [...visibility.trace];
|
|
|
visibility.logSummary("capture-done");
|
|
|
visibility.detach();
|
|
|
|
|
|
if (sniffedUrls.length > 0) {
|
|
|
console.log(
|
|
|
`[rpa] quote-network-sniff (telemetry): ${[...new Set(sniffedUrls)].join(", ")}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
contractPayloads,
|
|
|
jsonPayloads,
|
|
|
networkTrace,
|
|
|
resultUrl: page.url(),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/** @deprecated 使用 captureQuoteNetworkResponses */
|
|
|
export const captureQuoteEvents = captureQuoteNetworkResponses;
|