import fs from "node:fs"; import type { Page } from "playwright"; export type NetworkEvent = { ts: number; iso: string; phase: string; kind: "request" | "response"; method: string; url: string; status?: number; resourceType?: string; isPlaceCommit: boolean; isLocationSearch: boolean; isQuote: boolean; }; export type CommitMoment = { ts: number; iso: string; url: string; status: number; method: string; msSinceRecordingStart: number; msSinceLastCheckpoint: number | null; checkpointPhase: string; }; export class NetworkLogger { private readonly events: NetworkEvent[] = []; private readonly placeCommits: CommitMoment[] = []; private recordingStartedAt = Date.now(); private lastCheckpointAt: number | null = null; private checkpointPhase = "recording_start"; constructor( private readonly outPath: string, private readonly onPlaceCommit?: (moment: CommitMoment) => void, ) { fs.mkdirSync(pathDir(outPath), { recursive: true }); } setCheckpoint(phase: string): void { this.checkpointPhase = phase; this.lastCheckpointAt = Date.now(); } attach(page: Page): void { page.on("request", (req) => { const url = req.url(); if (!this.isAxelRelevant(url)) { return; } this.append({ ts: Date.now(), iso: new Date().toISOString(), phase: this.checkpointPhase, kind: "request", method: req.method(), url: url.slice(0, 500), resourceType: req.resourceType(), isPlaceCommit: this.isPlaceCommitUrl(url), isLocationSearch: url.includes("/axel/location/search"), isQuote: url.includes("/axel/quote"), }); }); page.on("response", async (res) => { const url = res.url(); if (!this.isAxelRelevant(url)) { return; } const status = res.status(); this.append({ ts: Date.now(), iso: new Date().toISOString(), phase: this.checkpointPhase, kind: "response", method: res.request().method(), url: url.slice(0, 500), status, resourceType: res.request().resourceType(), isPlaceCommit: this.isPlaceCommitUrl(url), isLocationSearch: url.includes("/axel/location/search"), isQuote: url.includes("/axel/quote"), }); if ( this.isPlaceCommitUrl(url) && status === 200 && res.request().method().toUpperCase() === "GET" ) { const now = Date.now(); const moment: CommitMoment = { ts: now, iso: new Date(now).toISOString(), url: url.slice(0, 500), status, method: "GET", msSinceRecordingStart: now - this.recordingStartedAt, msSinceLastCheckpoint: this.lastCheckpointAt === null ? null : now - this.lastCheckpointAt, checkpointPhase: this.checkpointPhase, }; this.placeCommits.push(moment); this.onPlaceCommit?.(moment); console.log( `[录证] ★ place commit ${status} @ +${moment.msSinceRecordingStart}ms phase=${this.checkpointPhase}`, ); console.log(` ${url.slice(0, 120)}`); } }); } flush(): void { fs.writeFileSync(this.outPath, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8"); } getPlaceCommits(): CommitMoment[] { return [...this.placeCommits]; } private isAxelRelevant(url: string): boolean { return ( url.includes("services.mothership.com/axel/location") || url.includes("services.mothership.com/axel/quote") || url.includes("maps.googleapis.com") || url.includes("places.googleapis.com") ); } private isPlaceCommitUrl(url: string): boolean { return ( url.includes("/axel/location/place/") && !url.includes("reverse-geocode") ); } private append(event: NetworkEvent): void { this.events.push(event); fs.appendFileSync(this.outPath, `${JSON.stringify(event)}\n`, "utf8"); } } function pathDir(filePath: string): string { const idx = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); return idx >= 0 ? filePath.slice(0, idx) : filePath; }