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.

277 lines
7.6 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 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 200RPA 本次未触发 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,
};
}