import fs from "node:fs"; export type NetworkEvent = { ts: number; iso: string; phase: string; kind: "request" | "response"; method: string; url: string; status?: number; isPlaceCommit?: boolean; isLocationSearch?: boolean; isQuote?: boolean; }; export type SearchPlaceChain = { index: number; phase: string; searchRequestTs: number; searchResponseTs: number | null; searchStatus: number | null; placeRequestTs: number | null; placeResponseTs: number | null; placeStatus: number | null; placeId: string | null; gapSearchResponseToPlaceRequestMs: number | null; gapPlaceRequestToPlaceResponseMs: number | null; complete: boolean; }; export type NetworkSummary = { source: string; eventCount: number; searchResponses200: number; placeGet200: number; quotePost200: number; chains: SearchPlaceChain[]; }; const PLACE_ID_RE = /\/axel\/location\/place\/(ChIJ[^/?]+)/; export function loadNetworkNdjson(filePath: string): NetworkEvent[] { const raw = fs.readFileSync(filePath, "utf8"); return raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line) as NetworkEvent); } export function extractPlaceId(url: string): string | null { const m = url.match(PLACE_ID_RE); return m?.[1] ?? null; } /** 从 network.ndjson 提取每次 search 200 后是否紧跟 GET place 200 */ export function summarizeNetwork( events: NetworkEvent[], source: string, ): NetworkSummary { const chains: SearchPlaceChain[] = []; let searchResponses200 = 0; let placeGet200 = 0; let quotePost200 = 0; for (let i = 0; i < events.length; i += 1) { const ev = events[i]!; if ( ev.kind === "response" && ev.isLocationSearch && ev.status === 200 && ev.method.toUpperCase() === "POST" ) { searchResponses200 += 1; const chain: SearchPlaceChain = { index: chains.length, phase: ev.phase, searchRequestTs: findRequestTs(events, i, ev.url, "POST") ?? ev.ts, searchResponseTs: ev.ts, searchStatus: 200, placeRequestTs: null, placeResponseTs: null, placeStatus: null, placeId: null, gapSearchResponseToPlaceRequestMs: null, gapPlaceRequestToPlaceResponseMs: null, complete: false, }; for (let j = i + 1; j < events.length; j += 1) { const next = events[j]!; if ( next.kind === "response" && next.isLocationSearch && next.method.toUpperCase() === "POST" ) { break; } if ( next.kind === "request" && next.isPlaceCommit && next.method.toUpperCase() === "GET" && chain.placeRequestTs === null ) { chain.placeRequestTs = next.ts; chain.placeId = extractPlaceId(next.url); if (chain.searchResponseTs) { chain.gapSearchResponseToPlaceRequestMs = next.ts - chain.searchResponseTs; } } if ( next.kind === "response" && next.isPlaceCommit && next.method.toUpperCase() === "GET" && chain.placeRequestTs !== null && chain.placeResponseTs === null ) { chain.placeResponseTs = next.ts; chain.placeStatus = next.status ?? null; if (chain.placeRequestTs) { chain.gapPlaceRequestToPlaceResponseMs = next.ts - chain.placeRequestTs; } chain.complete = next.status === 200; break; } } chains.push(chain); } if ( ev.kind === "response" && ev.isPlaceCommit && ev.status === 200 && ev.method.toUpperCase() === "GET" ) { placeGet200 += 1; } if ( ev.kind === "response" && ev.isQuote && ev.status === 200 && ev.method.toUpperCase() === "POST" ) { quotePost200 += 1; } } return { source, eventCount: events.length, searchResponses200, placeGet200, quotePost200, chains, }; } function findRequestTs( events: NetworkEvent[], responseIndex: number, url: string, method: string, ): number | null { for (let i = responseIndex; i >= 0; i -= 1) { const ev = events[i]!; if (ev.kind === "request" && ev.url === url && ev.method.toUpperCase() === method) { return ev.ts; } } return null; } export type NetworkDiffVerdict = { humanRunId: string; rpaRunId: string; human: NetworkSummary; rpa: NetworkSummary; gaps: string[]; verdict: "MATCH" | "RPA_MISSING_PLACE" | "RPA_PARTIAL" | "HUMAN_INCOMPLETE"; recommendations: string[]; }; export function diffHumanRpaNetwork( humanEvents: NetworkEvent[], rpaEvents: NetworkEvent[], humanRunId: string, rpaRunId: string, ): NetworkDiffVerdict { const human = summarizeNetwork(humanEvents, `human:${humanRunId}`); const rpa = summarizeNetwork(rpaEvents, `rpa:${rpaRunId}`); const gaps: string[] = []; const recommendations: string[] = []; if (human.placeGet200 < 2) { return { humanRunId, rpaRunId, human, rpa, gaps: ["真人基准 place GET 200 不足 2 次,无法对比"], verdict: "HUMAN_INCOMPLETE", recommendations: ["重新执行 record:human-commit-baseline"], }; } if (rpa.placeGet200 < human.placeGet200) { gaps.push( `RPA place GET 200: ${rpa.placeGet200},真人: ${human.placeGet200}`, ); } const humanComplete = human.chains.filter((c) => c.complete).length; const rpaComplete = rpa.chains.filter((c) => c.complete).length; if (rpaComplete < humanComplete) { gaps.push( `search→place 完整链: RPA ${rpaComplete}/${human.chains.length},真人 ${humanComplete}/${human.chains.length}`, ); } for (let i = 0; i < human.chains.length; i += 1) { const h = human.chains[i]!; const r = rpa.chains[i]; const label = `第${i + 1}次(search phase=${h.phase})`; if (!r) { gaps.push(`${label}: RPA 无对应 search 链`); continue; } if (h.complete && !r.complete) { gaps.push( `${label}: 真人有 place 200 (gap ${h.gapSearchResponseToPlaceRequestMs}ms),RPA 无 place 请求`, ); } else if (h.complete && r.complete && h.gapSearchResponseToPlaceRequestMs && r.gapSearchResponseToPlaceRequestMs) { const delta = Math.abs( r.gapSearchResponseToPlaceRequestMs - h.gapSearchResponseToPlaceRequestMs, ); if (delta > 3000) { gaps.push( `${label}: search→place 延迟差 ${delta}ms (人 ${h.gapSearchResponseToPlaceRequestMs}ms / RPA ${r.gapSearchResponseToPlaceRequestMs}ms)`, ); } } } if (human.quotePost200 > 0 && rpa.quotePost200 === 0) { gaps.push("真人有 axel/quote 200,RPA 本次未触发 quote(可能地址未 commit)"); } let verdict: NetworkDiffVerdict["verdict"] = "MATCH"; if (rpa.placeGet200 === 0) { verdict = "RPA_MISSING_PLACE"; recommendations.push( "RPA 仅有 search 无 place:点选未触发 widget 内部 handler,对照 trace 复刻鼠标点击联想行", ); recommendations.push("禁止新增 fix-place/CDP 策略;改 mothership-address-adapter 点选目标"); } else if (rpaComplete < humanComplete) { verdict = "RPA_PARTIAL"; recommendations.push("部分侧 place 成功:对比成功侧与失败侧 adapter 日志 method/events"); } if (gaps.length === 0) { recommendations.push("网络链路与真人一致,若仍 assert 失败则查 DOM 双地址态非网络层"); } return { humanRunId, rpaRunId, human, rpa, gaps, verdict, recommendations, }; }