|
|
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,
|
|
|
};
|
|
|
}
|