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.
605 lines (510 loc) ⢠21.4 kB
JavaScript
// DevTools Agent Page Generator - Browser-side script
// This script runs in the browser context to generate agent pages
// Only inject once
if (typeof window['__DEVTOOLS_AGENT_GENERATOR__'] === 'undefined') {
window['__DEVTOOLS_AGENT_GENERATOR__'] = {
generate() {
const result = {
content: '',
elements: [],
actions: [],
title: document.title,
url: window.location.href
};
const usedIds = new Set();
let elementCounter = 0;
// Start with title and basic info
result.content += document.title + '\n';
result.content += '='.repeat(document.title.length) + '\n\n';
// Process visible content in reading order
this.processContentNode(document.body, result, usedIds, elementCounter, '');
// Add forms and actions summary
if (result.elements.length > 0) {
// Group elements by forms first
const forms = this.groupElementsByForms(result.elements);
// Add form fill tool reference if forms exist
if (forms.length > 0) {
result.content += '\nFORM TOOLS:\n';
forms.forEach((form, index) => {
const fieldsList = form.fields.map((f) => f.id + ': "value"').join(', ');
result.content += '- form_fill({formData: {' + fieldsList + '}}) -> Fill entire form\n';
});
result.content += '\n';
}
// Add individual actions
result.content += '\nACTIONS AVAILABLE:\n';
result.elements.forEach(el => {
const example = this.generateActionExample(el);
if (example) {
result.content += '- ' + example + '\n';
result.actions.push(example);
}
});
// Add hover actions for hoverable elements
if (this.hoverableElements && this.hoverableElements.length > 0) {
result.content += '\nHOVER ACTIONS:\n';
this.hoverableElements.slice(0, 5).forEach(el => {
result.content += '- page_trigger_hover({selector: "' + el.id + '"}) -> Capture hover animation for ' + (el.text || el.tagName) + '\n';
});
if (this.hoverableElements.length > 5) {
result.content += '- ... and ' + (this.hoverableElements.length - 5) + ' more hoverable elements\n';
}
}
// Add stats
result.content += '\nINTERACTIVE ELEMENTS: ' + result.elements.length + ' total\n';
const actionStats = this.getActionStats(result.elements);
Object.entries(actionStats).forEach(([action, count]) => {
result.content += '- ' + action + ': ' + count + ' elements\n';
});
}
// Add hoverable elements section if any detected during processing
if (this.hoverableElements && this.hoverableElements.length > 0) {
const hoverElements = this.hoverableElements;
result.content += '\nš±ļø HOVERABLE ELEMENTS: ' + hoverElements.length + ' detected\n';
hoverElements.slice(0, 10).forEach(el => {
result.content += '- ' + el.tagName.toUpperCase();
if (el.text) {
result.content += ': "' + el.text + (el.text.length >= 30 ? '...' : '') + '"';
}
result.content += ' ā [' + el.id + ']\n';
});
if (hoverElements.length > 10) {
result.content += '... and ' + (hoverElements.length - 10) + ' more\n';
}
result.content += '\nš” HOVER TOOL USAGE:\n';
result.content += '- Use the same IDs as interactive elements: page_trigger_hover({selector: "submit-1"})\n';
result.content += '- Works with any element marked "** Has Hover State **"\n';
result.content += '- Captures hover animations and style changes\n';
// Add hoverable elements to result
result.hoverableElements = hoverElements;
}
return result;
},
// Process content nodes in reading order
processContentNode(node, result, usedIds, elementCounter, indent) {
if (!node || !this.isNodeVisible(node)) return;
// Skip script, style, and other non-content elements
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
if (['script', 'style', 'noscript', 'meta', 'link', 'head'].includes(tagName)) {
return;
}
}
// Handle text nodes
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text && text.length > 0) {
const parent = node.parentElement;
if (parent && !this.isStructuralElement(parent) && !this.isInteractiveElement(parent)) {
result.content += indent + text + '\n';
}
}
return;
}
// Handle element nodes
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
const tagName = element.tagName.toLowerCase();
// Check if this is an interactive element
if (this.isInteractiveElement(element)) {
const agentElement = this.processInteractiveElement(element, usedIds, elementCounter);
if (agentElement) {
result.elements.push(agentElement);
elementCounter++;
// Apply data attributes to DOM immediately
element.setAttribute('data-mcp-id', agentElement.id);
element.setAttribute('data-mcp-type', agentElement.type);
element.setAttribute('data-mcp-action', agentElement.action);
// Add inline representation
const inlineRep = this.createInlineRepresentation(agentElement);
result.content += indent + inlineRep + '\n';
return;
}
}
// Handle structural elements
const newIndent = this.handleStructuralElement(element, result, indent);
// Process children
if (!this.elementHandlesOwnText(element)) {
Array.from(element.childNodes).forEach(child => {
this.processContentNode(child, result, usedIds, elementCounter, newIndent);
});
}
}
},
// Check if element is visible using modern API
isNodeVisible(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.parentElement && this.isElementVisible(node.parentElement);
}
if (node.nodeType === Node.ELEMENT_NODE) {
return this.isElementVisible(node);
}
return false;
},
// Modern visibility detection
isElementVisible(element) {
if (!element) return false;
// Use modern checkVisibility API if available
if (typeof element.checkVisibility === 'function') {
return element.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true,
contentVisibilityAuto: true,
opacityProperty: true,
visibilityProperty: true
});
}
// Fallback visibility check
const style = window.getComputedStyle(element);
if (style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0') {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return false;
}
return true;
},
// Check if element is interactive
isInteractiveElement(element) {
const tagName = element.tagName.toLowerCase();
const type = element.type && element.type.toLowerCase();
// Form elements
if (['input', 'textarea', 'select', 'button'].includes(tagName)) {
return type !== 'hidden';
}
// Links and clickable elements
if (tagName === 'a' && element.href) return true;
if (element.hasAttribute('onclick')) return true;
if (element.getAttribute('role') === 'button') return true;
if (element.getAttribute('role') === 'link') return true;
return false;
},
// Check if element is structural
isStructuralElement(element) {
const tagName = element.tagName.toLowerCase();
return ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'span', 'label'].includes(tagName);
},
// Check if element has hover states using CSS
hasHoverState(element) {
try {
// Only check interactive elements for hover states
if (!this.isInteractiveElement(element)) {
return false;
}
const computedStyle = window.getComputedStyle(element);
const hasTransition = computedStyle.transition && computedStyle.transition !== 'all 0s ease 0s' && computedStyle.transition !== 'none';
const hasCursor = computedStyle.cursor && computedStyle.cursor === 'pointer';
// Check for common hover patterns
const hasHoverClass = element.className.includes('hover') ||
element.className.includes('btn') ||
element.className.includes('button') ||
element.className.includes('link');
return hasTransition || hasCursor || hasHoverClass;
} catch (e) {
return false;
}
},
// Check if element handles its own text content
elementHandlesOwnText(element) {
const tagName = element.tagName.toLowerCase();
return ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'label'].includes(tagName);
},
// Process interactive element into agent element
processInteractiveElement(element, usedIds, counter) {
const tagName = element.tagName.toLowerCase();
const type = (element.type && element.type.toLowerCase()) || tagName;
// Generate semantic ID
const id = this.generateSemanticId(element, usedIds);
// Check for hover states and mark element
let hasHover = false;
if (this.hasHoverState && this.hasHoverState(element)) {
hasHover = true;
element.setAttribute('data-mcp-hoverable', 'true');
console.log('[DEBUG] Found hoverable element:', id, tagName, (element.textContent || '').trim().substring(0, 30));
// Track globally for tool reference using the same ID
if (!this.hoverableElements) this.hoverableElements = [];
this.hoverableElements.push({
id: id, // Use the same ID as the interactive element
element: element,
tagName: tagName,
text: (element.textContent || '').trim().substring(0, 30)
});
}
// Determine action type
let action = 'click';
if (tagName === 'input') {
if (['text', 'email', 'password', 'tel', 'url', 'search', 'number'].includes(type)) {
action = 'fill';
} else if (['checkbox', 'radio'].includes(type)) {
action = 'toggle';
} else if (['date', 'time', 'datetime-local'].includes(type)) {
action = 'fill';
}
} else if (tagName === 'textarea') {
action = 'fill';
} else if (tagName === 'select') {
action = 'select';
}
// Get label and context
const label = this.getElementLabel(element);
const text = (element.textContent && element.textContent.trim()) || label || element.placeholder || '';
const agentElement = {
id,
type,
action,
text,
label,
tagName,
placeholder: element.placeholder || '',
required: element.hasAttribute('required'),
checked: element.checked,
value: element.value || '',
hasHover: hasHover
};
// Add options for select elements
if (tagName === 'select') {
agentElement.options = Array.from(element.options).map(opt => opt.textContent.trim()).filter(Boolean);
}
return agentElement;
},
// Generate semantic ID for element
generateSemanticId(element, usedIds) {
// Try existing ID first
if (element.id && !usedIds.has(element.id)) {
usedIds.add(element.id);
return element.id;
}
// Try name attribute
if (element.name && !usedIds.has(element.name)) {
usedIds.add(element.name);
return element.name;
}
// Generate from label/context for form fields
const tagName = element.tagName.toLowerCase();
if (['input', 'textarea', 'select'].includes(tagName)) {
const label = this.getElementLabel(element);
if (label) {
const baseId = label.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 30);
if (baseId && !usedIds.has(baseId)) {
usedIds.add(baseId);
return baseId;
}
}
}
// Fallback to type-based ID
const type = (element.type && element.type.toLowerCase()) || tagName;
for (let i = 1; i <= 100; i++) {
const fallbackId = type + '-' + i;
if (!usedIds.has(fallbackId)) {
usedIds.add(fallbackId);
return fallbackId;
}
}
// Ultimate fallback
const ultimateId = tagName + '-' + Date.now();
usedIds.add(ultimateId);
return ultimateId;
},
// Get label for element
getElementLabel(element) {
// Try explicit label first
if (element.id) {
const label = document.querySelector('label[for="' + element.id + '"]');
if (label) {
return label.textContent.trim();
}
}
// Try parent label
const parentLabel = element.closest('label');
if (parentLabel) {
return parentLabel.textContent.trim();
}
// Try aria-label
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel) {
return ariaLabel.trim();
}
// Try placeholder as fallback
const placeholder = element.getAttribute('placeholder');
if (placeholder) {
return placeholder.trim();
}
return '';
},
// Create inline representation of interactive element
createInlineRepresentation(agentElement) {
const { type, action, text, label, placeholder, options, hasHover } = agentElement;
let displayText = label || text || placeholder || type;
displayText = displayText.substring(0, 50);
let actionText = action.toUpperCase();
if (action === 'fill') actionText = 'INPUT';
else if (action === 'select') actionText = 'SELECT';
else if (action === 'toggle') actionText = 'CHECKBOX';
else if (action === 'click') actionText = 'BUTTON';
let result = '**[' + actionText + ': ' + displayText + ']** (ID: ' + agentElement.id + ')';
// Add hover notation if element has hover states
if (hasHover) {
result += ' ** Has Hover State **';
}
// Add options for select
if (options && options.length > 0) {
result += '\n Options: ' + options.slice(0, 7).join(', ');
if (options.length > 7) result += ', ...';
}
return result;
},
// Handle structural elements
handleStructuralElement(element, result, indent) {
const tagName = element.tagName.toLowerCase();
// Headings
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
const level = parseInt(tagName[1]);
const text = element.textContent.trim();
if (text) {
result.content += '\n' + '#'.repeat(level) + ' ' + text + '\n\n';
}
return indent;
}
// Paragraphs
if (['p', 'div'].includes(tagName)) {
const directText = this.getDirectTextContent(element);
if (directText.trim()) {
result.content += '\n' + directText.trim() + '\n';
}
return indent;
}
return indent;
},
// Get direct text content
getDirectTextContent(element) {
let text = '';
Array.from(element.childNodes).forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
});
return text;
},
// Group elements by forms
groupElementsByForms(elements) {
const forms = [];
const formElements = document.querySelectorAll('form');
formElements.forEach((formElement, index) => {
const formId = formElement.id || 'form-' + (index + 1);
const formFields = [];
// Get all interactive elements within the form
const formInteractiveElements = formElement.querySelectorAll('input, textarea, select, button');
// Match them with our processed elements
elements.forEach(element => {
for (let domElement of formInteractiveElements) {
if (this.isMatchingElement(domElement, element)) {
formFields.push(element);
break;
}
}
});
if (formFields.length > 0) {
forms.push({
id: formId,
fields: formFields
});
}
});
return forms;
},
// Check if DOM element matches our processed element
isMatchingElement(domElement, processedElement) {
// Match by ID first
if (domElement.id && domElement.id === processedElement.id) {
return true;
}
// Match by name
if (domElement.name && domElement.name === processedElement.id) {
return true;
}
// Match by type and label combination
const domType = (domElement.type && domElement.type.toLowerCase()) || domElement.tagName.toLowerCase();
const processedType = processedElement.type;
if (domType === processedType) {
const domLabel = this.getElementLabel(domElement);
if (domLabel && domLabel === processedElement.label) {
return true;
}
// Match by placeholder
if (domElement.placeholder && domElement.placeholder === processedElement.placeholder) {
return true;
}
}
return false;
},
// Get statistics about action types
getActionStats(elements) {
const stats = {};
elements.forEach(element => {
const action = element.action || 'unknown';
stats[action] = (stats[action] || 0) + 1;
});
return stats;
},
// Generate action example for element
generateActionExample(element) {
const { id, action, type, options } = element;
if (action === 'fill') {
if (type === 'email') {
return 'execute_action({actionId: "' + id + '", params: {value: "user@example.com"}}) -> Fill email';
} else if (type === 'password') {
return 'execute_action({actionId: "' + id + '", params: {value: "password123"}}) -> Fill password';
} else if (type === 'tel') {
return 'execute_action({actionId: "' + id + '", params: {value: "+1234567890"}}) -> Fill phone';
} else {
return 'execute_action({actionId: "' + id + '", params: {value: "text"}}) -> Fill ' + (element.label || type);
}
} else if (action === 'select' && options && options.length > 0) {
return 'execute_action({actionId: "' + id + '", params: {value: "' + options[0] + '"}}) -> Select option';
} else if (action === 'toggle') {
return 'execute_action({actionId: "' + id + '"}) -> Toggle ' + (element.label || 'checkbox');
} else {
return 'execute_action({actionId: "' + id + '"}) -> Click ' + (element.label || element.text || 'button');
}
}
};
// Also inject the interaction script
window['__AGENT_PAGE__'] = {
execute: function(actionId, params) {
const element = document.querySelector('[data-mcp-id="' + actionId + '"]');
if (!element) throw new Error('Element not found: ' + actionId);
const action = element.getAttribute('data-mcp-action');
if (action === 'fill' && params && params.value !== undefined) {
element.value = params.value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
} else if (action === 'toggle') {
element.checked = !element.checked;
element.dispatchEvent(new Event('change', { bubbles: true }));
} else if (action === 'select' && params && params.value !== undefined) {
if (element.tagName.toLowerCase() === 'select') {
const options = Array.from(element.options);
const option = options.find(opt =>
opt.value === params.value ||
opt.textContent.trim() === params.value
);
if (option) {
element.value = option.value;
element.dispatchEvent(new Event('change', { bubbles: true }));
} else {
throw new Error('Option not found: ' + params.value);
}
}
} else {
element.click();
}
return { success: true, actionId: actionId, action: action };
}
};
}