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.

268 lines (267 loc) • 10.9 kB
/** * Hover state detection for Supapup * Detects elements with hover effects and captures visual changes */ export class HoverDetector { page; constructor(page) { this.page = page; } /** * Detect all hoverable elements on the page */ async detectHoverableElements() { return await this.page.evaluate(() => { const hoverable = []; let hoverIndex = 0; // Helper to check if element has hover styles function hasHoverStyles(element) { const computed = window.getComputedStyle(element); let confidence = 0; let hoverType = 'none'; // Check for transitions if (computed.transition !== 'none' && computed.transition !== 'all 0s ease 0s') { confidence += 0.3; hoverType = 'css'; } // Check for cursor change if (computed.cursor === 'pointer') { confidence += 0.2; } // Check common hover selectors const tagName = element.tagName.toLowerCase(); if (['a', 'button', 'input[type="button"]', 'input[type="submit"]'].includes(tagName)) { confidence += 0.3; hoverType = 'css'; } // Check class names for hover indicators const className = element.className; if (className && typeof className === 'string') { const hoverPatterns = ['hover', 'dropdown', 'tooltip', 'menu', 'btn']; if (hoverPatterns.some(pattern => className.toLowerCase().includes(pattern))) { confidence += 0.2; } } // Check for event listeners (simplified check) const hasMouseEvents = !!(element.onmouseover || element.onmouseenter || element.onmouseleave || element.onmouseout); if (hasMouseEvents) { confidence += 0.3; hoverType = hoverType === 'css' ? 'both' : 'javascript'; } // Check if element has children that might appear on hover const children = element.querySelectorAll('*'); let hiddenChildren = 0; children.forEach(child => { const childStyle = window.getComputedStyle(child); if (childStyle.display === 'none' || childStyle.visibility === 'hidden') { hiddenChildren++; } }); if (hiddenChildren > 0) { confidence += 0.3; hoverType = 'css'; } return { hasHover: confidence > 0.3, confidence: Math.min(confidence, 1), type: hoverType }; } // Process all elements const elements = document.querySelectorAll('*'); const processedSelectors = new Set(); elements.forEach(element => { // Skip if already processed if (element.hasAttribute('data-mcp-hover-id')) return; // Skip invisible elements const rect = element.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const computed = window.getComputedStyle(element); if (computed.display === 'none' || computed.visibility === 'hidden') return; // Check for hover styles const hoverCheck = hasHoverStyles(element); if (hoverCheck.hasHover) { const hoverId = `hover-${hoverIndex++}`; const selector = element.id ? `#${element.id}` : element.className && typeof element.className === 'string' ? `.${element.className.split(' ')[0]}` : element.tagName.toLowerCase(); // Avoid duplicates if (processedSelectors.has(selector)) return; processedSelectors.add(selector); element.setAttribute('data-mcp-hoverable', 'true'); element.setAttribute('data-mcp-hover-id', hoverId); hoverable.push({ id: hoverId, selector, tagName: element.tagName.toLowerCase(), text: element.textContent?.trim().substring(0, 50), type: element.type || '', hasTransition: computed.transition !== 'none', transitionDuration: computed.transitionDuration, hoverType: hoverCheck.type, confidence: hoverCheck.confidence }); } }); return hoverable.sort((a, b) => b.confidence - a.confidence); }); } /** * Capture hover sequence for a specific element */ async captureHoverSequence(hoverId, duration = 1000, interval = 100) { const element = await this.page.$(`[data-mcp-hover-id="${hoverId}"]`); if (!element) return null; const boundingBox = await element.boundingBox(); if (!boundingBox) return null; const captures = []; const startTime = Date.now(); // Capture initial state const initialCapture = await this.captureElementState(element, boundingBox); captures.push({ timestamp: 0, screenshot: initialCapture.screenshot, styles: initialCapture.styles }); // Trigger hover await element.hover(); // Capture during hover const capturePromises = []; let currentTime = interval; while (currentTime < duration) { const timeToCapture = currentTime; capturePromises.push(new Promise(async (resolve) => { await new Promise(resolve => setTimeout(resolve, timeToCapture - (capturePromises.length * interval))); const capture = await this.captureElementState(element, boundingBox); captures.push({ timestamp: timeToCapture, screenshot: capture.screenshot, styles: capture.styles }); resolve(); })); currentTime += interval; } await Promise.all(capturePromises); // Move mouse away to end hover await this.page.mouse.move(0, 0); await new Promise(resolve => setTimeout(resolve, 100)); // Capture final state const finalCapture = await this.captureElementState(element, boundingBox); captures.push({ timestamp: duration + 100, screenshot: finalCapture.screenshot, styles: finalCapture.styles }); // Analyze changes const changes = this.analyzeChanges(captures); return { hoverId, element: { selector: await this.page.evaluate(el => { return el.id ? `#${el.id}` : el.className ? `.${el.className.split(' ')[0]}` : el.tagName.toLowerCase(); }, element), bounds: boundingBox }, captures, changes }; } /** * Capture element state including screenshot and styles */ async captureElementState(element, bounds) { // Add padding to capture shadow effects const padding = 20; const clip = { x: Math.max(0, bounds.x - padding), y: Math.max(0, bounds.y - padding), width: bounds.width + (padding * 2), height: bounds.height + (padding * 2) }; // Take screenshot const screenshot = await this.page.screenshot({ clip, encoding: 'base64' }); // Get computed styles const styles = await this.page.evaluate(el => { const computed = window.getComputedStyle(el); return { backgroundColor: computed.backgroundColor, color: computed.color, transform: computed.transform, opacity: computed.opacity, boxShadow: computed.boxShadow, borderColor: computed.borderColor, filter: computed.filter, scale: computed.scale }; }, element); return { screenshot, styles }; } /** * Analyze captures to detect changes */ analyzeChanges(captures) { if (captures.length < 2) { return { hasVisualChange: false, styleChanges: [], sizeChange: false }; } const initial = captures[0]; const styleChanges = []; let hasVisualChange = false; // Compare styles across captures for (let i = 1; i < captures.length; i++) { const capture = captures[i]; if (capture.styles && initial.styles) { for (const [key, value] of Object.entries(capture.styles)) { if (value !== initial.styles[key] && !styleChanges.includes(key)) { styleChanges.push(key); hasVisualChange = true; } } } } // In a real implementation, we'd compare screenshots too // For now, we assume visual change if styles changed return { hasVisualChange, styleChanges, sizeChange: false // Would need image analysis }; } /** * Generate agent page snippet for hoverable elements */ generateHoverableSection(elements) { if (elements.length === 0) return ''; let section = '\nšŸ–±ļø HOVERABLE ELEMENTS:\n'; elements.forEach(el => { const confidence = Math.round(el.confidence * 100); section += ` • ${el.tagName}`; if (el.text) section += `: "${el.text.substring(0, 30)}${el.text.length > 30 ? '...' : ''}"`; section += ` → [${el.id}]`; if (el.hasTransition) section += ' (animated)'; section += ` ${confidence}% confidence\n`; }); section += '\n šŸ’” Use: page_trigger_hover({selector: "hover-id"})\n'; return section; } }