Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Fetch any URL via Chrome CDP and convert the rendered page to clean markdown with YouTube transcript support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/lib/browser/network-journal.ts
1import type { TargetSession } from "./cdp-client";2import type { Logger } from "../utils/logger";34type JsonObject = Record<string, unknown>;56export interface NetworkEntry {7requestId: string;8url: string;9method: string;10resourceType: string;11timestamp: number;12requestHeaders?: Record<string, string>;13requestBody?: string;14status?: number;15statusText?: string;16responseHeaders?: Record<string, string>;17mimeType?: string;18body?: string;19bodyBase64?: boolean;20bodyError?: string;21failed?: boolean;22failureReason?: string;23finished: boolean;24}2526function normalizeHeaders(headers: unknown): Record<string, string> | undefined {27if (!headers || typeof headers !== "object") {28return undefined;29}30return Object.fromEntries(31Object.entries(headers as Record<string, unknown>).map(([key, value]) => [key, String(value)]),32);33}3435function sleep(ms: number): Promise<void> {36return new Promise((resolve) => setTimeout(resolve, ms));37}3839export class NetworkJournal {40private readonly entries = new Map<string, NetworkEntry>();41private lastActivityAt = Date.now();42private started = false;4344constructor(45private readonly session: TargetSession,46private readonly log: Logger,47) {}4849async start(): Promise<void> {50if (this.started) {51return;52}5354this.started = true;55this.session.on("Network.requestWillBeSent", this.handleRequestWillBeSent);56this.session.on("Network.responseReceived", this.handleResponseReceived);57this.session.on("Network.loadingFinished", this.handleLoadingFinished);58this.session.on("Network.loadingFailed", this.handleLoadingFailed);59await this.session.send("Network.enable");60}6162stop(): void {63if (!this.started) {64return;65}66this.session.off("Network.requestWillBeSent", this.handleRequestWillBeSent);67this.session.off("Network.responseReceived", this.handleResponseReceived);68this.session.off("Network.loadingFinished", this.handleLoadingFinished);69this.session.off("Network.loadingFailed", this.handleLoadingFailed);70this.started = false;71}7273private touch(): void {74this.lastActivityAt = Date.now();75}7677private readonly handleRequestWillBeSent = (params: JsonObject): void => {78const requestId = typeof params.requestId === "string" ? params.requestId : undefined;79const request = params.request as JsonObject | undefined;80if (!requestId || !request) {81return;82}8384this.touch();85this.entries.set(requestId, {86requestId,87url: String(request.url ?? ""),88method: String(request.method ?? "GET"),89resourceType: String(params.type ?? "Other"),90timestamp: Date.now(),91requestHeaders: normalizeHeaders(request.headers),92requestBody: typeof request.postData === "string" ? request.postData : undefined,93finished: false,94});95};9697private readonly handleResponseReceived = (params: JsonObject): void => {98const requestId = typeof params.requestId === "string" ? params.requestId : undefined;99const response = params.response as JsonObject | undefined;100if (!requestId || !response) {101return;102}103104this.touch();105const existing = this.entries.get(requestId);106if (!existing) {107return;108}109110existing.status = typeof response.status === "number" ? response.status : undefined;111existing.statusText = typeof response.statusText === "string" ? response.statusText : undefined;112existing.responseHeaders = normalizeHeaders(response.headers);113existing.mimeType = typeof response.mimeType === "string" ? response.mimeType : undefined;114this.entries.set(requestId, existing);115};116117private readonly handleLoadingFinished = (params: JsonObject): void => {118const requestId = typeof params.requestId === "string" ? params.requestId : undefined;119if (!requestId) {120return;121}122123this.touch();124const existing = this.entries.get(requestId);125if (!existing) {126return;127}128existing.finished = true;129this.entries.set(requestId, existing);130};131132private readonly handleLoadingFailed = (params: JsonObject): void => {133const requestId = typeof params.requestId === "string" ? params.requestId : undefined;134if (!requestId) {135return;136}137138this.touch();139const existing = this.entries.get(requestId);140if (!existing) {141return;142}143existing.finished = true;144existing.failed = true;145existing.failureReason = typeof params.errorText === "string" ? params.errorText : "Unknown error";146this.entries.set(requestId, existing);147};148149getEntries(): NetworkEntry[] {150return Array.from(this.entries.values());151}152153findEntries(predicate: (entry: NetworkEntry) => boolean): NetworkEntry[] {154return this.getEntries().filter(predicate);155}156157async waitForIdle(options: { idleMs?: number; timeoutMs?: number } = {}): Promise<void> {158const idleMs = options.idleMs ?? 1_200;159const timeoutMs = options.timeoutMs ?? 15_000;160const startedAt = Date.now();161162while (Date.now() - startedAt < timeoutMs) {163if (Date.now() - this.lastActivityAt >= idleMs) {164return;165}166await sleep(Math.min(150, idleMs));167}168169throw new Error("Timed out waiting for network idle");170}171172async waitForResponse(173predicate: (entry: NetworkEntry) => boolean,174options: { timeoutMs?: number } = {},175): Promise<NetworkEntry> {176const timeoutMs = options.timeoutMs ?? 10_000;177const startedAt = Date.now();178179while (Date.now() - startedAt < timeoutMs) {180const matched = this.getEntries().find((entry) => entry.finished && predicate(entry));181if (matched) {182return matched;183}184await sleep(150);185}186187throw new Error("Timed out waiting for matching network response");188}189190async ensureBody(entry: NetworkEntry): Promise<string | undefined> {191if (entry.body !== undefined) {192return entry.body;193}194if (entry.bodyError || entry.failed || !entry.finished) {195return undefined;196}197198try {199const result = await this.session.send<{ body: string; base64Encoded: boolean }>("Network.getResponseBody", {200requestId: entry.requestId,201});202entry.bodyBase64 = result.base64Encoded;203entry.body = result.base64Encoded ? Buffer.from(result.body, "base64").toString("utf8") : result.body;204return entry.body;205} catch (error) {206entry.bodyError = error instanceof Error ? error.message : String(error);207this.log.debug(`Failed to fetch response body for ${entry.url}: ${entry.bodyError}`);208return undefined;209}210}211212async getJsonBody(entry: NetworkEntry): Promise<unknown | null> {213const body = await this.ensureBody(entry);214if (!body) {215return null;216}217218try {219return JSON.parse(body);220} catch {221return null;222}223}224225async toJSON(options: { includeBodies?: boolean } = {}): Promise<NetworkEntry[]> {226const entries = this.getEntries();227if (!options.includeBodies) {228return entries;229}230231await Promise.all(entries.map((entry) => this.ensureBody(entry)));232return entries;233}234}235236