Spaces:
Sleeping
Sleeping
| """ | |
| Accessibility audit and testing features | |
| """ | |
| import json | |
| import logging | |
| from datetime import datetime | |
| from browser.driver import get_driver, cleanup_driver | |
| logger = logging.getLogger(__name__) | |
| def accessibility_audit(url: str, use_persistent: bool = False) -> str: | |
| """Perform basic accessibility audit on a page""" | |
| driver = None | |
| try: | |
| driver = get_driver(url, use_persistent) | |
| # Run accessibility checks | |
| audit_results = driver.execute_script(""" | |
| const results = { | |
| images_without_alt: [], | |
| form_inputs_without_labels: [], | |
| low_contrast_elements: [], | |
| missing_landmarks: [], | |
| heading_structure: [], | |
| interactive_elements_without_text: [], | |
| links_without_text: [], | |
| aria_issues: [] | |
| }; | |
| // Check images without alt text | |
| document.querySelectorAll('img:not([alt])').forEach(img => { | |
| results.images_without_alt.push(img.src || 'no-src'); | |
| }); | |
| // Check form inputs without labels | |
| document.querySelectorAll('input, select, textarea').forEach(input => { | |
| const id = input.id; | |
| const hasLabel = id && document.querySelector(`label[for="${id}"]`); | |
| const hasAriaLabel = input.getAttribute('aria-label'); | |
| const hasAriaLabelledBy = input.getAttribute('aria-labelledby'); | |
| if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy) { | |
| results.form_inputs_without_labels.push({ | |
| type: input.type, | |
| name: input.name, | |
| id: id || 'no-id' | |
| }); | |
| } | |
| }); | |
| // Check heading structure | |
| const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); | |
| let lastLevel = 0; | |
| headings.forEach(heading => { | |
| const level = parseInt(heading.tagName.substring(1)); | |
| results.heading_structure.push({ | |
| level: level, | |
| text: heading.textContent.trim().substring(0, 50), | |
| issue: level > lastLevel + 1 ? 'skipped_level' : null | |
| }); | |
| lastLevel = level; | |
| }); | |
| // Check for landmarks | |
| const landmarks = { | |
| header: document.querySelector('header, [role="banner"]'), | |
| nav: document.querySelector('nav, [role="navigation"]'), | |
| main: document.querySelector('main, [role="main"]'), | |
| footer: document.querySelector('footer, [role="contentinfo"]') | |
| }; | |
| for (const [role, element] of Object.entries(landmarks)) { | |
| if (!element) { | |
| results.missing_landmarks.push(role); | |
| } | |
| } | |
| // Check interactive elements without text | |
| document.querySelectorAll('button, a').forEach(elem => { | |
| const text = elem.textContent.trim(); | |
| const ariaLabel = elem.getAttribute('aria-label'); | |
| const ariaLabelledBy = elem.getAttribute('aria-labelledby'); | |
| if (!text && !ariaLabel && !ariaLabelledBy) { | |
| results.interactive_elements_without_text.push({ | |
| tag: elem.tagName, | |
| class: elem.className, | |
| href: elem.href || null | |
| }); | |
| } | |
| }); | |
| // Check links without meaningful text | |
| document.querySelectorAll('a[href]').forEach(link => { | |
| const text = link.textContent.trim().toLowerCase(); | |
| if (text === 'click here' || text === 'here' || text === 'read more') { | |
| results.links_without_text.push({ | |
| text: text, | |
| href: link.href | |
| }); | |
| } | |
| }); | |
| // Check ARIA issues | |
| document.querySelectorAll('[aria-hidden="true"]').forEach(elem => { | |
| if (elem.querySelector('a, button, input, select, textarea')) { | |
| results.aria_issues.push({ | |
| issue: 'interactive_element_hidden', | |
| element: elem.tagName + (elem.className ? '.' + elem.className : '') | |
| }); | |
| } | |
| }); | |
| return results; | |
| """) | |
| # Calculate score | |
| issues_count = sum(len(v) if isinstance(v, list) else 0 for v in audit_results.values()) | |
| score = max(0, 100 - (issues_count * 5)) # Deduct 5 points per issue | |
| result = { | |
| "url": url, | |
| "accessibility_score": score, | |
| "grade": get_grade(score), | |
| "issues": audit_results, | |
| "total_issues": issues_count, | |
| "timestamp": datetime.now().isoformat(), | |
| "recommendations": get_recommendations(audit_results) | |
| } | |
| return json.dumps(result, indent=2) | |
| except Exception as e: | |
| logger.error(f"Error in accessibility_audit: {e}") | |
| return f"Error: {e}" | |
| finally: | |
| cleanup_driver(driver, use_persistent) | |
| def get_grade(score): | |
| """Convert score to letter grade""" | |
| if score >= 90: | |
| return "A" | |
| elif score >= 80: | |
| return "B" | |
| elif score >= 70: | |
| return "C" | |
| elif score >= 60: | |
| return "D" | |
| else: | |
| return "F" | |
| def get_recommendations(audit_results): | |
| """Get recommendations based on audit results""" | |
| recommendations = [] | |
| if audit_results["images_without_alt"]: | |
| recommendations.append("Add alt text to all images for screen reader users") | |
| if audit_results["form_inputs_without_labels"]: | |
| recommendations.append("Associate all form inputs with labels using 'for' attribute or aria-label") | |
| if audit_results["missing_landmarks"]: | |
| recommendations.append("Add semantic HTML5 landmarks (header, nav, main, footer) or ARIA roles") | |
| if audit_results["heading_structure"]: | |
| has_issues = any(h["issue"] for h in audit_results["heading_structure"]) | |
| if has_issues: | |
| recommendations.append("Fix heading hierarchy - don't skip heading levels") | |
| if audit_results["interactive_elements_without_text"]: | |
| recommendations.append("Add descriptive text or aria-labels to all interactive elements") | |
| return recommendations | |
| def check_color_contrast(url: str, use_persistent: bool = False) -> str: | |
| """Check color contrast for text elements""" | |
| driver = None | |
| try: | |
| driver = get_driver(url, use_persistent) | |
| # Check color contrast | |
| contrast_results = driver.execute_script(""" | |
| function getLuminance(r, g, b) { | |
| const sRGB = [r, g, b].map(val => { | |
| val = val / 255; | |
| return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); | |
| }); | |
| return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2]; | |
| } | |
| function getContrastRatio(color1, color2) { | |
| const l1 = getLuminance(...color1); | |
| const l2 = getLuminance(...color2); | |
| const lighter = Math.max(l1, l2); | |
| const darker = Math.min(l1, l2); | |
| return (lighter + 0.05) / (darker + 0.05); | |
| } | |
| function getRGBValues(colorStr) { | |
| const match = colorStr.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); | |
| if (match) { | |
| return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]; | |
| } | |
| return null; | |
| } | |
| const results = []; | |
| const textElements = document.querySelectorAll('p, span, div, h1, h2, h3, h4, h5, h6, a, button'); | |
| textElements.forEach(elem => { | |
| const style = window.getComputedStyle(elem); | |
| const color = getRGBValues(style.color); | |
| const bgColor = getRGBValues(style.backgroundColor); | |
| if (color && bgColor) { | |
| const ratio = getContrastRatio(color, bgColor); | |
| const fontSize = parseFloat(style.fontSize); | |
| const fontWeight = style.fontWeight; | |
| // WCAG AA standards | |
| const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700); | |
| const requiredRatio = isLargeText ? 3 : 4.5; | |
| if (ratio < requiredRatio) { | |
| results.push({ | |
| element: elem.tagName, | |
| text: elem.textContent.trim().substring(0, 50), | |
| contrastRatio: ratio.toFixed(2), | |
| requiredRatio: requiredRatio, | |
| fontSize: fontSize, | |
| isLargeText: isLargeText | |
| }); | |
| } | |
| } | |
| }); | |
| return results; | |
| """) | |
| return json.dumps({ | |
| "low_contrast_elements": contrast_results, | |
| "total_issues": len(contrast_results) | |
| }, indent=2) | |
| except Exception as e: | |
| logger.error(f"Error in check_color_contrast: {e}") | |
| return f"Error: {e}" | |
| finally: | |
| cleanup_driver(driver, use_persistent) |