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.

685 lines (684 loc) • 28.5 kB
import { randomUUID } from 'crypto'; export class PageAnalysis { page; hoverAnimationData = new Map(); constructor(page) { this.page = page; } async getPageState() { try { const state = await this.page.evaluate(() => { const agentPage = window.__AGENT_PAGE__; return { url: window.location.href, title: document.title, readyState: document.readyState, elementsCount: agentPage?.manifest?.elements?.length || 0, hasAgentInterface: !!agentPage }; }); return { content: [ { type: 'text', text: JSON.stringify(state, null, 2) }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to get page state: ${error.message}` }, ], }; } } async discoverActions() { try { const actions = await this.page.evaluate(() => { const agentPage = window.__AGENT_PAGE__; if (agentPage?.manifest?.elements) { return agentPage.manifest.elements.map((el) => ({ id: el.id, type: el.type, action: el.action, description: el.description })); } return []; }); return { content: [ { type: 'text', text: JSON.stringify({ actions }, null, 2) }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to discover actions: ${error.message}` }, ], }; } } async getPageResources() { try { const resources = await this.page.evaluate(() => { const scripts = Array.from(document.scripts).map(script => ({ src: script.src, type: script.type || 'text/javascript', async: script.async, defer: script.defer })); const links = Array.from(document.links).map(link => ({ href: link.href, rel: link.rel, type: link.type })); const stylesheets = Array.from(document.styleSheets).map(sheet => ({ href: sheet.href, media: Array.from(sheet.media).join(', ') })); const images = Array.from(document.images).map(img => ({ src: img.src, alt: img.alt, width: img.width, height: img.height })); return { scripts: scripts.length, links: links.length, stylesheets: stylesheets.length, images: images.length, details: { scripts, links, stylesheets, images } }; }); return { content: [ { type: 'text', text: `šŸ“¦ Page Resources Summary:\n\n` + `šŸ“œ Scripts: ${resources.scripts}\n` + `šŸ”— Links: ${resources.links}\n` + `šŸŽØ Stylesheets: ${resources.stylesheets}\n` + `šŸ–¼ļø Images: ${resources.images}\n\n` + `Use detailed view for full resource list.` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to get page resources: ${error.message}` }, ], }; } } async getPerformanceMetrics() { try { const metrics = await this.page.metrics(); const performanceEntries = await this.page.evaluate(() => { const navigation = performance.getEntriesByType('navigation')[0]; const paint = performance.getEntriesByType('paint'); return { navigation: { totalLoadTime: Math.round(navigation.loadEventEnd - navigation.fetchStart), ttfb: Math.round(navigation.responseStart - navigation.fetchStart), domContentLoaded: Math.round(navigation.domContentLoadedEventEnd - navigation.fetchStart), domProcessing: Math.round(navigation.domComplete - navigation.domInteractive) }, paint: paint.reduce((acc, entry) => { acc[entry.name] = Math.round(entry.startTime); return acc; }, {}) }; }); const summary = `šŸ“Š PERFORMANCE METRICS\n\n` + `ā±ļø TIMING:\n` + ` • Total Load Time: ${performanceEntries.navigation.totalLoadTime}ms\n` + ` • Time to First Byte: ${performanceEntries.navigation.ttfb}ms\n` + ` • DOM Content Loaded: ${performanceEntries.navigation.domContentLoaded}ms\n` + ` • DOM Processing: ${performanceEntries.navigation.domProcessing}ms\n` + (performanceEntries.paint['first-paint'] ? ` • First Paint: ${performanceEntries.paint['first-paint']}ms\n` : '') + (performanceEntries.paint['first-contentful-paint'] ? ` • First Contentful Paint: ${performanceEntries.paint['first-contentful-paint']}ms\n` : '') + `\nšŸ“ˆ PUPPETEER METRICS:\n` + ` • Documents: ${metrics.Documents}\n` + ` • Frames: ${metrics.Frames}\n` + ` • JS Event Listeners: ${metrics.JSEventListeners}\n` + ` • DOM Nodes: ${metrics.Nodes}`; return { content: [ { type: 'text', text: summary }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to get performance metrics: ${error.message}` }, ], }; } } async getAccessibilityTree() { try { const snapshot = await this.page.accessibility.snapshot(); return { content: [ { type: 'text', text: `♿ Accessibility Tree:\n\n${JSON.stringify(snapshot, null, 2)}` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to get accessibility tree: ${error.message}` }, ], }; } } async inspectElement(args) { try { let { selector } = args; const elementInfo = await this.page.evaluate((sel) => { // Try agent page ID first if it looks like one let element = null; // If it looks like an agent page ID (no CSS selector characters) if (!sel.includes('.') && !sel.includes('#') && !sel.includes('[') && !sel.includes(' ') && !sel.includes('>')) { // Try as data-mcp-id first element = document.querySelector(`[data-mcp-id="${sel}"]`); } // If not found, try as regular selector if (!element) { element = document.querySelector(sel); } if (!element) return null; const styles = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return { tagName: element.tagName, attributes: Array.from(element.attributes).reduce((acc, attr) => { acc[attr.name] = attr.value; return acc; }, {}), textContent: element.textContent?.trim(), styles: { display: styles.display, visibility: styles.visibility, opacity: styles.opacity, position: styles.position, zIndex: styles.zIndex, backgroundColor: styles.backgroundColor, color: styles.color, fontSize: styles.fontSize, fontFamily: styles.fontFamily }, geometry: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, isVisible: rect.width > 0 && rect.height > 0 && styles.visibility !== 'hidden', parent: element.parentElement?.tagName, children: element.children.length }; }, selector); if (!elementInfo) { return { content: [ { type: 'text', text: `Element not found: ${selector}` }, ], }; } return { content: [ { type: 'text', text: `šŸ” Element Inspection: ${selector}\n\n` + `šŸ“ Tag: ${elementInfo.tagName}\n` + `šŸ“ Geometry: ${elementInfo.geometry.width}Ɨ${elementInfo.geometry.height} at (${elementInfo.geometry.x}, ${elementInfo.geometry.y})\n` + `šŸ‘ļø Visible: ${elementInfo.isVisible}\n` + `šŸ“œ Text: ${elementInfo.textContent || 'None'}\n` + `šŸ—ļø Parent: ${elementInfo.parent}, Children: ${elementInfo.children}\n\n` + `šŸŽØ Key Styles:\n${JSON.stringify(elementInfo.styles, null, 2)}\n\n` + `šŸ“‹ Attributes:\n${JSON.stringify(elementInfo.attributes, null, 2)}` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to inspect element: ${error.message}` }, ], }; } } async evaluateScript(args) { try { const { script } = args; const result = await this.page.evaluate(script); return { content: [ { type: 'text', text: `šŸ“œ Script Result:\n\n${JSON.stringify(result, null, 2)}` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Script execution failed: ${error.message}` }, ], }; } } async executeAndWait(args) { try { const { action, selector, value, code, waitTime = 3000 } = args; let result; if (action === 'evaluate') { result = await this.page.evaluate(code); } else if (action === 'click' && selector) { await this.page.click(selector); result = { clicked: selector }; } else if (action === 'fill' && selector && value) { await this.page.type(selector, value); result = { filled: selector, value }; } else if (action === 'submit' && selector) { await this.page.click(selector); result = { submitted: selector }; } else if (action === 'hover' && selector) { await this.page.hover(selector); result = { hovered: selector }; } else { throw new Error(`Unknown action: ${action}`); } // Wait for changes await new Promise(resolve => setTimeout(resolve, waitTime)); // Check for navigation const url = this.page.url(); return { content: [ { type: 'text', text: `āœ… Action completed: ${JSON.stringify(result)}\n` + `🌐 Current URL: ${url}\n` + `ā±ļø Waited: ${waitTime}ms for changes` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Execute and wait failed: ${error.message}` }, ], }; } } async captureHoverSequence(args) { try { const { selector, duration = 1000, interval = 100 } = args; // First check if hover detection has been run const hoverElementsExist = await this.page.evaluate(() => { return document.querySelectorAll('[data-mcp-hover-id]').length > 0; }); // If no hover elements detected yet, run detection if (!hoverElementsExist) { console.log('šŸ–±ļø No hover elements detected, running hover detection...'); // Check if detector exists, if not inject the script const detectorExists = await this.page.evaluate(() => { return typeof window.__SUPAPUP_HOVER_DETECTOR__ !== 'undefined'; }); if (!detectorExists) { console.log('šŸ–±ļø Injecting hover detection script...'); // Import and inject the hover detection script const { HOVER_DETECTION_SCRIPT } = await import('../generators/hover-detection-script.js'); await this.page.evaluate(HOVER_DETECTION_SCRIPT); } // Run detection const detectionResult = await this.page.evaluate(() => { const win = window; if (win.__SUPAPUP_HOVER_DETECTOR__) { win.__SUPAPUP_HOVER_DETECTOR__.detectHoverableElements(); const elements = win.__SUPAPUP_HOVERABLE_ELEMENTS__ || []; return { detected: elements.length, hoverIds: elements.slice(0, 10).map((el) => ({ id: el.getAttribute('data-mcp-hover-id'), text: el.textContent?.trim().substring(0, 50) })) }; } return { detected: 0, hoverIds: [] }; }); console.log(`šŸ–±ļø Detected ${detectionResult.detected} hoverable elements`); // If still no elements found after detection if (detectionResult.detected === 0) { return { content: [ { type: 'text', text: 'āŒ No hoverable elements detected on this page\n\n' + 'šŸ’” This page may not have any CSS hover effects, or they may be JavaScript-based.' }, ], }; } // If selector wasn't provided, suggest available hover elements if (!selector || selector === 'undefined') { // Get hoverable elements from integrated system const availableElements = await this.page.evaluate(() => { const elements = document.querySelectorAll('[data-mcp-hoverable="true"]'); return Array.from(elements).slice(0, 10).map(el => ({ id: el.getAttribute('data-mcp-id'), text: el.textContent?.trim().substring(0, 50) || el.tagName.toLowerCase(), tagName: el.tagName.toLowerCase() })); }); if (availableElements.length === 0) { return { content: [ { type: 'text', text: `āŒ No hoverable elements found on this page.\n\n` + `Elements with hover states are automatically detected and marked with "** Has Hover State **" in the agent page.` }, ], }; } const suggestions = availableElements.map((h) => ` • ${h.id}: ${h.text}`).join('\n'); return { content: [ { type: 'text', text: `šŸ–±ļø Found ${availableElements.length} hoverable elements!\n\n` + `Use the same IDs as interactive elements:\n${suggestions}\n\n` + `Example: page_trigger_hover({selector: "${availableElements[0]?.id}"})` }, ], }; } } // Look for element by interactive element ID with hoverable attribute const element = await this.page.$(`[data-mcp-id="${selector}"][data-mcp-hoverable="true"]`); if (!element) { // Check if element exists but isn't hoverable const elementExists = await this.page.$(`[data-mcp-id="${selector}"]`); if (elementExists) { return { content: [ { type: 'text', text: `āŒ Element "${selector}" exists but is not hoverable.\n\n` + `Only elements marked with "** Has Hover State **" in the agent page can be used with page_trigger_hover.` }, ], }; } // Get available hoverable IDs for error message const availableIds = await this.page.evaluate(() => { const elements = document.querySelectorAll('[data-mcp-hoverable="true"]'); return Array.from(elements).slice(0, 10).map(el => ({ id: el.getAttribute('data-mcp-id'), text: el.textContent?.trim().substring(0, 50) || el.tagName.toLowerCase() })); }); const suggestions = availableIds.map((h) => ` • ${h.id}: ${h.text}`).join('\n'); return { content: [ { type: 'text', text: `āŒ Hoverable element not found: ${selector}\n\n` + `Available hover elements:\n${suggestions}` }, ], }; } // Get element info and bounds const elementInfo = await this.page.evaluate((elementId) => { const el = document.querySelector(`[data-mcp-id="${elementId}"][data-mcp-hoverable="true"]`); if (!el) return null; const rect = el.getBoundingClientRect(); const computed = window.getComputedStyle(el); return { bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, tagName: el.tagName.toLowerCase(), text: el.textContent?.trim().substring(0, 50), transitionDuration: computed.transitionDuration }; }, selector); if (!elementInfo) { return { content: [ { type: 'text', text: `āŒ Could not get element bounds for: ${selector}` }, ], }; } // Calculate screenshot area with padding for shadows const padding = 20; const clip = { x: Math.max(0, elementInfo.bounds.x - padding), y: Math.max(0, elementInfo.bounds.y - padding), width: elementInfo.bounds.width + (padding * 2), height: elementInfo.bounds.height + (padding * 2) }; const captures = []; // Capture before hover const beforeCapture = await this.page.screenshot({ clip, encoding: 'base64' }); captures.push(beforeCapture); // Trigger hover await element.hover(); // Capture during hover at intervals const steps = Math.floor(duration / interval); for (let i = 0; i < steps; i++) { await new Promise(resolve => setTimeout(resolve, interval)); const capture = await this.page.screenshot({ clip, encoding: 'base64' }); captures.push(capture); } // Move mouse away to end hover await this.page.mouse.move(0, 0); await new Promise(resolve => setTimeout(resolve, 200)); // Capture after hover const afterCapture = await this.page.screenshot({ clip, encoding: 'base64' }); captures.push(afterCapture); // Store frames with pagination support const animationId = randomUUID(); const chunk = { id: animationId, totalFrames: captures.length, frames: captures, metadata: { selector, elementInfo, duration, interval, timestamp: new Date() } }; this.hoverAnimationData.set(animationId, chunk); // Return pagination info instead of all images return { content: [ { type: 'text', text: `āœ… Captured hover animation for ${selector}\n` + `šŸ“ø ${captures.length} frames over ${duration}ms\n` + `šŸŽ¬ Animation ID: ${animationId}\n` + `šŸ·ļø Element: <${elementInfo.tagName}> "${elementInfo.text || 'no text'}"\n` + `šŸ“ Size: ${Math.round(elementInfo.bounds.width)}x${Math.round(elementInfo.bounds.height)}px\n\n` + `šŸ” Use page_trigger_hover_get_frame({id: "${animationId}", frame: 1}) to view frames\n` + `šŸ“‹ Available frames: 1-${captures.length}` } ], }; } catch (error) { return { content: [ { type: 'text', text: `āŒ Hover capture failed: ${error.message}` }, ], }; } } async generateAgentPage(args) { try { const { enhanced = true, mode = 'auto' } = args; // This would trigger a re-generation of the agent page const pageContent = await this.page.content(); const manifest = { elements: [], summary: 'Re-generated agent page', forms: [], navigation: [] }; return { content: [ { type: 'text', text: `Agent page generated: ${manifest.summary}\n\n` + `Mode: ${mode}, Enhanced: ${enhanced}\n\n` + `Use navigate command for full agent page generation.` }, ], }; } catch (error) { return { content: [ { type: 'text', text: `Failed to generate agent page: ${error.message}` }, ], }; } } async getHoverAnimationFrame(params) { try { const { id, frame } = params; if (!id) { return { content: [ { type: 'text', text: `āŒ Animation ID is required\n\nUse the ID from page_trigger_hover response.` }, ], }; } const animation = this.hoverAnimationData.get(id); if (!animation) { return { content: [ { type: 'text', text: `āŒ Animation not found: ${id}\n\nAnimation may have expired or ID is invalid.` }, ], }; } if (!frame || frame < 1 || frame > animation.totalFrames) { return { content: [ { type: 'text', text: `āŒ Invalid frame number: ${frame}\n\nAvailable frames: 1-${animation.totalFrames}` }, ], }; } const frameIndex = frame - 1; // Convert to 0-based index const frameImage = animation.frames[frameIndex]; return { content: [ { type: 'text', text: `šŸŽ¬ Hover animation frame ${frame}/${animation.totalFrames}\n` + `šŸ“ Element: ${animation.metadata.selector}\n` + `ā±ļø Captured: ${animation.metadata.timestamp.toISOString()}` }, { type: 'image', data: frameImage, mimeType: 'image/png' } ], }; } catch (error) { return { content: [ { type: 'text', text: `āŒ Failed to get hover animation frame: ${error.message}` }, ], }; } } }