UNPKG

supapup

Version:

⚔ Lightning-fast MCP browser dev tool. Navigate → Get instant structured data. No screenshots needed! Puppeteer: šŸ“ø → CSS selectors → JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.

912 lines (911 loc) • 46.5 kB
import puppeteer from 'puppeteer-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; import { DevToolsAgentPageGenerator } from './../generators/devtools-agent-page-generator.js'; import { randomUUID } from 'crypto'; /** * Environment Variables for Browser Configuration: * * SUPAPUP_HEADLESS - Set to 'true' for headless mode (default: false - shows browser window) * SUPAPUP_DEBUG_PORT - Chrome remote debugging port (default: 9222) * SUPAPUP_DEVTOOLS - Set to 'true' to open DevTools (default: false) * * Example MCP config for headless server: * { * "mcpServers": { * "supapup": { * "command": "supapup", * "env": { * "SUPAPUP_HEADLESS": "true" * } * } * } * } */ // Apply stealth plugin puppeteer.use(StealthPlugin()); export class BrowserTools { browser = null; page = null; browserRecovery; currentManifest = null; forcedHeadless = null; // null = use env var, true/false = override agentPageChunkData = new Map(); constructor(browserRecovery) { this.browserRecovery = browserRecovery; } // Getters for external access getBrowser() { return this.browser; } getPage() { return this.page; } getCurrentManifest() { return this.currentManifest; } setCurrentManifest(manifest) { this.currentManifest = manifest; } // Called by main server after browser crash clearBrowserReferences() { this.browser = null; this.page = null; this.currentManifest = null; } async navigate(url, visible) { try { // Set visibility if provided if (visible !== undefined) { this.forcedHeadless = !visible; } // Launch browser if needed if (!this.browser) { // Environment variable configuration const headless = this.forcedHeadless !== null ? this.forcedHeadless : process.env.SUPAPUP_HEADLESS === 'true'; // Default false (visible browser) const debugPort = process.env.SUPAPUP_DEBUG_PORT || '9222'; const devtools = process.env.SUPAPUP_DEVTOOLS === 'true'; console.log(`šŸš€ Launching browser (headless: ${headless})...`); // Base arguments for all environments const baseArgs = [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-web-security', '--disable-features=VizDisplayCompositor', `--remote-debugging-port=${debugPort}` ]; // Additional arguments for headless environments const headlessArgs = [ '--no-zygote', '--single-process', '--disable-gpu', '--disable-dev-shm-usage' ]; // Arguments for non-headless (development) environments const devArgs = [ '--start-maximized' ]; this.browser = await puppeteer.launch({ headless, defaultViewport: null, devtools, args: [ ...baseArgs, ...(headless ? headlessArgs : devArgs) ] }); } // Create page if needed if (!this.page) { // Check if there's an existing blank page we can reuse const pages = await this.browser.pages(); const blankPage = pages.find(page => page.url() === 'about:blank' || page.url() === ''); if (blankPage) { this.page = blankPage; } else { this.page = await this.browser.newPage(); } await this.page.setViewport({ width: 1920, height: 1080 }); // Set user agent await this.page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); // Inject dialog handling script on every new document await this.page.evaluateOnNewDocument(() => { // Only inject if not already present if (typeof window.__MCP_DIALOGS_INSTALLED__ === 'undefined') { window.__MCP_DIALOGS_INSTALLED__ = true; // Helper functions for agents window.click_alert = function (index) { const alerts = document.querySelectorAll('[data-mcp-type="alert"]'); if (alerts[index - 1]) { const okBtn = alerts[index - 1].querySelector('[data-mcp-id="alert-ok"]'); if (okBtn) { okBtn.click(); return true; } } return false; }; window.fill_prompt = function (index, value) { const prompts = document.querySelectorAll('[data-mcp-type="prompt"]'); if (prompts[index - 1]) { const input = prompts[index - 1].querySelector('[data-mcp-id="prompt-input"]'); if (input) { // Use React-compatible value setting if (typeof window.__AGENT_PAGE__?.triggerReactChange === 'function') { window.__AGENT_PAGE__.triggerReactChange(input, value); } else { // Fallback for non-React forms input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } input.style.background = '#ffffaa'; return true; } } return false; }; window.click_prompt = function (index, accept) { const prompts = document.querySelectorAll('[data-mcp-type="prompt"]'); if (prompts[index - 1]) { const btn = accept ? prompts[index - 1].querySelector('[data-mcp-id="prompt-ok"]') : prompts[index - 1].querySelector('[data-mcp-id="prompt-cancel"]'); if (btn) { btn.click(); return true; } } return false; }; window.click_confirm = function (index, accept) { const confirms = document.querySelectorAll('[data-mcp-type="confirm"]'); if (confirms[index - 1]) { const btn = accept ? confirms[index - 1].querySelector('[data-mcp-id="confirm-ok"]') : confirms[index - 1].querySelector('[data-mcp-id="confirm-cancel"]'); if (btn) { btn.click(); return true; } } return false; }; window.list_dialogs = function () { const alerts = document.querySelectorAll('[data-mcp-type="alert"]'); const prompts = document.querySelectorAll('[data-mcp-type="prompt"]'); const confirms = document.querySelectorAll('[data-mcp-type="confirm"]'); return { alerts: alerts.length, prompts: prompts.length, confirms: confirms.length }; }; // Enhanced dialog overrides const originalAlert = window.alert; const originalConfirm = window.confirm; const originalPrompt = window.prompt; window.alert = function (message) { console.log('[MCP] Alert intercepted:', message); const alertNum = document.querySelectorAll('[data-mcp-type="alert"]').length + 1; const alertDiv = document.createElement('div'); alertDiv.id = 'mcp-alert-' + Date.now(); alertDiv.setAttribute('data-mcp-id', 'alert-dialog-' + alertNum); alertDiv.setAttribute('data-mcp-type', 'alert'); alertDiv.style.cssText = 'position:fixed;top:20px;right:20px;background:#ff4444;color:white;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:300px;font-family:monospace;'; alertDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;">🚨 Alert #' + alertNum + '</div><div style="margin-bottom:10px;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.8;">Agent: Use click_alert(' + alertNum + ') to dismiss</div><button data-mcp-id="alert-ok" onclick="this.parentElement.remove();" style="background:white;color:#ff4444;border:none;padding:5px 10px;border-radius:3px;">OK</button>'; document.body.appendChild(alertDiv); console.log('[MCP] Alert helper: click_alert(' + alertNum + ')'); return undefined; }; window.prompt = function (message, defaultValue) { console.log('[MCP] Prompt intercepted:', message); const promptNum = document.querySelectorAll('[data-mcp-type="prompt"]').length + 1; const promptDiv = document.createElement('div'); promptDiv.id = 'mcp-prompt-' + Date.now(); promptDiv.setAttribute('data-mcp-id', 'prompt-dialog-' + promptNum); promptDiv.setAttribute('data-mcp-type', 'prompt'); promptDiv.style.cssText = 'position:fixed;top:20px;left:20px;background:#44ff44;color:black;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:350px;font-family:monospace;'; promptDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;color:black;">šŸ“ Prompt #' + promptNum + '</div><div style="margin-bottom:10px;color:black;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.7;color:black;">Agent: fill_prompt(' + promptNum + ', "text") then click_prompt(' + promptNum + ', true)</div><input type="text" data-mcp-id="prompt-input" value="' + (defaultValue || '') + '" style="width:250px;padding:5px;margin-bottom:10px;border:1px solid #ccc;border-radius:3px;"><br><button data-mcp-id="prompt-ok" onclick="this.parentElement.remove();" style="background:#44ff44;color:white;border:none;padding:5px 10px;border-radius:3px;margin-right:5px;">OK</button><button data-mcp-id="prompt-cancel" onclick="this.parentElement.remove();" style="background:#ff4444;color:white;border:none;padding:5px 10px;border-radius:3px;">Cancel</button>'; document.body.appendChild(promptDiv); console.log('[MCP] Prompt helpers: fill_prompt(' + promptNum + ', "text"), click_prompt(' + promptNum + ', true/false)'); return null; }; window.confirm = function (message) { console.log('[MCP] Confirm intercepted:', message); const confirmNum = document.querySelectorAll('[data-mcp-type="confirm"]').length + 1; const confirmDiv = document.createElement('div'); confirmDiv.id = 'mcp-confirm-' + Date.now(); confirmDiv.setAttribute('data-mcp-id', 'confirm-dialog-' + confirmNum); confirmDiv.setAttribute('data-mcp-type', 'confirm'); confirmDiv.style.cssText = 'position:fixed;top:80px;right:20px;background:#4444ff;color:white;padding:15px;border-radius:5px;z-index:999999;box-shadow:0 4px 8px rgba(0,0,0,0.3);min-width:300px;font-family:monospace;'; confirmDiv.innerHTML = '<div style="margin-bottom:10px;font-weight:bold;">ā“ Confirm #' + confirmNum + '</div><div style="margin-bottom:10px;">' + message + '</div><div style="margin-bottom:10px;font-size:12px;opacity:0.8;">Agent: click_confirm(' + confirmNum + ', true) or click_confirm(' + confirmNum + ', false)</div><button data-mcp-id="confirm-ok" onclick="this.parentElement.remove();" style="background:white;color:#4444ff;border:none;padding:5px 10px;border-radius:3px;margin-right:5px;">OK</button><button data-mcp-id="confirm-cancel" onclick="this.parentElement.remove();" style="background:white;color:#4444ff;border:none;padding:5px 10px;border-radius:3px;">Cancel</button>'; document.body.appendChild(confirmDiv); console.log('[MCP] Confirm helper: click_confirm(' + confirmNum + ', true/false)'); return false; }; console.log('[MCP] Enhanced dialog overrides with helper functions installed'); } }); } console.log(`šŸ“ Navigating to: ${url}`); // Determine redirect limit based on URL const isLocalhost = url.includes('localhost') || url.includes('127.0.0.1') || url.includes('0.0.0.0'); const maxRedirects = isLocalhost ? 30 : 15; // Navigate with redirect tracking let redirectCount = 0; let currentUrl = url; const startTime = Date.now(); try { const response = await this.page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Track redirects while (this.page.url() !== currentUrl && redirectCount < maxRedirects) { redirectCount++; currentUrl = this.page.url(); console.log(`ā†Ŗļø Redirect ${redirectCount}: ${currentUrl}`); if (redirectCount >= maxRedirects) { throw new Error(`Too many redirects (${redirectCount}). Possible infinite redirect attack or complex SPA flow. URL: ${currentUrl}`); } } const finalUrl = this.page.url(); console.log(`āœ… Navigation completed. Final URL: ${finalUrl}`); console.log(`šŸ”„ Redirects: ${redirectCount}`); console.log(`ā±ļø Time: ${Date.now() - startTime}ms`); // Wait for JavaScript to complete and page to settle await new Promise(resolve => setTimeout(resolve, 2000)); // Additional check for common dynamic content patterns try { // Wait for common loading indicators to disappear await this.page.waitForFunction(() => { const loadingIndicators = document.querySelectorAll('.loading, .spinner, [data-loading="true"]'); return loadingIndicators.length === 0; }, { timeout: 5000 }); } catch (e) { // If loading indicators don't disappear in 5 seconds, continue anyway console.log('āš ļø Some loading indicators may still be present, continuing...'); } // Check for bot detection and CAPTCHAs const botCheckResult = await this.checkForBotDetection(); if (botCheckResult.hasIssues) { return this.generateCaptchaResponse(finalUrl, botCheckResult); } // Use DevToolsAgentPageGenerator directly const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: agentPage }] }; } catch (error) { console.error('āŒ Navigation error:', error.message); if (error.message.includes('Too many redirects')) { throw error; // Re-throw redirect errors } // Handle other navigation errors return { content: [{ type: 'text', text: `āŒ Navigation failed: ${error.message}\nšŸ“ URL: ${url}` }] }; } } catch (error) { console.error('āŒ Browser navigation error:', error); return { content: [{ type: 'text', text: `āŒ Navigation failed: ${error.message}\nšŸ“ URL: ${url}` }] }; } } async closeBrowser() { try { if (this.browser) { console.log('šŸ”š Closing browser...'); await this.browser.close(); this.browser = null; this.page = null; this.currentManifest = null; console.log('āœ… Browser closed successfully'); return { content: [{ type: 'text', text: 'āœ… Browser closed successfully' }] }; } else { return { content: [{ type: 'text', text: 'āš ļø No browser instance to close' }] }; } } catch (error) { console.error('āŒ Error closing browser:', error); return { content: [{ type: 'text', text: `āŒ Error closing browser: ${error.message}` }] }; } } async setBrowserVisibility(visible, restart = true) { try { const currentState = this.forcedHeadless !== null ? !this.forcedHeadless : process.env.SUPAPUP_HEADLESS !== 'true'; const newHeadless = !visible; // Set the forced headless state this.forcedHeadless = newHeadless; let responseText = ''; if (currentState === visible) { responseText = `šŸ”§ Browser visibility already set to ${visible ? 'visible' : 'headless'} mode`; } else { responseText = `šŸ”§ Browser visibility changed from ${currentState ? 'visible' : 'headless'} to ${visible ? 'visible' : 'headless'} mode`; } if (restart && this.browser) { responseText += '\n\nšŸ”„ Restarting browser to apply changes...'; // Close current browser await this.browser.close(); this.browser = null; this.page = null; this.currentManifest = null; responseText += '\nāœ… Browser will restart with new visibility settings on next navigation'; } else if (this.browser) { responseText += '\n\nāš ļø Browser is currently running. Use restart=true or navigate to a new page to apply changes.'; } else { responseText += '\n\nāœ… Settings will be applied when browser launches on next navigation'; } return { content: [{ type: 'text', text: responseText }] }; } catch (error) { return { content: [{ type: 'text', text: `āŒ Error setting browser visibility: ${error.message}` }] }; } } async openInTab(content, contentType = 'text/html', title) { try { if (!this.browser) { throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.'); } // Create a new page const newPage = await this.browser.newPage(); // Set title if provided if (title) { await newPage.evaluateOnNewDocument((title) => { document.title = title; }, title); } // Handle different content types if (contentType.startsWith('text/html')) { await newPage.setContent(content, { waitUntil: 'domcontentloaded' }); } else if (contentType.startsWith('text/')) { const htmlContent = ` <!DOCTYPE html> <html> <head> <title>${title || 'Content'}</title> <style> body { font-family: monospace; white-space: pre-wrap; padding: 20px; } </style> </head> <body>${content}</body> </html> `; await newPage.setContent(htmlContent, { waitUntil: 'domcontentloaded' }); } else { // For other content types, create a data URL const dataUrl = `data:${contentType};base64,${Buffer.from(content).toString('base64')}`; await newPage.goto(dataUrl); } const url = newPage.url(); console.log(`āœ… Opened content in new tab: ${url}`); return { content: [{ type: 'text', text: `āœ… Opened content in new tab\nšŸ“ URL: ${url}\nšŸ“‹ Content type: ${contentType}` }] }; } catch (error) { console.error('āŒ Error opening content in tab:', error); return { content: [{ type: 'text', text: `āŒ Error opening content in tab: ${error.message}` }] }; } } async listTabs() { try { if (!this.browser) { throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.'); } const pages = await this.browser.pages(); const tabList = await Promise.all(pages.map(async (page, index) => { try { const title = await page.title(); const url = page.url(); return `${index}: "${title}" - ${url}`; } catch (error) { return `${index}: [Error getting tab info] - ${error}`; } })); const response = `šŸ“‘ Open browser tabs (${tabList.length}):\n${tabList.join('\n')}`; return { content: [{ type: 'text', text: response }] }; } catch (error) { console.error('āŒ Error listing tabs:', error); return { content: [{ type: 'text', text: `āŒ Error listing tabs: ${error.message}` }] }; } } async switchTab(index) { try { if (!this.browser) { throw new Error('Browser not initialized. Use browser_navigate tool to open a webpage first, then try this operation again.'); } const pages = await this.browser.pages(); if (index < 0 || index >= pages.length) { throw new Error(`Invalid tab index: ${index}. Available tabs: 0-${pages.length - 1}. Use browser_list_tabs to see all open tabs and their indices.`); } const targetPage = pages[index]; await targetPage.bringToFront(); // Update current page reference this.page = targetPage; // Get tab info const title = await targetPage.title(); const url = targetPage.url(); console.log(`āœ… Switched to tab ${index}: "${title}"`); return { content: [{ type: 'text', text: `āœ… Switched to tab ${index}\nšŸ“‹ "${title}"\nšŸ“ ${url}` }] }; } catch (error) { console.error('āŒ Error switching tabs:', error); return { content: [{ type: 'text', text: `āŒ Error switching tabs: ${error.message}` }] }; } } // Private helper methods async checkForBotDetection() { if (!this.page) return { hasIssues: false }; try { const result = await this.page.evaluate(() => { // Check for visible CAPTCHAs only (not just presence of scripts) const captchaSelectors = [ '.g-recaptcha:not([style*="display: none"]):not([style*="display:none"])', '.h-captcha:not([style*="display: none"]):not([style*="display:none"])', '.cf-turnstile:not([style*="display: none"]):not([style*="display:none"])', 'iframe[src*="recaptcha"]:not([style*="display: none"]):not([style*="display:none"])', 'iframe[src*="hcaptcha"]:not([style*="display: none"]):not([style*="display:none"])', 'iframe[src*="turnstile"]:not([style*="display: none"]):not([style*="display:none"])' ]; let visibleCaptchas = 0; captchaSelectors.forEach(selector => { const elements = document.querySelectorAll(selector); elements.forEach(el => { const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0') { visibleCaptchas++; } }); }); // Check for blocking text patterns const bodyText = document.body.textContent || ''; const blockingPatterns = [ /please complete.*captcha/i, /verify.*you.*human/i, /prove.*you.*human/i, /security check/i, /access denied/i, /blocked.*suspicious/i ]; const hasBlockingText = blockingPatterns.some(pattern => pattern.test(bodyText)); return { visibleCaptchas, hasBlockingText, hasIssues: visibleCaptchas > 0 || hasBlockingText }; }); return result; } catch (error) { console.error('āŒ Bot detection check failed:', error); return { hasIssues: false }; } } generateCaptchaResponse(url, botCheckResult) { const text = `šŸ¤– Whoops! Seems like I am a robot... Any humans around?\n` + `āš ļø CAPTCHA/verification detected - need human help!\n\n` + `šŸ“ Current URL: ${url}\n\n` + `šŸ”’ Detection details:\n` + ` • Visible CAPTCHAs: ${botCheckResult.visibleCaptchas || 0}\n` + ` • Blocking text: ${botCheckResult.hasBlockingText ? 'Yes' : 'No'}\n\n` + `šŸ‘¤ Hey human! Could you please:\n` + ` 1. Go to the browser window\n` + ` 2. Complete the CAPTCHA/verification challenge\n` + ` 3. I'll detect when you're done and continue automatically\n\n` + `ā³ WAITING: This tool is now polling for CAPTCHA completion.\n` + `šŸ›‘ AGENT: DO NOTHING. DO NOT CALL ANY TOOLS.\n` + `The tool will automatically return when the user completes the CAPTCHA.`; return { content: [{ type: 'text', text }] }; } // Removed - DevToolsAgentPageGenerator is now the single source of truth async generateAgentPage() { try { if (!this.page) throw new Error('Page not initialized'); // Single source of truth - DevToolsAgentPageGenerator const generator = new DevToolsAgentPageGenerator(this.page); const result = await generator.generateAgentPage(); // Store current manifest for later use this.currentManifest = result; // Check if content needs to be chunked (20k chars is roughly 5k tokens) const TOKEN_LIMIT = 20000; // Conservative limit for agent pages if (result.content.length > TOKEN_LIMIT) { console.log(`šŸ“¦ Agent page too large (${result.content.length} chars), creating chunks...`); // Create chunks based on logical sections const chunks = this.chunkAgentPage(result.content, result.elements); // Store chunked data const chunkId = randomUUID(); this.agentPageChunkData.set(chunkId, { id: chunkId, totalChunks: chunks.length, chunks: chunks.map(c => c.content), metadata: { url: result.url, title: result.title, totalElements: result.elements.length, timestamp: new Date(), elementRanges: chunks.map(c => ({ start: c.startElement, end: c.endElement })) } }); // Return first chunk with instructions const url = await this.page.url(); let response = `āœ… Navigation successful\nšŸ“ URL: ${url}\n\n`; response += `āš ļø Large page detected (${result.content.length} characters)\n`; response += `šŸ“¦ Content split into ${chunks.length} chunks\n`; response += `šŸ“‹ Chunk ID: ${chunkId}\n`; response += `šŸ” Use get_agent_page_chunk({id: "${chunkId}", chunk: 2}) for next chunk\n\n`; response += chunks[0].content; return response; } // Return the complete agent page with navigation header const url = await this.page.url(); return `āœ… Navigation successful\nšŸ“ URL: ${url}\n\n${result.content}`; } catch (error) { console.error('āŒ Agent page generation failed:', error.message); throw new Error(`Agent page generation failed: ${error.message}`); } } chunkAgentPage(content, elements) { const chunks = []; const CHUNK_SIZE = 18000; // Leave room for headers // Split content by major sections (forms, navigation, etc.) const sections = content.split(/\n(?=## )/); // Split on section headers let currentChunk = ''; let currentStartElement = 0; let currentEndElement = 0; let elementIndex = 0; for (const section of sections) { // Count elements in this section const sectionElementCount = (section.match(/\[\w+-[\w-]+\]/g) || []).length; // If adding this section would exceed limit, save current chunk if (currentChunk && (currentChunk.length + section.length > CHUNK_SIZE)) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: currentEndElement }); currentChunk = ''; currentStartElement = elementIndex; } currentChunk += (currentChunk ? '\n\n' : '') + section; currentEndElement = elementIndex + sectionElementCount - 1; elementIndex += sectionElementCount; // If current chunk is getting large, save it if (currentChunk.length > CHUNK_SIZE) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: currentEndElement }); currentChunk = ''; currentStartElement = elementIndex; } } // Add remaining content if (currentChunk.trim()) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: Math.max(currentEndElement, elements.length - 1) }); } return chunks; } // Getter for agent page chunk data (for AgentTools access) getAgentPageChunkData() { return this.agentPageChunkData; } async navigateAndCaptureLoadingSequence(url, params = {}, screenshotTools) { try { // Launch browser if needed if (!this.browser) { // Environment variable configuration const headless = this.forcedHeadless !== null ? this.forcedHeadless : process.env.SUPAPUP_HEADLESS === 'true'; // Default false (visible browser) const debugPort = process.env.SUPAPUP_DEBUG_PORT || '9222'; const devtools = process.env.SUPAPUP_DEVTOOLS === 'true'; console.log(`šŸš€ Launching browser for loading sequence capture (headless: ${headless})...`); // Base arguments for all environments const baseArgs = [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-web-security', '--disable-features=VizDisplayCompositor', `--remote-debugging-port=${debugPort}` ]; // Additional arguments for headless environments const headlessArgs = [ '--no-zygote', '--single-process', '--disable-gpu', '--disable-dev-shm-usage' ]; // Arguments for non-headless (development) environments const devArgs = [ '--start-maximized' ]; this.browser = await puppeteer.launch({ headless, defaultViewport: null, devtools, args: [ ...baseArgs, ...(headless ? headlessArgs : devArgs) ] }); } const { captureInterval = 500, // ms between captures (agent configurable) maxDuration = 10000, // max capture time quality = 30, // Lower quality for faster processing stopOnNetworkIdle = true, // stop when network settles viewport = { width: 1200, height: 800 } // Smaller viewport for faster screenshots } = params; // Create page if needed if (!this.page) { // Check if there's an existing blank page we can reuse const pages = await this.browser.pages(); const blankPage = pages.find(page => page.url() === 'about:blank' || page.url() === ''); if (blankPage) { this.page = blankPage; } else { this.page = await this.browser.newPage(); } await this.page.setViewport(viewport); // Use configurable viewport await this.page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); } else { // Update viewport for loading sequence capture await this.page.setViewport(viewport); } console.log(`šŸŽ¬ Starting loading sequence capture for: ${url}`); console.log(`šŸ“ø Capturing every ${captureInterval}ms for up to ${maxDuration}ms`); const frames = []; const frameMetadata = []; const startTime = Date.now(); let captureActive = true; let lastNetworkActivity = Date.now(); let frameIndex = 0; // Track network activity const requestHandler = () => { lastNetworkActivity = Date.now(); }; const responseHandler = () => { lastNetworkActivity = Date.now(); }; if (stopOnNetworkIdle) { this.page.on('request', requestHandler); this.page.on('response', responseHandler); } // Start navigation const navigationPromise = this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Capture initial state try { const initialShot = await this.page.screenshot({ type: 'jpeg', quality, encoding: 'base64' }); frames.push(initialShot); frameMetadata.push({ index: frameIndex++, timestamp: 0, description: 'Initial navigation state' }); console.log('šŸ“ø Captured initial frame'); } catch (e) { console.log('āš ļø Could not capture initial frame'); } // Capture frames at intervals const captureIntervalId = setInterval(async () => { if (!captureActive || !this.page) { clearInterval(captureIntervalId); return; } const elapsed = Date.now() - startTime; // Check stop conditions if (elapsed > maxDuration) { console.log('ā±ļø Max duration reached'); captureActive = false; return; } if (stopOnNetworkIdle && elapsed > 3000 && Date.now() - lastNetworkActivity > 2000) { console.log('šŸ”„ Network idle detected'); captureActive = false; return; } try { // Capture frame const screenshot = await this.page.screenshot({ type: 'jpeg', quality, encoding: 'base64' }); // Get page state info const stateInfo = await this.page.evaluate(() => { const indicators = []; // Check for loading indicators const loadingElements = document.querySelectorAll('.loading, .spinner, [class*="load"], [class*="spin"], .skeleton'); if (loadingElements.length > 0) { indicators.push(`${loadingElements.length} loading indicators`); } // Check video states const videos = document.querySelectorAll('video'); const loadingVideos = Array.from(videos).filter(v => v.readyState < 4); if (loadingVideos.length > 0) { indicators.push(`${loadingVideos.length}/${videos.length} videos loading`); } // Check images const images = document.querySelectorAll('img'); const loadingImages = Array.from(images).filter(img => !img.complete); if (loadingImages.length > 0) { indicators.push(`${loadingImages.length}/${images.length} images loading`); } // Check if document is still parsing if (document.readyState !== 'complete') { indicators.push(`document ${document.readyState}`); } return indicators.length > 0 ? indicators.join(', ') : 'content visible'; }); frames.push(screenshot); frameMetadata.push({ index: frameIndex++, timestamp: elapsed, description: stateInfo }); console.log(`šŸ“ø Frame ${frameIndex} at ${elapsed}ms: ${stateInfo}`); } catch (error) { console.error('Failed to capture frame:', error); } }, captureInterval); // Wait for navigation try { await navigationPromise; console.log('āœ… Navigation completed'); } catch (error) { console.log('āš ļø Navigation timeout/error:', error); } // Wait for capture to finish while (captureActive) { await new Promise(resolve => setTimeout(resolve, 100)); } clearInterval(captureIntervalId); // Clean up event listeners if (stopOnNetworkIdle) { this.page.off('request', requestHandler); this.page.off('response', responseHandler); } // Capture final state await new Promise(resolve => setTimeout(resolve, 1000)); try { const finalShot = await this.page.screenshot({ type: 'jpeg', quality, encoding: 'base64' }); frames.push(finalShot); frameMetadata.push({ index: frameIndex++, timestamp: Date.now() - startTime, description: 'Final loaded state' }); console.log('šŸ“ø Captured final frame'); } catch (e) { console.log('āš ļø Could not capture final frame'); } // Store as chunk data for retrieval const sequenceId = `loading-${Date.now()}-${Math.random().toString(36).substring(7)}`; // Use provided screenshot tools or create new one let screenshotToolsInstance = screenshotTools; if (!screenshotToolsInstance) { const { ScreenshotTools } = await import('./screenshot-tools.js'); screenshotToolsInstance = new ScreenshotTools(); screenshotToolsInstance.initialize(this.page); } // Store in chunk format const chunkData = screenshotToolsInstance.getChunkData(); console.log(`šŸ’¾ Storing loading sequence with ID: ${sequenceId}`); console.log(`šŸ“Š ChunkData size before: ${chunkData.size}`); chunkData.set(sequenceId, { id: sequenceId, totalChunks: frames.length, chunks: frames, metadata: { fullPage: false, quality, timestamp: new Date(), frameMetadata // Store frame descriptions } }); console.log(`šŸ“Š ChunkData size after: ${chunkData.size}`); // Generate loading sequence analysis const analysis = frameMetadata.map((meta, idx) => ` Frame ${idx + 1}: ${meta.timestamp}ms - ${meta.description}`).join('\n'); return { content: [{ type: 'text', text: `šŸŽ¬ Loading sequence captured successfully!\n\n` + `šŸ“ URL: ${url}\n` + `šŸŽžļø Captured ${frames.length} frames over ${frameMetadata[frameMetadata.length - 1].timestamp}ms\n` + `šŸ†” Sequence ID: ${sequenceId}\n\n` + `šŸ“Š Loading Timeline:\n${analysis}\n\n` + `šŸ” View frames with:\n` + ` • get_loading_sequence_frame({id: "${sequenceId}", frame: 1}) - Initial state\n` + ` • get_loading_sequence_frame({id: "${sequenceId}", frame: ${Math.ceil(frames.length / 2)}}) - Mid-load\n` + ` • get_loading_sequence_frame({id: "${sequenceId}", frame: ${frames.length}}) - Final state\n\n` + `šŸ’” Navigate between frames to see how the page loaded!` }] }; } catch (error) { console.error('āŒ Loading sequence capture failed:', error); return { content: [{ type: 'text', text: `āŒ Loading sequence capture failed: ${error.message}\nšŸ“ URL: ${url}` }] }; } } }