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.

339 lines (338 loc) 13 kB
/** * Form detection and JSON template generation * Helps agents understand what data to send for form filling */ export class FormDetector { /** * Detect all forms on the page and generate JSON templates */ static detectForms() { const forms = []; // First, find all elements that look like forms const formContainers = this.findFormContainers(); formContainers.forEach((container, index) => { const fields = this.extractFormFields(container); if (fields.length > 0) { const formId = container.id || container.getAttribute('data-mcp-id') || `form-${index}`; const formName = this.getFormName(container, fields); forms.push({ formId, formName, fields, jsonTemplate: this.generateJsonTemplate(fields), example: this.generateExampleData(fields), description: this.generateFormDescription(fields) }); } }); return forms; } /** * Find all form containers (actual forms only) */ static findFormContainers() { // Only return actual <form> elements, not divs that happen to contain inputs return Array.from(document.querySelectorAll('form')); } /** * Extract all form fields from a container */ static extractFormFields(container) { const fields = []; const inputs = container.querySelectorAll('[data-mcp-id]'); inputs.forEach(element => { const tagName = element.tagName.toLowerCase(); const type = element.getAttribute('type') || tagName; const mcpAction = element.getAttribute('data-mcp-action'); // Skip non-input elements if (!['fill', 'choose', 'toggle', 'select', 'upload'].includes(mcpAction || '')) { return; } const field = { id: element.getAttribute('data-mcp-id') || '', type: element.getAttribute('data-mcp-type') || type, name: element.getAttribute('name') || element.id || '', required: element.hasAttribute('required') || element.getAttribute('aria-required') === 'true' || this.hasRequiredIndicator(element), placeholder: element.getAttribute('placeholder') || undefined, label: this.getFieldLabel(element), value: this.getCurrentValue(element) }; // Add type-specific properties if (tagName === 'select') { field.options = this.getSelectOptions(element); } if (tagName === 'input') { const inputEl = element; field.minLength = inputEl.minLength > 0 ? inputEl.minLength : undefined; field.maxLength = inputEl.maxLength > 0 ? inputEl.maxLength : undefined; field.pattern = inputEl.pattern || undefined; } fields.push(field); }); return fields; } /** * Get the label for a field */ static getFieldLabel(element) { // Try various methods to find the label const id = element.id; if (id) { const label = document.querySelector(`label[for="${id}"]`); if (label) return label.textContent?.trim() || ''; } // Parent label const parentLabel = element.closest('label'); if (parentLabel) { const clone = parentLabel.cloneNode(true); const input = clone.querySelector('input, select, textarea'); if (input) input.remove(); return clone.textContent?.trim() || ''; } // Look for adjacent label const prev = element.previousElementSibling; if (prev && prev.tagName === 'LABEL') { return prev.textContent?.trim() || ''; } // Look for label in parent container const parent = element.parentElement; if (parent) { const label = parent.querySelector('label'); if (label && !label.querySelector('input, select, textarea')) { return label.textContent?.trim() || ''; } } return ''; } /** * Check if field has a required indicator (like * or "required" text) */ static hasRequiredIndicator(element) { const label = this.getFieldLabel(element); if (label && (label.includes('*') || label.toLowerCase().includes('required'))) { return true; } // Check parent container for required indicators const parent = element.parentElement; if (parent) { const text = parent.textContent || ''; return text.includes('*') || text.toLowerCase().includes('required'); } return false; } /** * Get current value of a field */ static getCurrentValue(element) { const tagName = element.tagName.toLowerCase(); const type = element.getAttribute('type'); if (tagName === 'input') { if (type === 'checkbox' || type === 'radio') { return element.checked; } return element.value; } else if (tagName === 'textarea') { return element.value; } else if (tagName === 'select') { return element.value; } return ''; } /** * Get options for a select field */ static getSelectOptions(select) { return Array.from(select.options) .filter(opt => opt.value) // Skip empty options .map(opt => { const displayText = opt.textContent?.trim() || opt.value; // If display text is different from value, show both if (displayText !== opt.value && displayText.length > 0) { return `${displayText} (${opt.value})`; } return opt.value; }); } /** * Generate a JSON template with field types */ static generateJsonTemplate(fields) { const template = {}; fields.forEach(field => { let value = `<${field.type}>`; // Provide more specific templates based on type switch (field.type) { case 'email': value = '<email@example.com>'; break; case 'phone': case 'tel': value = '<+1234567890>'; break; case 'password': value = '<password>'; break; case 'number': value = '<number>'; break; case 'date': value = '<YYYY-MM-DD>'; break; case 'checkbox': case 'radio': value = '<true|false>'; break; case 'select': if (field.options && field.options.length > 0) { // Show user-friendly options with note about display text const displayOptions = field.options.map(opt => { if (opt.includes('(') && opt.includes(')')) { // Extract display text from format "Display Text (value)" const match = opt.match(/^(.+?)\s*\((.+?)\)$/); if (match) return match[1]; // Return display text } return opt; }); value = `<${displayOptions.join('|')}> (use display text or technical value)`; } break; case 'url': value = '<https://example.com>'; break; default: value = '<string>'; } // Add required indicator if (field.required) { template[field.id] = `${value} (required)`; } else { template[field.id] = `${value} (optional)`; } }); return template; } /** * Generate example data for the form */ static generateExampleData(fields) { const example = {}; fields.forEach(field => { // Skip optional fields in example (show minimal required data) if (!field.required) return; switch (field.type) { case 'email': example[field.id] = 'john.doe@example.com'; break; case 'phone': case 'tel': example[field.id] = '+1 (555) 123-4567'; break; case 'password': example[field.id] = 'SecurePass123!'; break; case 'text': if (field.label?.toLowerCase().includes('first')) { example[field.id] = 'John'; } else if (field.label?.toLowerCase().includes('last')) { example[field.id] = 'Doe'; } else if (field.label?.toLowerCase().includes('address')) { example[field.id] = '123 Main Street'; } else if (field.label?.toLowerCase().includes('city')) { example[field.id] = 'New York'; } else if (field.label?.toLowerCase().includes('zip') || field.label?.toLowerCase().includes('postal')) { example[field.id] = '10001'; } else { example[field.id] = 'Example text'; } break; case 'number': example[field.id] = 123; break; case 'date': example[field.id] = '1990-01-01'; break; case 'checkbox': example[field.id] = true; break; case 'select': if (field.options && field.options.length > 0) { example[field.id] = field.options[0]; } break; case 'url': example[field.id] = 'https://example.com'; break; default: example[field.id] = 'Example value'; } }); return example; } /** * Generate a description of the form */ static generateFormDescription(fields) { const requiredCount = fields.filter(f => f.required).length; const optionalCount = fields.length - requiredCount; let description = `Form with ${fields.length} fields`; if (requiredCount > 0) { description += ` (${requiredCount} required`; if (optionalCount > 0) { description += `, ${optionalCount} optional`; } description += ')'; } // Add field type summary const types = new Set(fields.map(f => f.type)); description += '. Field types: ' + Array.from(types).join(', '); return description; } /** * Try to determine form name/purpose */ static getFormName(container, fields) { // Check for heading const heading = container.querySelector('h1, h2, h3, h4, h5, h6'); if (heading) { return heading.textContent?.trim() || 'Form'; } // Check for form title/legend const legend = container.querySelector('legend'); if (legend) { return legend.textContent?.trim() || 'Form'; } // Try to infer from fields const fieldTypes = fields.map(f => f.type); const fieldLabels = fields.map(f => f.label?.toLowerCase() || '').join(' '); if (fieldTypes.includes('email') && fieldTypes.includes('password')) { if (fieldLabels.includes('register') || fieldLabels.includes('sign up')) { return 'Registration Form'; } return 'Login Form'; } if (fieldLabels.includes('contact') || fieldLabels.includes('message')) { return 'Contact Form'; } if (fieldLabels.includes('payment') || fieldLabels.includes('card')) { return 'Payment Form'; } if (fieldLabels.includes('search')) { return 'Search Form'; } return 'Form'; } }