Files
web-inspector/backend/app/engines/accessibility.py
jungwoo choi b5fa5d96b9 feat: 웹사이트 표준화 검사 도구 구현
- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목)
- FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트)
- Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트)
- Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392)
- 전체 테스트 32/32 PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:57:27 +09:00

423 lines
17 KiB
Python

"""
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="<label for='id'>를 사용하거나 aria-label 속성을 추가하세요",
wcag_criterion="1.3.1",
))
return issues
def _bs_check_aria(self, soup: BeautifulSoup) -> list[Issue]:
"""A-06: Basic ARIA attribute validation."""
issues = []
valid_roles = {
"alert", "alertdialog", "application", "article", "banner", "button",
"cell", "checkbox", "columnheader", "combobox", "complementary",
"contentinfo", "definition", "dialog", "directory", "document",
"feed", "figure", "form", "grid", "gridcell", "group", "heading",
"img", "link", "list", "listbox", "listitem", "log", "main",
"marquee", "math", "menu", "menubar", "menuitem", "menuitemcheckbox",
"menuitemradio", "navigation", "none", "note", "option", "presentation",
"progressbar", "radio", "radiogroup", "region", "row", "rowgroup",
"rowheader", "scrollbar", "search", "searchbox", "separator",
"slider", "spinbutton", "status", "switch", "tab", "table",
"tablist", "tabpanel", "term", "textbox", "timer", "toolbar",
"tooltip", "tree", "treegrid", "treeitem",
}
elements_with_role = soup.find_all(attrs={"role": True})
invalid_roles = []
for el in elements_with_role:
role = el.get("role", "").strip().lower()
if role and role not in valid_roles:
invalid_roles.append(el)
if invalid_roles:
issues.append(self._create_issue(
code="A-06",
severity="major",
message=f"유효하지 않은 ARIA 역할이 {len(invalid_roles)}개 발견되었습니다",
element=str(invalid_roles[0])[:200] if invalid_roles else None,
suggestion="올바른 ARIA 역할을 사용하세요 (WAI-ARIA 명세 참조)",
wcag_criterion="4.1.2",
))
return issues
def _bs_check_link_text(self, soup: BeautifulSoup) -> list[Issue]:
"""A-07: Check link text clarity."""
issues = []
vague_texts = {"click here", "here", "more", "read more", "link", "여기", "더보기", "클릭"}
links = soup.find_all("a")
vague_links = []
for link in links:
text = link.get_text(strip=True).lower()
if text in vague_texts:
vague_links.append(link)
if vague_links:
issues.append(self._create_issue(
code="A-07",
severity="minor",
message=f"목적이 불분명한 링크 텍스트가 {len(vague_links)}개 발견되었습니다",
element=str(vague_links[0])[:200] if vague_links else None,
suggestion="'여기를 클릭하세요' 대신 구체적인 링크 목적을 설명하는 텍스트를 사용하세요",
wcag_criterion="2.4.4",
))
return issues
def _bs_check_lang(self, soup: BeautifulSoup) -> list[Issue]:
"""A-08: Check page language attribute."""
html_tag = soup.find("html")
if html_tag is None or not html_tag.get("lang"):
return [self._create_issue(
code="A-08",
severity="major",
message="HTML 요소에 lang 속성이 없습니다",
suggestion='<html lang="ko">와 같이 페이지 언어를 명시하세요',
wcag_criterion="3.1.1",
)]
return []
def _bs_check_skip_nav(self, soup: BeautifulSoup) -> list[Issue]:
"""A-09: Check for skip navigation link."""
# Look for skip nav patterns
skip_links = soup.find_all("a", href=True)
has_skip = False
for link in skip_links[:10]: # Check first 10 links
href = link.get("href", "")
text = link.get_text(strip=True).lower()
if href.startswith("#") and any(
keyword in text
for keyword in ["skip", "본문", "건너뛰기", "main", "content"]
):
has_skip = True
break
if not has_skip:
return [self._create_issue(
code="A-09",
severity="minor",
message="건너뛰기 링크(skip navigation)가 없습니다",
suggestion='페이지 상단에 <a href="#main-content">본문으로 건너뛰기</a> 링크를 추가하세요',
wcag_criterion="2.4.1",
)]
return []
def _bs_check_autoplay(self, soup: BeautifulSoup) -> list[Issue]:
"""A-10: Check for autoplay media without controls."""
issues = []
media = soup.find_all(["video", "audio"])
for el in media:
if el.get("autoplay") is not None:
has_controls = el.get("controls") is not None or el.get("muted") is not None
if not has_controls:
issues.append(self._create_issue(
code="A-10",
severity="major",
message="자동 재생 미디어에 정지/음소거 컨트롤이 없습니다",
element=str(el)[:200],
suggestion="autoplay 미디어에 controls 속성을 추가하거나 muted 속성을 사용하세요",
wcag_criterion="1.4.2",
))
break # Report only first
return issues