""" Accessibility (WCAG 2.1 AA) Checker Engine (F-003). Uses Playwright + axe-core for comprehensive accessibility testing. Falls back to BeautifulSoup-based checks if Playwright is unavailable. """ import json import logging import os from pathlib import Path from typing import Optional from bs4 import BeautifulSoup from app.engines.base import BaseChecker from app.models.schemas import CategoryResult, Issue logger = logging.getLogger(__name__) # axe-core JS file path AXE_CORE_JS_PATH = Path(__file__).parent / "axe_core" / "axe.min.js" # Korean message mapping for axe-core rules AXE_RULE_MESSAGES = { "image-alt": ("A-01", "이미지에 대체 텍스트(alt)가 없습니다", "1.1.1"), "color-contrast": ("A-02", "텍스트와 배경의 색상 대비가 부족합니다", "1.4.3"), "keyboard": ("A-03", "키보드로 접근할 수 없는 요소가 있습니다", "2.1.1"), "focus-visible": ("A-04", "키보드 포커스가 시각적으로 표시되지 않습니다", "2.4.7"), "label": ("A-05", "폼 요소에 레이블이 연결되지 않았습니다", "1.3.1"), "input-label": ("A-05", "입력 요소에 레이블이 없습니다", "1.3.1"), "aria-valid-attr": ("A-06", "유효하지 않은 ARIA 속성이 사용되었습니다", "4.1.2"), "aria-roles": ("A-06", "유효하지 않은 ARIA 역할이 사용되었습니다", "4.1.2"), "aria-required-attr": ("A-06", "필수 ARIA 속성이 누락되었습니다", "4.1.2"), "aria-valid-attr-value": ("A-06", "ARIA 속성 값이 올바르지 않습니다", "4.1.2"), "link-name": ("A-07", "링크 텍스트가 목적을 설명하지 않습니다", "2.4.4"), "html-has-lang": ("A-08", "HTML 요소에 lang 속성이 없습니다", "3.1.1"), "html-lang-valid": ("A-08", "HTML lang 속성 값이 올바르지 않습니다", "3.1.1"), "bypass": ("A-09", "건너뛰기 링크(skip navigation)가 없습니다", "2.4.1"), "no-autoplay-audio": ("A-10", "자동 재생 미디어에 정지/음소거 컨트롤이 없습니다", "1.4.2"), "audio-caption": ("A-10", "오디오/비디오에 자막이 없습니다", "1.2.2"), "video-caption": ("A-10", "비디오에 자막이 없습니다", "1.2.2"), } # axe-core impact to severity mapping IMPACT_TO_SEVERITY = { "critical": "critical", "serious": "major", "moderate": "minor", "minor": "info", } class AccessibilityChecker(BaseChecker): """Accessibility (WCAG 2.1 AA) checker engine.""" @property def category_name(self) -> str: return "accessibility" async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult: """ Primary: Playwright + axe-core. Fallback: BeautifulSoup-based basic checks. """ try: return await self._check_with_playwright(url) except Exception as e: logger.warning( "Playwright accessibility check failed, falling back to basic checks: %s", str(e), ) return await self._check_with_beautifulsoup(url, html_content) async def _check_with_playwright(self, url: str) -> CategoryResult: """Run axe-core via Playwright headless browser.""" from playwright.async_api import async_playwright await self.update_progress(10, "브라우저 시작 중...") async with async_playwright() as p: browser = await p.chromium.launch(headless=True) try: page = await browser.new_page() await self.update_progress(20, "페이지 로드 중...") await page.goto(url, wait_until="networkidle", timeout=30000) await self.update_progress(40, "axe-core 주입 중...") # Load axe-core JS if AXE_CORE_JS_PATH.exists() and AXE_CORE_JS_PATH.stat().st_size > 1000: axe_js = AXE_CORE_JS_PATH.read_text(encoding="utf-8") await page.evaluate(axe_js) else: # Fallback: load from CDN await page.evaluate(""" async () => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js'; document.head.appendChild(script); await new Promise((resolve, reject) => { script.onload = resolve; script.onerror = reject; }); } """) await self.update_progress(60, "접근성 검사 실행 중...") axe_results = await page.evaluate(""" () => { return new Promise((resolve, reject) => { if (typeof axe === 'undefined') { reject(new Error('axe-core not loaded')); return; } axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'best-practice'] } }).then(resolve).catch(reject); }); } """) await self.update_progress(80, "결과 분석 중...") issues = self._parse_axe_results(axe_results) score = self._calculate_axe_score(axe_results) finally: await browser.close() await self.update_progress(100, "완료") return self._build_result( category="accessibility", score=score, issues=issues, wcag_level="AA", ) async def _check_with_beautifulsoup(self, url: str, html_content: str) -> CategoryResult: """Fallback: basic accessibility checks using BeautifulSoup.""" soup = BeautifulSoup(html_content, "html5lib") issues: list[Issue] = [] await self.update_progress(20, "이미지 대체 텍스트 검사 중...") issues += self._bs_check_img_alt(soup) await self.update_progress(35, "폼 레이블 검사 중...") issues += self._bs_check_form_labels(soup) await self.update_progress(50, "ARIA 속성 검사 중...") issues += self._bs_check_aria(soup) await self.update_progress(60, "링크 텍스트 검사 중...") issues += self._bs_check_link_text(soup) await self.update_progress(70, "언어 속성 검사 중...") issues += self._bs_check_lang(soup) await self.update_progress(80, "건너뛰기 링크 검사 중...") issues += self._bs_check_skip_nav(soup) await self.update_progress(90, "자동 재생 검사 중...") issues += self._bs_check_autoplay(soup) score = self._calculate_score_by_deduction(issues) await self.update_progress(100, "완료") return self._build_result( category="accessibility", score=score, issues=issues, wcag_level="AA", ) def _parse_axe_results(self, axe_results: dict) -> list[Issue]: """Convert axe-core violations to Issue list with Korean messages.""" issues = [] for violation in axe_results.get("violations", []): rule_id = violation.get("id", "") impact = violation.get("impact", "minor") severity = IMPACT_TO_SEVERITY.get(impact, "info") # Map to our issue codes if rule_id in AXE_RULE_MESSAGES: code, korean_msg, wcag = AXE_RULE_MESSAGES[rule_id] else: code = "A-06" korean_msg = violation.get("description", "접근성 위반 사항이 발견되었습니다") wcag = "4.1.2" # Get affected elements nodes = violation.get("nodes", []) element = None if nodes: html_snippet = nodes[0].get("html", "") if html_snippet: element = html_snippet[:200] # Additional context for color contrast detail = "" if rule_id == "color-contrast" and nodes: data = nodes[0].get("any", [{}]) if data and isinstance(data, list) and len(data) > 0: msg_data = data[0].get("data", {}) if isinstance(msg_data, dict): fg = msg_data.get("fgColor", "") bg = msg_data.get("bgColor", "") ratio = msg_data.get("contrastRatio", "") if ratio: detail = f" (대비율: {ratio}:1, 최소 4.5:1 필요)" # Create the issue with node count info node_count = len(nodes) count_info = f" ({node_count}개 요소)" if node_count > 1 else "" issues.append(self._create_issue( code=code, severity=severity, message=f"{korean_msg}{detail}{count_info}", element=element, suggestion=violation.get("helpUrl", "해당 WCAG 기준을 확인하고 수정하세요"), wcag_criterion=wcag, )) return issues def _calculate_axe_score(self, axe_results: dict) -> int: """ Calculate score based on axe-core violations. critical=-20, serious=-10, moderate=-5, minor=-2 """ severity_weights = { "critical": 20, "serious": 10, "moderate": 5, "minor": 2, } deduction = 0 for violation in axe_results.get("violations", []): impact = violation.get("impact", "minor") deduction += severity_weights.get(impact, 2) return max(0, 100 - deduction) # --- BeautifulSoup fallback checks --- def _bs_check_img_alt(self, soup: BeautifulSoup) -> list[Issue]: """A-01: Check images for alt text.""" issues = [] images = soup.find_all("img") missing = [img for img in images if not img.get("alt") and img.get("alt") != ""] if missing: issues.append(self._create_issue( code="A-01", severity="critical", message=f"alt 속성이 없는 이미지가 {len(missing)}개 발견되었습니다", element=str(missing[0])[:200] if missing else None, suggestion="모든 이미지에 설명적인 대체 텍스트를 추가하세요", wcag_criterion="1.1.1", )) return issues def _bs_check_form_labels(self, soup: BeautifulSoup) -> list[Issue]: """A-05: Check form elements for associated labels.""" issues = [] inputs = soup.find_all(["input", "select", "textarea"]) unlabeled = [] for inp in inputs: input_type = inp.get("type", "text") if input_type in ("hidden", "submit", "button", "reset", "image"): continue inp_id = inp.get("id") has_label = False if inp_id: label = soup.find("label", attrs={"for": inp_id}) if label: has_label = True if inp.get("aria-label") or inp.get("aria-labelledby") or inp.get("title"): has_label = True # Check if wrapped in label parent_label = inp.find_parent("label") if parent_label: has_label = True if not has_label: unlabeled.append(inp) if unlabeled: issues.append(self._create_issue( code="A-05", severity="critical", message=f"레이블이 연결되지 않은 폼 요소가 {len(unlabeled)}개 발견되었습니다", element=str(unlabeled[0])[:200] if unlabeled else None, suggestion="