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
JavaScript
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}`
},
],
};
}
}
}