989 lines
31 KiB
TypeScript
989 lines
31 KiB
TypeScript
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
|
import { BaseBrowserToolExecutor } from '../base-browser';
|
|
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
|
import { LIMITS, NETWORK_FILTERS } from '@/common/constants';
|
|
|
|
// Static resource file extensions
|
|
const STATIC_RESOURCE_EXTENSIONS = [
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.png',
|
|
'.gif',
|
|
'.svg',
|
|
'.webp',
|
|
'.ico',
|
|
'.bmp', // Images
|
|
'.css',
|
|
'.scss',
|
|
'.less', // Styles
|
|
'.js',
|
|
'.jsx',
|
|
'.ts',
|
|
'.tsx', // Scripts
|
|
'.woff',
|
|
'.woff2',
|
|
'.ttf',
|
|
'.eot',
|
|
'.otf', // Fonts
|
|
'.mp3',
|
|
'.mp4',
|
|
'.avi',
|
|
'.mov',
|
|
'.wmv',
|
|
'.flv',
|
|
'.ogg',
|
|
'.wav', // Media
|
|
'.pdf',
|
|
'.doc',
|
|
'.docx',
|
|
'.xls',
|
|
'.xlsx',
|
|
'.ppt',
|
|
'.pptx', // Documents
|
|
];
|
|
|
|
// Ad and analytics domain list
|
|
const AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS;
|
|
|
|
interface NetworkCaptureStartToolParams {
|
|
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
|
|
maxCaptureTime?: number; // Maximum capture time (milliseconds)
|
|
inactivityTimeout?: number; // Inactivity timeout (milliseconds)
|
|
includeStatic?: boolean; // Whether to include static resources
|
|
}
|
|
|
|
interface NetworkRequestInfo {
|
|
requestId: string;
|
|
url: string;
|
|
method: string;
|
|
type: string;
|
|
requestTime: number;
|
|
requestHeaders?: Record<string, string>;
|
|
requestBody?: string;
|
|
responseHeaders?: Record<string, string>;
|
|
responseTime?: number;
|
|
status?: number;
|
|
statusText?: string;
|
|
responseSize?: number;
|
|
responseType?: string;
|
|
responseBody?: string;
|
|
errorText?: string;
|
|
specificRequestHeaders?: Record<string, string>;
|
|
specificResponseHeaders?: Record<string, string>;
|
|
mimeType?: string; // Response MIME type
|
|
}
|
|
|
|
interface CaptureInfo {
|
|
tabId: number;
|
|
tabUrl: string;
|
|
tabTitle: string;
|
|
startTime: number;
|
|
endTime?: number;
|
|
requests: Record<string, NetworkRequestInfo>;
|
|
maxCaptureTime: number;
|
|
inactivityTimeout: number;
|
|
includeStatic: boolean;
|
|
limitReached?: boolean; // Whether request count limit is reached
|
|
}
|
|
|
|
/**
|
|
* Network Capture Start Tool V2 - Uses Chrome webRequest API to start capturing network requests
|
|
*/
|
|
class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
|
|
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START;
|
|
public static instance: NetworkCaptureStartTool | null = null;
|
|
public captureData: Map<number, CaptureInfo> = new Map(); // tabId -> capture data
|
|
private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
|
|
private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
|
|
private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last activity
|
|
private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests
|
|
public static MAX_REQUESTS_PER_CAPTURE = LIMITS.MAX_NETWORK_REQUESTS; // Maximum capture request count
|
|
private listeners: { [key: string]: (details: any) => void } = {};
|
|
|
|
// Static resource MIME types list (for filtering)
|
|
private static STATIC_MIME_TYPES_TO_FILTER = [
|
|
'image/', // All image types
|
|
'font/', // All font types
|
|
'audio/', // All audio types
|
|
'video/', // All video types
|
|
'text/css',
|
|
'text/javascript',
|
|
'application/javascript',
|
|
'application/x-javascript',
|
|
'application/pdf',
|
|
'application/zip',
|
|
'application/octet-stream', // Usually for downloads or generic binary data
|
|
];
|
|
|
|
// API response MIME types list (these types are usually not filtered)
|
|
private static API_MIME_TYPES = [
|
|
'application/json',
|
|
'application/xml',
|
|
'text/xml',
|
|
'application/x-www-form-urlencoded',
|
|
'application/graphql',
|
|
'application/grpc',
|
|
'application/protobuf',
|
|
'application/x-protobuf',
|
|
'application/x-json',
|
|
'application/ld+json',
|
|
'application/problem+json',
|
|
'application/problem+xml',
|
|
'application/soap+xml',
|
|
'application/vnd.api+json',
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
if (NetworkCaptureStartTool.instance) {
|
|
return NetworkCaptureStartTool.instance;
|
|
}
|
|
NetworkCaptureStartTool.instance = this;
|
|
|
|
// Listen for tab close events
|
|
chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
|
|
// Listen for tab creation events
|
|
chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Handle tab close events
|
|
*/
|
|
private handleTabRemoved(tabId: number) {
|
|
if (this.captureData.has(tabId)) {
|
|
console.log(`NetworkCaptureV2: Tab ${tabId} was closed, cleaning up resources.`);
|
|
this.cleanupCapture(tabId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle tab creation events
|
|
* If a new tab is opened from a tab being captured, automatically start capturing the new tab's requests
|
|
*/
|
|
private async handleTabCreated(tab: chrome.tabs.Tab) {
|
|
try {
|
|
// Check if there are any tabs currently capturing
|
|
if (this.captureData.size === 0) return;
|
|
|
|
// Get the openerTabId of the new tab (ID of the tab that opened this tab)
|
|
const openerTabId = tab.openerTabId;
|
|
if (!openerTabId) return;
|
|
|
|
// Check if the opener tab is currently capturing
|
|
if (!this.captureData.has(openerTabId)) return;
|
|
|
|
// Get the new tab's ID
|
|
const newTabId = tab.id;
|
|
if (!newTabId) return;
|
|
|
|
console.log(
|
|
`NetworkCaptureV2: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,
|
|
);
|
|
|
|
// Get the opener tab's capture settings
|
|
const openerCaptureInfo = this.captureData.get(openerTabId);
|
|
if (!openerCaptureInfo) return;
|
|
|
|
// Wait a short time to ensure the tab is ready
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
// Start capturing requests for the new tab
|
|
await this.startCaptureForTab(newTabId, {
|
|
maxCaptureTime: openerCaptureInfo.maxCaptureTime,
|
|
inactivityTimeout: openerCaptureInfo.inactivityTimeout,
|
|
includeStatic: openerCaptureInfo.includeStatic,
|
|
});
|
|
|
|
console.log(`NetworkCaptureV2: Successfully extended capture to new tab ${newTabId}`);
|
|
} catch (error) {
|
|
console.error(`NetworkCaptureV2: Error extending capture to new tab:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine whether a request should be filtered (based on URL)
|
|
*/
|
|
private shouldFilterRequest(url: string, includeStatic: boolean): boolean {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
|
|
// Check if it's an ad or analytics domain
|
|
if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) {
|
|
console.log(`NetworkCaptureV2: Filtering ad/analytics domain: ${urlObj.hostname}`);
|
|
return true;
|
|
}
|
|
|
|
// If not including static resources, check extensions
|
|
if (!includeStatic) {
|
|
const path = urlObj.pathname.toLowerCase();
|
|
if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
|
|
console.log(`NetworkCaptureV2: Filtering static resource by extension: ${path}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (e) {
|
|
console.error('NetworkCaptureV2: Error filtering URL:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter based on MIME type
|
|
*/
|
|
private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
|
|
if (!mimeType) return false;
|
|
|
|
// Always keep API response types
|
|
if (NetworkCaptureStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
|
|
return false;
|
|
}
|
|
|
|
// If not including static resources, filter out static resource MIME types
|
|
if (!includeStatic) {
|
|
// Filter static resource MIME types
|
|
if (
|
|
NetworkCaptureStartTool.STATIC_MIME_TYPES_TO_FILTER.some((type) =>
|
|
mimeType.startsWith(type),
|
|
)
|
|
) {
|
|
console.log(`NetworkCaptureV2: Filtering static resource by MIME type: ${mimeType}`);
|
|
return true;
|
|
}
|
|
|
|
// Filter all MIME types starting with text/ (except those already in API_MIME_TYPES)
|
|
if (mimeType.startsWith('text/')) {
|
|
console.log(`NetworkCaptureV2: Filtering text response: ${mimeType}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Update last activity time and reset inactivity timer
|
|
*/
|
|
private updateLastActivityTime(tabId: number): void {
|
|
const captureInfo = this.captureData.get(tabId);
|
|
if (!captureInfo) return;
|
|
|
|
this.lastActivityTime.set(tabId, Date.now());
|
|
|
|
// Reset inactivity timer
|
|
if (this.inactivityTimers.has(tabId)) {
|
|
clearTimeout(this.inactivityTimers.get(tabId)!);
|
|
}
|
|
|
|
if (captureInfo.inactivityTimeout > 0) {
|
|
this.inactivityTimers.set(
|
|
tabId,
|
|
setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for inactivity
|
|
*/
|
|
private checkInactivity(tabId: number): void {
|
|
const captureInfo = this.captureData.get(tabId);
|
|
if (!captureInfo) return;
|
|
|
|
const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime;
|
|
const now = Date.now();
|
|
const inactiveTime = now - lastActivity;
|
|
|
|
if (inactiveTime >= captureInfo.inactivityTimeout) {
|
|
console.log(
|
|
`NetworkCaptureV2: No activity for ${inactiveTime}ms, stopping capture for tab ${tabId}`,
|
|
);
|
|
this.stopCaptureByInactivity(tabId);
|
|
} else {
|
|
// If inactivity time hasn't been reached yet, continue checking
|
|
const remainingTime = captureInfo.inactivityTimeout - inactiveTime;
|
|
this.inactivityTimers.set(
|
|
tabId,
|
|
setTimeout(() => this.checkInactivity(tabId), remainingTime),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop capture due to inactivity
|
|
*/
|
|
private async stopCaptureByInactivity(tabId: number): Promise<void> {
|
|
const captureInfo = this.captureData.get(tabId);
|
|
if (!captureInfo) return;
|
|
|
|
console.log(`NetworkCaptureV2: Stopping capture due to inactivity for tab ${tabId}`);
|
|
await this.stopCapture(tabId);
|
|
}
|
|
|
|
/**
|
|
* Clean up capture resources
|
|
*/
|
|
private cleanupCapture(tabId: number): void {
|
|
// Clear timers
|
|
if (this.captureTimers.has(tabId)) {
|
|
clearTimeout(this.captureTimers.get(tabId)!);
|
|
this.captureTimers.delete(tabId);
|
|
}
|
|
|
|
if (this.inactivityTimers.has(tabId)) {
|
|
clearTimeout(this.inactivityTimers.get(tabId)!);
|
|
this.inactivityTimers.delete(tabId);
|
|
}
|
|
|
|
// Remove data
|
|
this.lastActivityTime.delete(tabId);
|
|
this.captureData.delete(tabId);
|
|
this.requestCounters.delete(tabId);
|
|
|
|
console.log(`NetworkCaptureV2: Cleaned up all resources for tab ${tabId}`);
|
|
}
|
|
|
|
/**
|
|
* Set up request listeners
|
|
*/
|
|
private setupListeners(): void {
|
|
// Before request is sent
|
|
this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {
|
|
const captureInfo = this.captureData.get(details.tabId);
|
|
if (!captureInfo) return;
|
|
|
|
if (this.shouldFilterRequest(details.url, captureInfo.includeStatic)) {
|
|
return;
|
|
}
|
|
|
|
const currentCount = this.requestCounters.get(details.tabId) || 0;
|
|
if (currentCount >= NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE) {
|
|
console.log(
|
|
`NetworkCaptureV2: Request limit (${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${details.tabId}, ignoring new request: ${details.url}`,
|
|
);
|
|
captureInfo.limitReached = true;
|
|
return;
|
|
}
|
|
|
|
this.requestCounters.set(details.tabId, currentCount + 1);
|
|
this.updateLastActivityTime(details.tabId);
|
|
|
|
if (!captureInfo.requests[details.requestId]) {
|
|
captureInfo.requests[details.requestId] = {
|
|
requestId: details.requestId,
|
|
url: details.url,
|
|
method: details.method,
|
|
type: details.type,
|
|
requestTime: details.timeStamp,
|
|
};
|
|
|
|
if (details.requestBody) {
|
|
const requestBody = this.processRequestBody(details.requestBody);
|
|
if (requestBody) {
|
|
captureInfo.requests[details.requestId].requestBody = requestBody;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`NetworkCaptureV2: Captured request ${currentCount + 1}/${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE} for tab ${details.tabId}: ${details.method} ${details.url}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
// Send request headers
|
|
this.listeners.onSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => {
|
|
const captureInfo = this.captureData.get(details.tabId);
|
|
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
|
|
|
if (details.requestHeaders) {
|
|
const headers: Record<string, string> = {};
|
|
details.requestHeaders.forEach((header) => {
|
|
headers[header.name] = header.value || '';
|
|
});
|
|
captureInfo.requests[details.requestId].requestHeaders = headers;
|
|
}
|
|
};
|
|
|
|
// Receive response headers
|
|
this.listeners.onHeadersReceived = (details: chrome.webRequest.WebResponseHeadersDetails) => {
|
|
const captureInfo = this.captureData.get(details.tabId);
|
|
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
|
|
|
const requestInfo = captureInfo.requests[details.requestId];
|
|
|
|
requestInfo.status = details.statusCode;
|
|
requestInfo.statusText = details.statusLine;
|
|
requestInfo.responseTime = details.timeStamp;
|
|
requestInfo.mimeType = details.responseHeaders?.find(
|
|
(h) => h.name.toLowerCase() === 'content-type',
|
|
)?.value;
|
|
|
|
// Secondary filtering based on MIME type
|
|
if (
|
|
requestInfo.mimeType &&
|
|
this.shouldFilterByMimeType(requestInfo.mimeType, captureInfo.includeStatic)
|
|
) {
|
|
delete captureInfo.requests[details.requestId];
|
|
|
|
const currentCount = this.requestCounters.get(details.tabId) || 0;
|
|
if (currentCount > 0) {
|
|
this.requestCounters.set(details.tabId, currentCount - 1);
|
|
}
|
|
|
|
console.log(
|
|
`NetworkCaptureV2: Filtered request by MIME type (${requestInfo.mimeType}): ${requestInfo.url}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (details.responseHeaders) {
|
|
const headers: Record<string, string> = {};
|
|
details.responseHeaders.forEach((header) => {
|
|
headers[header.name] = header.value || '';
|
|
});
|
|
requestInfo.responseHeaders = headers;
|
|
}
|
|
|
|
this.updateLastActivityTime(details.tabId);
|
|
};
|
|
|
|
// Request completed
|
|
this.listeners.onCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => {
|
|
const captureInfo = this.captureData.get(details.tabId);
|
|
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
|
|
|
const requestInfo = captureInfo.requests[details.requestId];
|
|
if ('responseSize' in details) {
|
|
requestInfo.responseSize = details.fromCache ? 0 : (details as any).responseSize;
|
|
}
|
|
|
|
this.updateLastActivityTime(details.tabId);
|
|
};
|
|
|
|
// Request failed
|
|
this.listeners.onErrorOccurred = (details: chrome.webRequest.WebResponseErrorDetails) => {
|
|
const captureInfo = this.captureData.get(details.tabId);
|
|
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
|
|
|
const requestInfo = captureInfo.requests[details.requestId];
|
|
requestInfo.errorText = details.error;
|
|
|
|
this.updateLastActivityTime(details.tabId);
|
|
};
|
|
|
|
// Register all listeners
|
|
chrome.webRequest.onBeforeRequest.addListener(
|
|
this.listeners.onBeforeRequest,
|
|
{ urls: ['<all_urls>'] },
|
|
['requestBody'],
|
|
);
|
|
|
|
chrome.webRequest.onSendHeaders.addListener(
|
|
this.listeners.onSendHeaders,
|
|
{ urls: ['<all_urls>'] },
|
|
['requestHeaders'],
|
|
);
|
|
|
|
chrome.webRequest.onHeadersReceived.addListener(
|
|
this.listeners.onHeadersReceived,
|
|
{ urls: ['<all_urls>'] },
|
|
['responseHeaders'],
|
|
);
|
|
|
|
chrome.webRequest.onCompleted.addListener(this.listeners.onCompleted, { urls: ['<all_urls>'] });
|
|
|
|
chrome.webRequest.onErrorOccurred.addListener(this.listeners.onErrorOccurred, {
|
|
urls: ['<all_urls>'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove all request listeners
|
|
* Only remove listeners when all tab captures have stopped
|
|
*/
|
|
private removeListeners(): void {
|
|
// Don't remove listeners if there are still tabs being captured
|
|
if (this.captureData.size > 0) {
|
|
console.log(
|
|
`NetworkCaptureV2: Still capturing on ${this.captureData.size} tabs, not removing listeners.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`NetworkCaptureV2: No more active captures, removing all listeners.`);
|
|
|
|
if (this.listeners.onBeforeRequest) {
|
|
chrome.webRequest.onBeforeRequest.removeListener(this.listeners.onBeforeRequest);
|
|
}
|
|
|
|
if (this.listeners.onSendHeaders) {
|
|
chrome.webRequest.onSendHeaders.removeListener(this.listeners.onSendHeaders);
|
|
}
|
|
|
|
if (this.listeners.onHeadersReceived) {
|
|
chrome.webRequest.onHeadersReceived.removeListener(this.listeners.onHeadersReceived);
|
|
}
|
|
|
|
if (this.listeners.onCompleted) {
|
|
chrome.webRequest.onCompleted.removeListener(this.listeners.onCompleted);
|
|
}
|
|
|
|
if (this.listeners.onErrorOccurred) {
|
|
chrome.webRequest.onErrorOccurred.removeListener(this.listeners.onErrorOccurred);
|
|
}
|
|
|
|
// Clear listener object
|
|
this.listeners = {};
|
|
}
|
|
|
|
/**
|
|
* Process request body data
|
|
*/
|
|
private processRequestBody(requestBody: chrome.webRequest.WebRequestBody): string | undefined {
|
|
if (requestBody.raw && requestBody.raw.length > 0) {
|
|
return '[Binary data]';
|
|
} else if (requestBody.formData) {
|
|
return JSON.stringify(requestBody.formData);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Start network request capture for specified tab
|
|
* @param tabId Tab ID
|
|
* @param options Capture options
|
|
*/
|
|
private async startCaptureForTab(
|
|
tabId: number,
|
|
options: {
|
|
maxCaptureTime: number;
|
|
inactivityTimeout: number;
|
|
includeStatic: boolean;
|
|
},
|
|
): Promise<void> {
|
|
const { maxCaptureTime, inactivityTimeout, includeStatic } = options;
|
|
|
|
// If already capturing, stop first
|
|
if (this.captureData.has(tabId)) {
|
|
console.log(
|
|
`NetworkCaptureV2: Already capturing on tab ${tabId}. Stopping previous session.`,
|
|
);
|
|
await this.stopCapture(tabId);
|
|
}
|
|
|
|
try {
|
|
// Get tab information
|
|
const tab = await chrome.tabs.get(tabId);
|
|
|
|
// Initialize capture data
|
|
this.captureData.set(tabId, {
|
|
tabId: tabId,
|
|
tabUrl: tab.url || '',
|
|
tabTitle: tab.title || '',
|
|
startTime: Date.now(),
|
|
requests: {},
|
|
maxCaptureTime,
|
|
inactivityTimeout,
|
|
includeStatic,
|
|
limitReached: false,
|
|
});
|
|
|
|
// Initialize request counter
|
|
this.requestCounters.set(tabId, 0);
|
|
|
|
// Set up listeners
|
|
this.setupListeners();
|
|
|
|
// Update last activity time
|
|
this.updateLastActivityTime(tabId);
|
|
|
|
console.log(
|
|
`NetworkCaptureV2: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,
|
|
);
|
|
|
|
// Set maximum capture time
|
|
if (maxCaptureTime > 0) {
|
|
this.captureTimers.set(
|
|
tabId,
|
|
setTimeout(async () => {
|
|
console.log(
|
|
`NetworkCaptureV2: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,
|
|
);
|
|
await this.stopCapture(tabId);
|
|
}, maxCaptureTime),
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`NetworkCaptureV2: Error starting capture for tab ${tabId}:`, error);
|
|
|
|
// Clean up resources
|
|
if (this.captureData.has(tabId)) {
|
|
this.cleanupCapture(tabId);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop capture
|
|
* @param tabId Tab ID
|
|
*/
|
|
public async stopCapture(
|
|
tabId: number,
|
|
): Promise<{ success: boolean; message?: string; data?: any }> {
|
|
const captureInfo = this.captureData.get(tabId);
|
|
if (!captureInfo) {
|
|
console.log(`NetworkCaptureV2: No capture in progress for tab ${tabId}`);
|
|
return { success: false, message: `No capture in progress for tab ${tabId}` };
|
|
}
|
|
|
|
try {
|
|
// Record end time
|
|
captureInfo.endTime = Date.now();
|
|
|
|
// Extract common request and response headers
|
|
const requestsArray = Object.values(captureInfo.requests);
|
|
const commonRequestHeaders = this.analyzeCommonHeaders(requestsArray, 'requestHeaders');
|
|
const commonResponseHeaders = this.analyzeCommonHeaders(requestsArray, 'responseHeaders');
|
|
|
|
// Process request data, remove common headers
|
|
const processedRequests = requestsArray.map((req) => {
|
|
const finalReq: NetworkRequestInfo = { ...req };
|
|
|
|
if (finalReq.requestHeaders) {
|
|
finalReq.specificRequestHeaders = this.filterOutCommonHeaders(
|
|
finalReq.requestHeaders,
|
|
commonRequestHeaders,
|
|
);
|
|
delete finalReq.requestHeaders;
|
|
} else {
|
|
finalReq.specificRequestHeaders = {};
|
|
}
|
|
|
|
if (finalReq.responseHeaders) {
|
|
finalReq.specificResponseHeaders = this.filterOutCommonHeaders(
|
|
finalReq.responseHeaders,
|
|
commonResponseHeaders,
|
|
);
|
|
delete finalReq.responseHeaders;
|
|
} else {
|
|
finalReq.specificResponseHeaders = {};
|
|
}
|
|
|
|
return finalReq;
|
|
});
|
|
|
|
// Sort by time
|
|
processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));
|
|
|
|
// Remove listeners
|
|
this.removeListeners();
|
|
|
|
// Prepare result data
|
|
const resultData = {
|
|
captureStartTime: captureInfo.startTime,
|
|
captureEndTime: captureInfo.endTime,
|
|
totalDurationMs: captureInfo.endTime - captureInfo.startTime,
|
|
settingsUsed: {
|
|
maxCaptureTime: captureInfo.maxCaptureTime,
|
|
inactivityTimeout: captureInfo.inactivityTimeout,
|
|
includeStatic: captureInfo.includeStatic,
|
|
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
|
|
},
|
|
commonRequestHeaders,
|
|
commonResponseHeaders,
|
|
requests: processedRequests,
|
|
requestCount: processedRequests.length,
|
|
totalRequestsReceived: this.requestCounters.get(tabId) || 0,
|
|
requestLimitReached: captureInfo.limitReached || false,
|
|
tabUrl: captureInfo.tabUrl,
|
|
tabTitle: captureInfo.tabTitle,
|
|
};
|
|
|
|
// Clean up resources
|
|
this.cleanupCapture(tabId);
|
|
|
|
return {
|
|
success: true,
|
|
data: resultData,
|
|
};
|
|
} catch (error: any) {
|
|
console.error(`NetworkCaptureV2: Error stopping capture for tab ${tabId}:`, error);
|
|
|
|
// Ensure resources are cleaned up
|
|
this.cleanupCapture(tabId);
|
|
|
|
return {
|
|
success: false,
|
|
message: `Error stopping capture: ${error.message || String(error)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyze common request or response headers
|
|
*/
|
|
private analyzeCommonHeaders(
|
|
requests: NetworkRequestInfo[],
|
|
headerType: 'requestHeaders' | 'responseHeaders',
|
|
): Record<string, string> {
|
|
if (!requests || requests.length === 0) return {};
|
|
|
|
// Find headers that are included in all requests
|
|
const commonHeaders: Record<string, string> = {};
|
|
const firstRequestWithHeaders = requests.find(
|
|
(req) => req[headerType] && Object.keys(req[headerType] || {}).length > 0,
|
|
);
|
|
|
|
if (!firstRequestWithHeaders || !firstRequestWithHeaders[headerType]) {
|
|
return {};
|
|
}
|
|
|
|
// Get all headers from the first request
|
|
const headers = firstRequestWithHeaders[headerType] as Record<string, string>;
|
|
const headerNames = Object.keys(headers);
|
|
|
|
// Check if each header exists in all requests with the same value
|
|
for (const name of headerNames) {
|
|
const value = headers[name];
|
|
const isCommon = requests.every((req) => {
|
|
const reqHeaders = req[headerType] as Record<string, string>;
|
|
return reqHeaders && reqHeaders[name] === value;
|
|
});
|
|
|
|
if (isCommon) {
|
|
commonHeaders[name] = value;
|
|
}
|
|
}
|
|
|
|
return commonHeaders;
|
|
}
|
|
|
|
/**
|
|
* Filter out common headers
|
|
*/
|
|
private filterOutCommonHeaders(
|
|
headers: Record<string, string>,
|
|
commonHeaders: Record<string, string>,
|
|
): Record<string, string> {
|
|
if (!headers || typeof headers !== 'object') return {};
|
|
|
|
const specificHeaders: Record<string, string> = {};
|
|
// Use Object.keys to avoid ESLint no-prototype-builtins warning
|
|
Object.keys(headers).forEach((name) => {
|
|
if (!(name in commonHeaders) || headers[name] !== commonHeaders[name]) {
|
|
specificHeaders[name] = headers[name];
|
|
}
|
|
});
|
|
|
|
return specificHeaders;
|
|
}
|
|
|
|
async execute(args: NetworkCaptureStartToolParams): Promise<ToolResult> {
|
|
const {
|
|
url: targetUrl,
|
|
maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes
|
|
inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop
|
|
includeStatic = false, // Default: don't include static resources
|
|
} = args;
|
|
|
|
console.log(`NetworkCaptureStartTool: Executing with args:`, args);
|
|
|
|
try {
|
|
// Get current tab or create new tab
|
|
let tabToOperateOn: chrome.tabs.Tab;
|
|
|
|
if (targetUrl) {
|
|
// Find tabs matching the URL
|
|
const matchingTabs = await chrome.tabs.query({ url: targetUrl });
|
|
|
|
if (matchingTabs.length > 0) {
|
|
// Use existing tab
|
|
tabToOperateOn = matchingTabs[0];
|
|
console.log(`NetworkCaptureV2: Found existing tab with URL: ${targetUrl}`);
|
|
} else {
|
|
// Create new tab
|
|
console.log(`NetworkCaptureV2: Creating new tab with URL: ${targetUrl}`);
|
|
tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });
|
|
|
|
// Wait for page to load
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
} else {
|
|
// Use current active tab
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (!tabs[0]) {
|
|
return createErrorResponse('No active tab found');
|
|
}
|
|
tabToOperateOn = tabs[0];
|
|
}
|
|
|
|
if (!tabToOperateOn?.id) {
|
|
return createErrorResponse('Failed to identify or create a tab');
|
|
}
|
|
|
|
// Use startCaptureForTab method to start capture
|
|
try {
|
|
await this.startCaptureForTab(tabToOperateOn.id, {
|
|
maxCaptureTime,
|
|
inactivityTimeout,
|
|
includeStatic,
|
|
});
|
|
} catch (error: any) {
|
|
return createErrorResponse(
|
|
`Failed to start capture for tab ${tabToOperateOn.id}: ${error.message || String(error)}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
message: 'Network capture V2 started successfully, waiting for stop command.',
|
|
tabId: tabToOperateOn.id,
|
|
url: tabToOperateOn.url,
|
|
maxCaptureTime,
|
|
inactivityTimeout,
|
|
includeStatic,
|
|
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
|
|
}),
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
} catch (error: any) {
|
|
console.error('NetworkCaptureStartTool: Critical error:', error);
|
|
return createErrorResponse(
|
|
`Error in NetworkCaptureStartTool: ${error.message || String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Network capture stop tool V2 - Stop webRequest API capture and return results
|
|
*/
|
|
class NetworkCaptureStopTool extends BaseBrowserToolExecutor {
|
|
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP;
|
|
public static instance: NetworkCaptureStopTool | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
if (NetworkCaptureStopTool.instance) {
|
|
return NetworkCaptureStopTool.instance;
|
|
}
|
|
NetworkCaptureStopTool.instance = this;
|
|
}
|
|
|
|
async execute(): Promise<ToolResult> {
|
|
console.log(`NetworkCaptureStopTool: Executing`);
|
|
|
|
try {
|
|
const startTool = NetworkCaptureStartTool.instance;
|
|
|
|
if (!startTool) {
|
|
return createErrorResponse('Network capture V2 start tool instance not found');
|
|
}
|
|
|
|
// Get all tabs currently capturing
|
|
const ongoingCaptures = Array.from(startTool.captureData.keys());
|
|
console.log(
|
|
`NetworkCaptureStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
|
|
);
|
|
|
|
if (ongoingCaptures.length === 0) {
|
|
return createErrorResponse('No active network captures found in any tab.');
|
|
}
|
|
|
|
// Get current active tab
|
|
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
const activeTabId = activeTabs[0]?.id;
|
|
|
|
// Determine the primary tab to stop
|
|
let primaryTabId: number;
|
|
|
|
if (activeTabId && startTool.captureData.has(activeTabId)) {
|
|
// If current active tab is capturing, prioritize stopping it
|
|
primaryTabId = activeTabId;
|
|
console.log(
|
|
`NetworkCaptureStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,
|
|
);
|
|
} else if (ongoingCaptures.length === 1) {
|
|
// If only one tab is capturing, stop it
|
|
primaryTabId = ongoingCaptures[0];
|
|
console.log(
|
|
`NetworkCaptureStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,
|
|
);
|
|
} else {
|
|
// If multiple tabs are capturing but current active tab is not among them, stop the first one
|
|
primaryTabId = ongoingCaptures[0];
|
|
console.log(
|
|
`NetworkCaptureStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,
|
|
);
|
|
}
|
|
|
|
const stopResult = await startTool.stopCapture(primaryTabId);
|
|
|
|
if (!stopResult.success) {
|
|
return createErrorResponse(
|
|
stopResult.message || `Failed to stop network capture for tab ${primaryTabId}`,
|
|
);
|
|
}
|
|
|
|
// If multiple tabs are capturing, stop other tabs
|
|
if (ongoingCaptures.length > 1) {
|
|
const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);
|
|
console.log(
|
|
`NetworkCaptureStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,
|
|
);
|
|
|
|
for (const tabId of otherTabIds) {
|
|
try {
|
|
await startTool.stopCapture(tabId);
|
|
} catch (error) {
|
|
console.error(`NetworkCaptureStopTool: Error stopping capture on tab ${tabId}:`, error);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`,
|
|
tabId: primaryTabId,
|
|
tabUrl: stopResult.data?.tabUrl || 'N/A',
|
|
tabTitle: stopResult.data?.tabTitle || 'Unknown Tab',
|
|
requestCount: stopResult.data?.requestCount || 0,
|
|
commonRequestHeaders: stopResult.data?.commonRequestHeaders || {},
|
|
commonResponseHeaders: stopResult.data?.commonResponseHeaders || {},
|
|
requests: stopResult.data?.requests || [],
|
|
captureStartTime: stopResult.data?.captureStartTime,
|
|
captureEndTime: stopResult.data?.captureEndTime,
|
|
totalDurationMs: stopResult.data?.totalDurationMs,
|
|
settingsUsed: stopResult.data?.settingsUsed || {},
|
|
totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0,
|
|
requestLimitReached: stopResult.data?.requestLimitReached || false,
|
|
remainingCaptures: Array.from(startTool.captureData.keys()),
|
|
}),
|
|
},
|
|
],
|
|
isError: false,
|
|
};
|
|
} catch (error: any) {
|
|
console.error('NetworkCaptureStopTool: Critical error:', error);
|
|
return createErrorResponse(
|
|
`Error in NetworkCaptureStopTool: ${error.message || String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const networkCaptureStartTool = new NetworkCaptureStartTool();
|
|
export const networkCaptureStopTool = new NetworkCaptureStopTool();
|