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 { try { return await response.json(); } catch { return null; } } async function pollUntilAxelQuote( getAxelCount: () => number, deadlineMs: number, ): Promise { 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 { if (inflight.size === 0) { return; } await Promise.allSettled([...inflight]); } async function waitForSignUpQuoteRates(page: Page, timeoutMs: number): Promise { 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, ): Promise { const contractPayloads: QuoteCaptureSignals["contractPayloads"] = []; const jsonPayloads: QuoteCaptureSignals["jsonPayloads"] = []; const seenKeys = new Set(); const sniffedUrls: string[] = []; const inflight = new Set>(); 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;