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.

237 lines
6.8 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.

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);
if (axelCount() === 0) {
const { isInlineQuotePanelVisible } = await import(
"@/workers/rpa/quote-capture/post-submit-navigation"
);
const inlineDeadline = Date.now() + Math.min(15_000, remaining());
while (Date.now() < inlineDeadline && axelCount() === 0) {
if (await isInlineQuotePanelVisible(page)) {
console.log("[rpa] quote-network: 首页内嵌报价已出现,等待 axel/quote…");
break;
}
await page.waitForTimeout(200);
}
}
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;